feat(easel): implement multiple weather providers

This commit is contained in:
2026-06-23 16:22:17 +03:00
parent 3096f97aa7
commit d9129a4947
10 changed files with 321 additions and 19 deletions

View File

@@ -2,4 +2,6 @@ DOCKER_REPO=git.shmyga.ru
DOCKER_GROUP=infernalgames DOCKER_GROUP=infernalgames
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP" DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+') VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
DOCKER_PROJECTS=("gallery") DOCKER_PROJECTS=("gallery")
OPENWEATHER_KEY="<EMPTY>"

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@
.venv .venv
#.vscode #.vscode
static/node_modules static/node_modules
static/dist static/dist
.env

View File

@@ -8,6 +8,7 @@
"python.testing.pytestArgs": ["tests", "-s"], "python.testing.pytestArgs": ["tests", "-s"],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.terminal.useEnvFile": true,
"python-envs.pythonProjects": [ "python-envs.pythonProjects": [
{ {
"path": ".", "path": ".",

View File

@@ -50,7 +50,13 @@ def build_weather_response(request: AppRequest, response: WeatherResponse):
router = APIRouter(prefix="/weather") router = APIRouter(prefix="/weather")
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=RedirectResponse)
async def get_weather(request: AppRequest):
default_provider = request.app.state.api.get_api_providers(WeatherApi.TYPE)[0]
return RedirectResponse(default_provider)
@router.get("/{provider}", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends, query: str | None = None): async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends, query: str | None = None):
locations = (await weather_api.find_locations(query)) if query else [] locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -62,12 +68,12 @@ async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends,
) )
@router.get("/{location}", response_class=RedirectResponse) @router.get("/{provider}/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str): async def get_weather_default(location: str):
return RedirectResponse(f"{location}/tag/today") return RedirectResponse(f"{location}/tag/today")
@router.get("/{location}/day/{date}", response_class=HTMLResponse) @router.get("/{provider}/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day( async def get_weather_day(
request: AppRequest, request: AppRequest,
weather_api: WeatherApiDepends, weather_api: WeatherApiDepends,
@@ -78,13 +84,13 @@ async def get_weather_day(
return build_weather_response(request, response) return build_weather_response(request, response)
@router.get("/{location}/days/{days}", response_class=HTMLResponse) @router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int): async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
response = await weather_api.get_days(location, days) response = await weather_api.get_days(location, days)
return build_weather_response(request, response) return build_weather_response(request, response)
@router.get("/{location}/tag/{tag}", response_class=HTMLResponse) @router.get("/{provider}/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, location: str, tag: str): async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, location: str, tag: str):
tag_value = TagUtil.parse_tag(tag) tag_value = TagUtil.parse_tag(tag)
if tag_value.type == TagType.DAY: if tag_value.type == TagType.DAY:

View File

@@ -36,7 +36,7 @@
<ul id="locations" <ul id="locations"
class="list-group mb-5"> class="list-group mb-5">
{% for location in locations %} {% for location in locations %}
<a href="{{location.id}}" <a href="{{ url_for('get_weather_default', provider=request.path_params.provider, location=location.id) }}"
class="list-group-item list-group-item-action px-4" class="list-group-item list-group-item-action px-4"
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});"> onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
<span class="fi fi-{{location.country_code}} me-1"></span> <span class="fi fi-{{location.country_code}} me-1"></span>
@@ -50,13 +50,20 @@
</ul> </ul>
<script> <script>
(function () { (function () {
const getStorageKey = () => {
const provider = window.location.pathname.split('/').pop();
return `locations:${provider}`;
}
document.loadLocations = () => { document.loadLocations = () => {
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}'); const provider = window.location.pathname.split('/').pop();
console.log('!', provider);
const locations = JSON.parse(window.localStorage.getItem(getStorageKey()) || '{}');
const container = document.querySelector('#locations'); const container = document.querySelector('#locations');
container.innerHTML = ''; container.innerHTML = '';
for (const [id, name] of Object.entries(locations)) { for (const [id, name] of Object.entries(locations)) {
const element = document.createElement('a'); const element = document.createElement('a');
element.href = `${id}`; element.href = `${window.location.pathname}/${id}`;
element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start'; element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start';
element.innerHTML = ` element.innerHTML = `
<span class="text-primary me-auto">${name}</span> <span class="text-primary me-auto">${name}</span>
@@ -67,15 +74,15 @@
} }
document.saveLocation = (location) => { document.saveLocation = (location) => {
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}'); const locations = JSON.parse(window.localStorage.getItem(getStorageKey()) || '{}');
locations[location.id] = location.name; locations[location.id] = location.name;
window.localStorage.setItem('locations', JSON.stringify(locations)); window.localStorage.setItem(getStorageKey(), JSON.stringify(locations));
} }
document.removeLocation = (id) => { document.removeLocation = (id) => {
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}'); const locations = JSON.parse(window.localStorage.getItem(getStorageKey()) || '{}');
delete locations[id]; delete locations[id];
window.localStorage.setItem('locations', JSON.stringify(locations)); window.localStorage.setItem(getStorageKey(), JSON.stringify(locations));
document.loadLocations(); document.loadLocations();
} }

View File

@@ -89,7 +89,9 @@ class GismeteoApi(WeatherApi):
lon=item["coordinates"]["longitude"], lon=item["coordinates"]["longitude"],
country=item["translations"]["kk"]["country"]["name"], country=item["translations"]["kk"]["country"]["name"],
country_code=item["country"]["code"].lower(), country_code=item["country"]["code"].lower(),
district=item["translations"]["kk"]["district"]["name"], district=(
item["translations"]["kk"]["district"]["name"] if item["translations"]["kk"]["district"] else ""
),
subdistrict=( subdistrict=(
item["translations"]["kk"]["subdistrict"]["name"] item["translations"]["kk"]["subdistrict"]["name"]
if "subdistrict" in item["translations"]["kk"] if "subdistrict" in item["translations"]["kk"]

View File

@@ -1,6 +1,7 @@
import datetime import datetime
import logging import logging
from collections import defaultdict from collections import defaultdict
from os import environ
from aiocache import cached from aiocache import cached
@@ -17,7 +18,7 @@ logger = logging.getLogger("openweather")
class OpenWeatherApi(WeatherApi): class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather" PROVIDER = "openweather"
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf") SOURCE = OpenWeather(environ["OPENWEATHER_KEY"])
@classmethod @classmethod
def _parse_location(cls, location_id: str) -> tuple[float, float]: def _parse_location(cls, location_id: str) -> tuple[float, float]:
@@ -32,7 +33,20 @@ class OpenWeatherApi(WeatherApi):
return await self.SOURCE.get_forecast(*self._parse_location(location_id)) return await self.SOURCE.get_forecast(*self._parse_location(location_id))
async def find_locations(self, query: str) -> list[Location]: async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError result = await self.SOURCE.get_locations(query)
return [
Location(
id=f"{item.lat}:{item.lon}",
name=item.name,
lat=item.lat,
lon=item.lon,
country=item.country,
country_code=item.country.lower(),
district=item.state,
subdistrict="",
)
for item in result
]
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data: Forecast = await self._get_location_forecast(location_id) data: Forecast = await self._get_location_forecast(location_id)

View File

@@ -67,6 +67,15 @@ class Forecast(Model):
list: list[ForecastItem] list: list[ForecastItem]
class Location(Model):
name: str
local_names: dict[str, str]
lat: float
lon: float
country: str
state: str
class OpenWeather: class OpenWeather:
BASE_URL = "https://api.openweathermap.org" BASE_URL = "https://api.openweathermap.org"
@@ -78,4 +87,10 @@ class OpenWeather:
endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric" endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
response = await self._source.request(endpoint) response = await self._source.request(endpoint)
response_data = json.loads(response) response_data = json.loads(response)
return Forecast(**response_data) return Forecast.model_validate(response_data)
async def get_locations(self, query: str, limit: int = 5) -> list[Location]:
endpoint = f"geo/1.0/direct?q={query}&limit={limit}&appid={self._api_key}"
response = await self._source.request(endpoint)
response_data = json.loads(response)
return [Location.model_validate(item) for item in response_data]

View File

@@ -20,7 +20,7 @@ class ApiBundle:
if isinstance(value, api_type): if isinstance(value, api_type):
if provider is None or provider == value.provider: if provider is None or provider == value.provider:
return value return value
raise ValueError(api_type) raise ValueError(api_type, provider)
def get_weather(self, provider: str | None = None) -> WeatherApi: def get_weather(self, provider: str | None = None) -> WeatherApi:
return self.get_api(WeatherApi, provider) return self.get_api(WeatherApi, provider)

View File

@@ -0,0 +1,254 @@
[
{
"name": "London",
"local_names": {
"fo": "London",
"nn": "London",
"tg": "Лондон",
"uk": "Лондон",
"lt": "Londonas",
"zu": "ILondon",
"eo": "Londono",
"os": "Лондон",
"pa": "ਲੰਡਨ",
"jv": "London",
"hu": "London",
"gd": "Lunnainn",
"hy": "Լոնդոն",
"to": "Lonitoni",
"sr": "Лондон",
"sv": "London",
"ku": "London",
"te": "లండన్",
"tw": "London",
"co": "Londra",
"eu": "Londres",
"et": "London",
"ca": "Londres",
"nl": "Londen",
"kl": "London",
"fi": "Lontoo",
"az": "London",
"mr": "लंडन",
"km": "ឡុងដ៍",
"af": "Londen",
"hi": "लंदन",
"gl": "Londres",
"ka": "ლონდონი",
"ff": "London",
"de": "London",
"sl": "London",
"th": "ลอนดอน",
"bn": "লন্ডন",
"pl": "Londyn",
"an": "Londres",
"bg": "Лондон",
"ht": "Lonn",
"so": "London",
"mn": "Лондон",
"id": "London",
"ko": "런던",
"yo": "Lọndọnu",
"sa": "लन्डन्",
"nv": "Tooh Dineʼé Bikin Haalʼá",
"br": "Londrez",
"rm": "Londra",
"ascii": "London",
"en": "London",
"ba": "Лондон",
"ga": "Londain",
"tr": "Londra",
"om": "Landan",
"bh": "लंदन",
"gu": "લંડન",
"ee": "London",
"se": "London",
"vi": "Luân Đôn",
"sk": "Londýn",
"lb": "London",
"cs": "Londýn",
"io": "London",
"ab": "Лондон",
"ug": "لوندۇن",
"ha": "Landan",
"wo": "Londar",
"bi": "London",
"st": "London",
"fj": "Lodoni",
"tt": "Лондон",
"mt": "Londra",
"vo": "London",
"bs": "London",
"or": "ଲଣ୍ଡନ",
"is": "London",
"tk": "London",
"he": "לונדון",
"ia": "London",
"kn": "ಲಂಡನ್",
"ie": "London",
"es": "Londres",
"my": "လန်ဒန်မြို့",
"sn": "London",
"zh": "伦敦",
"wa": "Londe",
"fy": "Londen",
"sq": "Londra",
"be": "Лондан",
"oc": "Londres",
"hr": "London",
"da": "London",
"bm": "London",
"ru": "Лондон",
"ne": "लन्डन",
"pt": "Londres",
"lo": "ລອນດອນ",
"ro": "Londra",
"cu": "Лондонъ",
"ce": "Лондон",
"ny": "London",
"am": "ለንደን",
"gv": "Lunnin",
"no": "London",
"it": "Londra",
"cv": "Лондон",
"ta": "இலண்டன்",
"ar": "لندن",
"av": "Лондон",
"sm": "Lonetona",
"ja": "ロンドン",
"ky": "Лондон",
"si": "ලන්ඩන්",
"tl": "Londres",
"na": "London",
"sw": "London",
"mi": "Rānana",
"lv": "Londona",
"gn": "Lóndyre",
"su": "London",
"mg": "Lôndôna",
"ml": "ലണ്ടൻ",
"fr": "Londres",
"ur": "علاقہ لندن",
"kw": "Loundres",
"yi": "לאנדאן",
"ig": "London",
"sc": "Londra",
"ln": "Lóndɛlɛ",
"kk": "Лондон",
"el": "Λονδίνο",
"sd": "لنڊن",
"li": "Londe",
"cy": "Llundain",
"ms": "London",
"fa": "لندن",
"sh": "London",
"feature_name": "London",
"uz": "London",
"bo": "ལོན་ཊོན།",
"mk": "Лондон",
"qu": "London",
"ay": "London",
"kv": "Лондон",
"ps": "لندن"
},
"lat": 51.5073219,
"lon": -0.1276474,
"country": "GB",
"state": "England"
},
{
"name": "City of London",
"local_names": {
"lt": "Londono Sitis",
"zh": "倫敦市",
"hi": "सिटी ऑफ़ लंदन",
"ko": "시티 오브 런던",
"es": "City de Londres",
"ur": "لندن شہر",
"uk": "Лондонське Сіті",
"he": "הסיטי של לונדון",
"en": "City of London",
"pt": "Cidade de Londres",
"fr": "Cité de Londres",
"ru": "Сити"
},
"lat": 51.5156177,
"lon": -0.0919983,
"country": "GB",
"state": "England"
},
{
"name": "London",
"local_names": {
"ga": "Londain",
"ka": "ლონდონი",
"yi": "לאנדאן",
"cr": "ᓬᐊᐣᑕᐣ",
"th": "ลอนดอน",
"he": "לונדון",
"el": "Λόντον",
"lt": "Londonas",
"ja": "ロンドン",
"fa": "لندن",
"bn": "লন্ডন",
"iu": "ᓚᓐᑕᓐ",
"ru": "Лондон",
"oj": "Baketigweyaang",
"ug": "لوندۇن",
"en": "London",
"be": "Лондан",
"ar": "لندن",
"fr": "London",
"lv": "Landona",
"ko": "런던",
"hy": "Լոնտոն"
},
"lat": 42.9832406,
"lon": -81.243372,
"country": "CA",
"state": "Ontario"
},
{
"name": "Chelsea",
"local_names": {
"id": "Chelsea, London",
"ru": "Челси",
"ga": "Chelsea",
"ur": "چیلسی، لندن",
"et": "Chelsea",
"pl": "Chelsea",
"da": "Chelsea",
"ar": "تشيلسي",
"fa": "چلسی",
"ko": "첼시",
"sv": "Chelsea, London",
"es": "Chelsea",
"eu": "Chelsea",
"nl": "Chelsea",
"sk": "Chelsea",
"no": "Chelsea",
"af": "Chelsea, Londen",
"uk": "Челсі",
"he": "צ'לסי",
"it": "Chelsea",
"hu": "Chelsea",
"ja": "チェルシー",
"zh": "車路士",
"hi": "चेल्सी, लंदन",
"pt": "Chelsea",
"az": "Çelsi",
"el": "Τσέλσι",
"vi": "Chelsea, Luân Đôn",
"en": "Chelsea",
"de": "Chelsea",
"tr": "Chelsea, Londra",
"fr": "Chelsea",
"sh": "Chelsea, London"
},
"lat": 51.4875167,
"lon": -0.1687007,
"country": "GB",
"state": "England"
},
{ "name": "London", "lat": 37.1289771, "lon": -84.0832646, "country": "US", "state": "Kentucky" }
]