refactor(easel): add weather routes
This commit is contained in:
85
gallery/easel/route/view/weather/__init__.py
Normal file
85
gallery/easel/route/view/weather/__init__.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
|
||||
from gallery.sketch.weather.model import WeatherResponse
|
||||
|
||||
from .filters import cloudness_icon, wind_direction_icon
|
||||
from .util import TagType, TagUtil
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount(
|
||||
"/weather/static", StaticFiles(directory=base_dir / "static"), name="static"
|
||||
)
|
||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
||||
|
||||
def build_weather_response(request: Request, response: WeatherResponse):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="weather.html",
|
||||
context={
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/weather", response_class=HTMLResponse)
|
||||
async def get_weather_list(request: Request):
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
locations = await weather_api.get_locations()
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"locations": locations,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/weather/{location}", response_class=RedirectResponse)
|
||||
async def get_weather_default(location: str):
|
||||
return RedirectResponse(f"{location}/tag/today")
|
||||
|
||||
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
|
||||
async def get_weather_day_mock(request: Request):
|
||||
response = WEATHER_MOCK_DATA.get_response("day")
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
|
||||
async def get_weather_days_mock(request: Request):
|
||||
response = WEATHER_MOCK_DATA.get_response("days")
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
||||
async def get_weather_day(request: Request, location: str, date: datetime.date):
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
response = await weather_api.get_day(location, date)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
||||
async def get_weather_days(request: Request, location: str, days: int):
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
response = await weather_api.get_days(location, days)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_weather_tag(request: Request, location: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await weather_api.get_day(location, tag_value.date)
|
||||
elif tag_value.type == TagType.DAYS:
|
||||
response = await weather_api.get_days(location, tag_value.days)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return build_weather_response(request, response)
|
||||
39
gallery/easel/route/view/weather/filters.py
Normal file
39
gallery/easel/route/view/weather/filters.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
|
||||
|
||||
|
||||
def wind_direction_icon(wind_direction: WindDirection) -> str:
|
||||
return {
|
||||
WindDirection.N: "⬇️",
|
||||
WindDirection.NO: "↙️",
|
||||
WindDirection.O: "⬅️",
|
||||
WindDirection.SO: "↖️",
|
||||
WindDirection.S: "⬆️",
|
||||
WindDirection.SW: "↗️",
|
||||
WindDirection.W: "➡️",
|
||||
WindDirection.NW: "↘️",
|
||||
WindDirection.CALM: "",
|
||||
}.get(wind_direction, wind_direction)
|
||||
|
||||
|
||||
def cloudness_icon(sky: Sky) -> list[str]:
|
||||
main_icon = ""
|
||||
if sky.thunder:
|
||||
if sky.cloudness == Cloudness.CLEAR:
|
||||
main_icon = "🌩️"
|
||||
if sky.precipitation == Precipitation.NO:
|
||||
main_icon = "⚡"
|
||||
else:
|
||||
main_icon = "⛈️"
|
||||
elif sky.precipitation == Precipitation.NO:
|
||||
main_icon = {
|
||||
Cloudness.CLEAR: "☀️",
|
||||
Cloudness.PARTLY_CLOUDY: "🌤️",
|
||||
Cloudness.CLOUDY: "⛅",
|
||||
Cloudness.MAINLY_CLOUDY: "☁️",
|
||||
}[sky.cloudness]
|
||||
else:
|
||||
main_icon = "🌧️"
|
||||
icons = [main_icon]
|
||||
if sky.fog:
|
||||
icons.append("🌫️")
|
||||
return icons
|
||||
BIN
gallery/easel/route/view/weather/static/favicon.ico
Normal file
BIN
gallery/easel/route/view/weather/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
0
gallery/easel/route/view/weather/static/index.js
Normal file
0
gallery/easel/route/view/weather/static/index.js
Normal file
116
gallery/easel/route/view/weather/static/style.css
Normal file
116
gallery/easel/route/view/weather/static/style.css
Normal file
@@ -0,0 +1,116 @@
|
||||
body {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
a.button {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.button.disabled {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
color: gray;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
table {
|
||||
/* width: 100%; */
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table,
|
||||
th,
|
||||
td {
|
||||
/* border: 1px solid rgba(0, 0, 0, 0.2); */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.date.now {
|
||||
background: rgba(0, 128, 255, 0.2);
|
||||
}
|
||||
|
||||
.date .value a {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloudness {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.cloudness .icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cloudness .icon:first-child {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.temperature .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.temperature .value.positive {
|
||||
color: orangered;
|
||||
}
|
||||
|
||||
.temperature .value.negative {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.wind .direction {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wind .gust {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.precipitation .value {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.pressure {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pressure .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
color: blueviolet;
|
||||
}
|
||||
|
||||
.humidity .value {
|
||||
color: blue;
|
||||
}
|
||||
26
gallery/easel/route/view/weather/templates/index.html
Normal file
26
gallery/easel/route/view/weather/templates/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible"
|
||||
content="ie=edge">
|
||||
<title>Погода</title>
|
||||
<link rel="stylesheet"
|
||||
href="/weather/static/style.css?v=1">
|
||||
<link rel="icon"
|
||||
href="/weather/static/favicon.ico"
|
||||
type="image/x-icon">
|
||||
</head>
|
||||
|
||||
<body class="app-container">
|
||||
<ul>
|
||||
{% for location in locations %}
|
||||
<li><a href="weather/{{location}}">{{location}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
177
gallery/easel/route/view/weather/templates/weather.html
Normal file
177
gallery/easel/route/view/weather/templates/weather.html
Normal file
@@ -0,0 +1,177 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible"
|
||||
content="ie=edge">
|
||||
<title>Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
|
||||
<link rel="stylesheet"
|
||||
href="/weather/static/style.css?v=1">
|
||||
<link rel="icon"
|
||||
href="/weather/static/favicon.ico"
|
||||
type="image/x-icon">
|
||||
</head>
|
||||
|
||||
<body class="app-container">
|
||||
<h3>
|
||||
{% if response.period == 'day' %}
|
||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
||||
<a class="button"
|
||||
href="../tag/days-10">⬆️</a>
|
||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<!-- date -->
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
{% if response.period == 'day' %}
|
||||
<td
|
||||
class="date {{'now' if value.date < datetime.datetime.now() and value.date + datetime.timedelta(hours=3) > datetime.datetime.now() else ''}}">
|
||||
<span class="value">{{value.date.strftime('%H:%M')}}</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
|
||||
<span class="value">
|
||||
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
|
||||
{{value.date.strftime('%a %d')}}
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- cloudness -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Облачность
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="cloudness">
|
||||
{% for icon in value.sky | cloudness_icon %}
|
||||
<div class="icon">{{icon}}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- temperature -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Температура, °C
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="temperature">
|
||||
{% for temperature in value.temperature %}
|
||||
<div class="value {{'positive' if temperature > 0 else 'negative'}}"
|
||||
style="background-color: rgba(255, 128, 128, {{(temperature - 10) * 0.015}});">
|
||||
{{temperature}}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_direction -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Направление ветра
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind">
|
||||
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_speed -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Скорость ветра, м/с
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind"
|
||||
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
|
||||
<span class="speed">{{value.wind_speed}}</span>
|
||||
{% if value.wind_gust != value.wind_speed %}
|
||||
<span class="gust">
|
||||
({{value.wind_gust}})
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- precipitation -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Осадки, мм
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="precipitation"
|
||||
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
|
||||
<span class="value">{{value.precipitation or ' '}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- pressure -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Давление, мм рт. ст.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="pressure">
|
||||
{% for pressure in value.pressure %}
|
||||
<div class="value"
|
||||
style="background-color: rgba(128, 0, 255, {{(pressure - 720) * 0.008}});">
|
||||
{{pressure}}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- humidity -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Влажность, %
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="humidity"
|
||||
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
|
||||
<span class="value">{{value.humidity}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<script src="/static/index.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
57
gallery/easel/route/view/weather/util.py
Normal file
57
gallery/easel/route/view/weather/util.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import datetime
|
||||
from enum import Enum
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class TagType(str, Enum):
|
||||
DAY = "day"
|
||||
DAYS = "days"
|
||||
|
||||
|
||||
class Tag(NamedTuple):
|
||||
type: TagType
|
||||
date: datetime.date
|
||||
days: int = 1
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.type == TagType.DAY:
|
||||
today = datetime.date.today()
|
||||
day = (self.date - today).days
|
||||
return f"day-{day}"
|
||||
elif self.type == TagType.DAYS:
|
||||
return f"days-{self.days}"
|
||||
else:
|
||||
raise ValueError(self.type)
|
||||
|
||||
|
||||
class TagUtil:
|
||||
@classmethod
|
||||
def parse_tag(cls, tag: str) -> Tag:
|
||||
if tag == "today":
|
||||
return Tag(TagType.DAY, datetime.date.today())
|
||||
elif tag == "tomorrow":
|
||||
return Tag(TagType.DAY, datetime.date.today() + datetime.timedelta(days=1))
|
||||
elif tag.startswith("day-"):
|
||||
days = int(tag.split("-")[-1])
|
||||
return Tag(
|
||||
TagType.DAY,
|
||||
datetime.date.today() + datetime.timedelta(days=days),
|
||||
)
|
||||
elif tag.startswith("days-"):
|
||||
days = int(tag.split("-")[-1])
|
||||
return Tag(TagType.DAYS, datetime.date.today(), days)
|
||||
raise ValueError(tag)
|
||||
|
||||
@classmethod
|
||||
def create_tag(
|
||||
cls,
|
||||
tag_type: TagType,
|
||||
date: datetime.date,
|
||||
day: int = 0,
|
||||
days: int = 10,
|
||||
) -> Tag:
|
||||
if tag_type == TagType.DAY:
|
||||
return Tag(tag_type, date + datetime.timedelta(days=day))
|
||||
if tag_type == TagType.DAYS:
|
||||
return Tag(tag_type, date, days)
|
||||
raise ValueError(tag_type)
|
||||
Reference in New Issue
Block a user