Compare commits
3 Commits
d3ef03a6a0
...
ad8144df37
| Author | SHA1 | Date | |
|---|---|---|---|
| ad8144df37 | |||
| f303d0e1f4 | |||
| 3e80ccb0df |
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
{
|
{
|
||||||
"folders": [
|
"folders": [
|
||||||
{
|
{
|
||||||
"path": "."
|
"path": ".",
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"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-envs.pythonProjects": [
|
||||||
|
{
|
||||||
|
"path": ".",
|
||||||
|
"envManager": "ms-python.python:poetry",
|
||||||
|
"packageManager": "ms-python.python:poetry",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"files.associations": {
|
||||||
|
"*.html": "jinja-html",
|
||||||
|
},
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/__pycache__": true
|
"**/__pycache__": true,
|
||||||
},
|
},
|
||||||
"terminal.integrated.env.linux": {
|
"terminal.integrated.env.linux": {
|
||||||
"PYTHONPATH": "${workspaceFolder}"
|
"PYTHONPATH": "${workspaceFolder}",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"launch": {
|
"launch": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
@@ -27,9 +37,9 @@
|
|||||||
"gallery.main:app",
|
"gallery.main:app",
|
||||||
"--reload",
|
"--reload",
|
||||||
"--log-config",
|
"--log-config",
|
||||||
"gallery/logging.yaml"
|
"gallery/logging.yaml",
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,22 @@ import locale as _locale
|
|||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from gallery.sketch.schedule.api import ScheduleApi
|
from gallery.sketch.bundle import ApiBundle
|
||||||
from gallery.sketch.weather.api import WeatherApi
|
|
||||||
|
|
||||||
from .route import doc
|
from .route import api, doc, view
|
||||||
from .route.api import schedule as schedule_api_route
|
|
||||||
from .route.api import weather as weather_api_route
|
DEFAULT_LOCALE = "ru_RU.UTF-8"
|
||||||
from .route.view import common as common_view_route
|
|
||||||
from .route.view import schedule as schedule_view_route
|
|
||||||
from .route.view import weather as weather_view_route
|
|
||||||
|
|
||||||
|
|
||||||
def build_app(
|
def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI:
|
||||||
weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8"
|
|
||||||
) -> FastAPI:
|
|
||||||
_locale.setlocale(_locale.LC_TIME, locale)
|
_locale.setlocale(_locale.LC_TIME, locale)
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Gallery",
|
title="Gallery",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
)
|
)
|
||||||
app.state.weather_api = weather_api
|
app.state.api = api_bundle
|
||||||
app.state.schedule_api = schedule_api
|
|
||||||
doc.mount(app)
|
doc.mount(app)
|
||||||
weather_api_route.mount(app)
|
api.mount(app)
|
||||||
schedule_api_route.mount(app)
|
view.mount(app)
|
||||||
common_view_route.mount(app)
|
|
||||||
weather_view_route.mount(app)
|
|
||||||
schedule_view_route.mount(app)
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
15
gallery/easel/core.py
Normal file
15
gallery/easel/core.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from gallery.sketch.bundle import ApiBundle
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
api: ApiBundle
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
state: State
|
||||||
|
|
||||||
|
|
||||||
|
class AppRequest(Request):
|
||||||
|
app: App
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from . import schedule, weather
|
||||||
|
|
||||||
|
|
||||||
|
def mount(app: FastAPI):
|
||||||
|
weather.mount(app)
|
||||||
|
schedule.mount(app)
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from gallery.sketch.weather.api import WeatherApi
|
from gallery.easel.core import AppRequest
|
||||||
from gallery.sketch.weather.model import WeatherResponse
|
from gallery.sketch.weather.model import WeatherResponse
|
||||||
|
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
def mount(app: FastAPI):
|
||||||
@app.get("/api/weather/locations")
|
@app.get("/api/weather/locations")
|
||||||
async def get_api_weather_locations(request: Request) -> list[str]:
|
async def get_api_weather_locations(request: AppRequest) -> list[str]:
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
weather_api = request.app.state.api.weather
|
||||||
return await weather_api.get_locations()
|
return await weather_api.get_locations()
|
||||||
|
|
||||||
@app.get("/api/weather/{location}/day/{date}")
|
@app.get("/api/weather/{location}/day/{date}")
|
||||||
async def get_api_weather_day(
|
async def get_api_weather_day(
|
||||||
request: Request, location: str, date: datetime.date
|
request: AppRequest, location: str, date: datetime.date
|
||||||
) -> WeatherResponse:
|
) -> WeatherResponse:
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
weather_api = request.app.state.api.weather
|
||||||
return await weather_api.get_day(location, date)
|
return await weather_api.get_day(location, date)
|
||||||
|
|
||||||
@app.get("/api/weather/{location}/days/{days}")
|
@app.get("/api/weather/{location}/days/{days}")
|
||||||
async def get_api_weather_days(
|
async def get_api_weather_days(
|
||||||
request: Request, location: str, days: int
|
request: AppRequest, location: str, days: int
|
||||||
) -> WeatherResponse:
|
) -> WeatherResponse:
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
weather_api = request.app.state.api.weather
|
||||||
return await weather_api.get_days(location, days)
|
return await weather_api.get_days(location, days)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from . import common, schedule, weather
|
||||||
|
|
||||||
|
|
||||||
|
def mount(app: FastAPI):
|
||||||
|
common.mount(app)
|
||||||
|
weather.mount(app)
|
||||||
|
schedule.mount(app)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def mount(app: FastAPI):
|
|||||||
async def get_section_list(request: Request):
|
async def get_section_list(request: Request):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="index.html",
|
name="root_index.html",
|
||||||
context={
|
context={
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"sections": SECTIONS,
|
"sections": SECTIONS,
|
||||||
|
|||||||
@@ -47,25 +47,32 @@ app
|
|||||||
*/
|
*/
|
||||||
.app-container {
|
.app-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
gap: 0.5rem;
|
||||||
|
|
||||||
.app-title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-grow: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-link-home > * {
|
.app-link-home > * {
|
||||||
margin-left: 2rem;
|
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
background-image: url("/static/common/gallery.png");
|
background-image: url("/static/common/gallery.png");
|
||||||
@@ -81,6 +88,7 @@ app
|
|||||||
|
|
||||||
ul.app-list {
|
ul.app-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.app-list > li {
|
ul.app-list > li {
|
||||||
|
|||||||
38
gallery/easel/route/view/common/templates/base.html
Normal file
38
gallery/easel/route/view/common/templates/base.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
{% block 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>{% block title %}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="/static/common/style.css?v={{version}}">
|
||||||
|
<link rel="icon"
|
||||||
|
href="/static/common/favicon.ico?v={{version}}"
|
||||||
|
type="image/x-icon">
|
||||||
|
{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="app-container">
|
||||||
|
<div class="app-menu">
|
||||||
|
<a class="app-link-home"
|
||||||
|
href="/">
|
||||||
|
<div></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="app-content">
|
||||||
|
<h3 class="app-header">
|
||||||
|
{% block header %}{% endblock %}</span>
|
||||||
|
</h3>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
<div class="app-footer">
|
||||||
|
{% block footer %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<!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="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="icon"
|
|
||||||
href="/static/common/favicon.ico?v={{version}}"
|
|
||||||
type="image/x-icon">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="app-container">
|
|
||||||
<h3 class="app-header">
|
|
||||||
<a class="app-link-home"
|
|
||||||
href="/">
|
|
||||||
<div></div>
|
|
||||||
</a>
|
|
||||||
<div class="app-title">
|
|
||||||
<span>Информация</span>
|
|
||||||
</div>
|
|
||||||
</h3>
|
|
||||||
<ul class="app-list">
|
|
||||||
{% for section in sections %}
|
|
||||||
<li>
|
|
||||||
<a href="{{section.link}}">
|
|
||||||
<span class="icon"
|
|
||||||
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
|
|
||||||
<span>{{section.title}}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
21
gallery/easel/route/view/common/templates/root_index.html
Normal file
21
gallery/easel/route/view/common/templates/root_index.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Информация{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}Информация{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<ul class="app-list">
|
||||||
|
{% for section in sections %}
|
||||||
|
<li>
|
||||||
|
<a href="{{section.link}}">
|
||||||
|
<span class="icon"
|
||||||
|
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
|
||||||
|
<span>{{section.title}}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from gallery.sketch.schedule.api import ScheduleApi
|
from gallery.easel.core import AppRequest
|
||||||
from gallery.sketch.schedule.catalog import BUNDLE
|
from gallery.sketch.schedule.catalog import BUNDLE
|
||||||
from gallery.version import __version__
|
from gallery.version import __version__
|
||||||
|
|
||||||
@@ -17,12 +17,17 @@ from .filters import timedelta_format
|
|||||||
def mount(app: FastAPI):
|
def mount(app: FastAPI):
|
||||||
base_dir = Path(__file__).parent
|
base_dir = Path(__file__).parent
|
||||||
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
|
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
|
||||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
templates = Jinja2Templates(
|
||||||
|
directory=[
|
||||||
|
base_dir.parent / "common/templates",
|
||||||
|
base_dir / "templates",
|
||||||
|
]
|
||||||
|
)
|
||||||
templates.env.filters["timedelta_format"] = timedelta_format
|
templates.env.filters["timedelta_format"] = timedelta_format
|
||||||
|
|
||||||
@app.get("/schedule", response_class=HTMLResponse)
|
@app.get("/schedule", response_class=HTMLResponse)
|
||||||
async def get_schedule_list(request: Request):
|
async def get_schedule_list(request: AppRequest):
|
||||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
schedule_api = request.app.state.api.schedule
|
||||||
channels = await schedule_api.get_channels()
|
channels = await schedule_api.get_channels()
|
||||||
channels_data = BUNDLE.select_items(channels)
|
channels_data = BUNDLE.select_items(channels)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -35,9 +40,9 @@ def mount(app: FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
||||||
async def get_schedule_tag(request: Request, tag: str, live: bool = False):
|
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
schedule_api = request.app.state.api.schedule
|
||||||
channels = await schedule_api.get_channels()
|
channels = await schedule_api.get_channels()
|
||||||
responses = [
|
responses = [
|
||||||
await schedule_api.get_channel_schedule(channel, tag_value.date)
|
await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||||
@@ -62,9 +67,9 @@ def mount(app: FastAPI):
|
|||||||
return RedirectResponse(f"{channel}/tag/today")
|
return RedirectResponse(f"{channel}/tag/today")
|
||||||
|
|
||||||
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||||
async def get_channel_tag(request: Request, channel: str, tag: str):
|
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
schedule_api = request.app.state.api.schedule
|
||||||
if tag_value.type == TagType.DAY:
|
if tag_value.type == TagType.DAY:
|
||||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,40 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}
|
||||||
|
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
|
||||||
<head>
|
{% endblock %}
|
||||||
<meta charset="UTF-8">
|
{% block head %}
|
||||||
<meta name="viewport"
|
{{ super() }}
|
||||||
content="width=device-width, initial-scale=1.0">
|
<link rel="stylesheet"
|
||||||
<meta http-equiv="X-UA-Compatible"
|
|
||||||
content="ie=edge">
|
|
||||||
<title>Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/schedule/style.css?v={{version}}">
|
href="/static/schedule/style.css?v={{version}}">
|
||||||
<link rel="icon"
|
<link rel="icon"
|
||||||
href="/static/schedule/favicon.ico?v={{version}}"
|
href="/static/schedule/favicon.ico?v={{version}}"
|
||||||
type="image/x-icon">
|
type="image/x-icon">
|
||||||
</head>
|
{% endblock %}
|
||||||
|
|
||||||
<body class="app-container">
|
{% block header %}
|
||||||
<h3 class="app-header">
|
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||||
<a class="app-link-home"
|
|
||||||
href="/">
|
|
||||||
<div></div>
|
|
||||||
</a>
|
|
||||||
<div class="app-title">
|
|
||||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
||||||
<a class="button"
|
<a class="button"
|
||||||
href="../..">⬆️</a>
|
href="../..">⬆️</a>
|
||||||
<span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||||
<a class="button"
|
<a class="button"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||||
</div>
|
{% endblock %}
|
||||||
</h3>
|
|
||||||
|
|
||||||
<table>
|
{% block content %}
|
||||||
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
@@ -51,7 +39,5 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
{% endblock %}
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,38 +1,21 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}ТВ{% endblock %}
|
||||||
|
{% block head %}
|
||||||
<head>
|
{{ super() }}
|
||||||
<meta charset="UTF-8">
|
<link rel="stylesheet"
|
||||||
<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="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/schedule/style.css?v={{version}}">
|
href="/static/schedule/style.css?v={{version}}">
|
||||||
<link rel="icon"
|
<link rel="icon"
|
||||||
href="/static/schedule/favicon.ico?v={{version}}"
|
href="/static/schedule/favicon.ico?v={{version}}"
|
||||||
type="image/x-icon">
|
type="image/x-icon">
|
||||||
</head>
|
{% endblock %}
|
||||||
|
|
||||||
<body class="app-container">
|
{% block header %}Телепрограмма{% endblock %}
|
||||||
<h3 class="app-header">
|
|
||||||
<a class="app-link-home"
|
{% block content %}
|
||||||
href="/">
|
<ul class="app-list">
|
||||||
<div></div>
|
|
||||||
</a>
|
|
||||||
<div class="app-title">
|
|
||||||
<span>Телепрограмма</span>
|
|
||||||
</div>
|
|
||||||
</h3>
|
|
||||||
<ul class="app-list">
|
|
||||||
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li>
|
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li>
|
||||||
{% for channel in channels %}
|
{% for channel in channels %}
|
||||||
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li>
|
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</body>
|
{% endblock %}
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,39 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}
|
||||||
|
{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}
|
||||||
<head>
|
{% endblock %}
|
||||||
<meta charset="UTF-8">
|
{% block head %}
|
||||||
<meta name="viewport"
|
{{ super() }}
|
||||||
content="width=device-width, initial-scale=1.0">
|
<link rel="stylesheet"
|
||||||
<meta http-equiv="X-UA-Compatible"
|
|
||||||
content="ie=edge">
|
|
||||||
<title>ТВ</title>
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/schedule/style.css?v={{version}}">
|
href="/static/schedule/style.css?v={{version}}">
|
||||||
<link rel="icon"
|
<link rel="icon"
|
||||||
href="/static/schedule/favicon.ico?v={{version}}"
|
href="/static/schedule/favicon.ico?v={{version}}"
|
||||||
type="image/x-icon">
|
type="image/x-icon">
|
||||||
</head>
|
{% endblock %}
|
||||||
|
|
||||||
<body class="app-container">
|
{% block header %}
|
||||||
<h3 class="app-header">
|
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||||
<a class="app-link-home"
|
|
||||||
href="/">
|
|
||||||
<div></div>
|
|
||||||
</a>
|
|
||||||
<div class="app-title">
|
|
||||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
||||||
<a class="button"
|
<a class="button"
|
||||||
href="..">⬆️</a>
|
href="..">⬆️</a>
|
||||||
<span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||||
<a class="button"
|
<a class="button"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||||
</div>
|
{% endblock %}
|
||||||
</h3>
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div>
|
||||||
<table class="{{'live' if live else ''}}">
|
<table class="{{'live' if live else ''}}">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -64,6 +53,5 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
</html>
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from gallery.sketch.weather.api import WeatherApi
|
from gallery.easel.core import AppRequest
|
||||||
from gallery.sketch.weather.catalog import BUNDLE
|
from gallery.sketch.weather.catalog import BUNDLE
|
||||||
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
|
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
|
||||||
from gallery.sketch.weather.model import WeatherResponse
|
from gallery.sketch.weather.model import WeatherResponse
|
||||||
@@ -19,11 +19,16 @@ from .filters import cloudness_icon, wind_direction_icon
|
|||||||
def mount(app: FastAPI):
|
def mount(app: FastAPI):
|
||||||
base_dir = Path(__file__).parent
|
base_dir = Path(__file__).parent
|
||||||
app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
|
app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
|
||||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
templates = Jinja2Templates(
|
||||||
|
directory=[
|
||||||
|
base_dir.parent / "common/templates",
|
||||||
|
base_dir / "templates",
|
||||||
|
]
|
||||||
|
)
|
||||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
||||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
templates.env.filters["cloudness_icon"] = cloudness_icon
|
||||||
|
|
||||||
def build_weather_response(request: Request, response: WeatherResponse):
|
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="weather.html",
|
name="weather.html",
|
||||||
@@ -36,8 +41,8 @@ def mount(app: FastAPI):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/weather", response_class=HTMLResponse)
|
@app.get("/weather", response_class=HTMLResponse)
|
||||||
async def get_weather_list(request: Request):
|
async def get_weather_list(request: AppRequest):
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
weather_api = request.app.state.api.weather
|
||||||
locations = await weather_api.get_locations()
|
locations = await weather_api.get_locations()
|
||||||
locations_data = BUNDLE.select_items(locations)
|
locations_data = BUNDLE.select_items(locations)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -54,31 +59,31 @@ def mount(app: FastAPI):
|
|||||||
return RedirectResponse(f"{location}/tag/today")
|
return RedirectResponse(f"{location}/tag/today")
|
||||||
|
|
||||||
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
|
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
|
||||||
async def get_weather_day_mock(request: Request):
|
async def get_weather_day_mock(request: AppRequest):
|
||||||
response = WEATHER_MOCK_DATA.get_response("day")
|
response = WEATHER_MOCK_DATA.get_response("day")
|
||||||
return build_weather_response(request, response)
|
return build_weather_response(request, response)
|
||||||
|
|
||||||
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
|
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
|
||||||
async def get_weather_days_mock(request: Request):
|
async def get_weather_days_mock(request: AppRequest):
|
||||||
response = WEATHER_MOCK_DATA.get_response("days")
|
response = WEATHER_MOCK_DATA.get_response("days")
|
||||||
return build_weather_response(request, response)
|
return build_weather_response(request, response)
|
||||||
|
|
||||||
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
||||||
async def get_weather_day(request: Request, location: str, date: datetime.date):
|
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
weather_api = request.app.state.api.weather
|
||||||
response = await weather_api.get_day(location, date)
|
response = await weather_api.get_day(location, date)
|
||||||
return build_weather_response(request, response)
|
return build_weather_response(request, response)
|
||||||
|
|
||||||
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
||||||
async def get_weather_days(request: Request, location: str, days: int):
|
async def get_weather_days(request: AppRequest, location: str, days: int):
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
weather_api = request.app.state.api.weather
|
||||||
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)
|
||||||
|
|
||||||
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||||
async def get_weather_tag(request: Request, location: str, tag: str):
|
async def get_weather_tag(request: AppRequest, location: str, tag: str):
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
weather_api = request.app.state.api.weather
|
||||||
if tag_value.type == TagType.DAY:
|
if tag_value.type == TagType.DAY:
|
||||||
response = await weather_api.get_day(location, tag_value.date)
|
response = await weather_api.get_day(location, tag_value.date)
|
||||||
elif tag_value.type == TagType.DAYS:
|
elif tag_value.type == TagType.DAYS:
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
|
from gallery.sketch.weather.model import (
|
||||||
|
Cloudness,
|
||||||
|
Precipitation,
|
||||||
|
Sky,
|
||||||
|
WindDirection,
|
||||||
|
WindDirectionDeg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def wind_direction_icon(wind_direction: WindDirection) -> str:
|
def wind_direction_icon(wind_direction_deg: float) -> str:
|
||||||
|
wind_direction = WindDirectionDeg(wind_direction_deg).direction
|
||||||
return {
|
return {
|
||||||
WindDirection.N: "⬇️",
|
WindDirection.N: "⬇️",
|
||||||
WindDirection.NO: "↙️",
|
WindDirection.NE: "↙️",
|
||||||
WindDirection.O: "⬅️",
|
WindDirection.E: "⬅️",
|
||||||
WindDirection.SO: "↖️",
|
WindDirection.SE: "↖️",
|
||||||
WindDirection.S: "⬆️",
|
WindDirection.S: "⬆️",
|
||||||
WindDirection.SW: "↗️",
|
WindDirection.SW: "↗️",
|
||||||
WindDirection.W: "➡️",
|
WindDirection.W: "➡️",
|
||||||
@@ -31,6 +38,8 @@ def cloudness_icon(sky: Sky) -> list[str]:
|
|||||||
Cloudness.CLOUDY: "⛅",
|
Cloudness.CLOUDY: "⛅",
|
||||||
Cloudness.MAINLY_CLOUDY: "☁️",
|
Cloudness.MAINLY_CLOUDY: "☁️",
|
||||||
}[sky.cloudness]
|
}[sky.cloudness]
|
||||||
|
elif sky.precipitation in [Precipitation.SNOW, Precipitation.HEAVY_SNOW]:
|
||||||
|
main_icon = "🌨️"
|
||||||
else:
|
else:
|
||||||
main_icon = "🌧️"
|
main_icon = "🌧️"
|
||||||
icons = [main_icon]
|
icons = [main_icon]
|
||||||
|
|||||||
@@ -1,37 +1,20 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}Погода{% endblock %}
|
||||||
|
{% block head %}
|
||||||
<head>
|
{{ super() }}
|
||||||
<meta charset="UTF-8">
|
<link rel="stylesheet"
|
||||||
<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="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/weather/style.css?v={{version}}">
|
href="/static/weather/style.css?v={{version}}">
|
||||||
<link rel="icon"
|
<link rel="icon"
|
||||||
href="/static/weather/favicon.ico?v={{version}}"
|
href="/static/weather/favicon.ico?v={{version}}"
|
||||||
type="image/x-icon">
|
type="image/x-icon">
|
||||||
</head>
|
{% endblock %}
|
||||||
|
|
||||||
<body class="app-container">
|
{% block header %}Погода{% endblock %}
|
||||||
<h3 class="app-header">
|
|
||||||
<a class="app-link-home"
|
{% block content %}
|
||||||
href="/">
|
<ul class="app-list">
|
||||||
<div></div>
|
|
||||||
</a>
|
|
||||||
<div class="app-title">
|
|
||||||
<span>Погода</span>
|
|
||||||
</div>
|
|
||||||
</h3>
|
|
||||||
<ul class="app-list">
|
|
||||||
{% for location in locations %}
|
{% for location in locations %}
|
||||||
<li><a href="weather/{{location.id}}">{{location.name}}</a></li>
|
<li><a href="weather/{{location.id}}">{{location.name}}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</body>
|
{% endblock %}
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,44 +1,32 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
||||||
|
{% block head %}
|
||||||
<head>
|
{{ super() }}
|
||||||
<meta charset="UTF-8">
|
<link rel="stylesheet"
|
||||||
<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="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/weather/style.css?v={{version}}">
|
href="/static/weather/style.css?v={{version}}">
|
||||||
<link rel="icon"
|
<link rel="icon"
|
||||||
href="/static/weather/favicon.ico?v={{version}}"
|
href="/static/weather/favicon.ico?v={{version}}"
|
||||||
type="image/x-icon">
|
type="image/x-icon">
|
||||||
</head>
|
{% endblock %}
|
||||||
|
|
||||||
<body class="app-container">
|
{% block header %}
|
||||||
<h3 class="app-header">
|
{% if response.period == 'day' %}
|
||||||
<a class="app-link-home"
|
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||||
href="/">
|
|
||||||
<div></div>
|
|
||||||
</a>
|
|
||||||
<div class="app-title">
|
|
||||||
{% 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>
|
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
||||||
<a class="button"
|
<a class="button"
|
||||||
href="../tag/days-10">⬆️</a>
|
href="../tag/days-10">⬆️</a>
|
||||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||||
<a class="button"
|
<a class="button"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if response.period == 'days' %}
|
{% if response.period == 'days' %}
|
||||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
{% endblock %}
|
||||||
</h3>
|
|
||||||
<table>
|
{% block content %}
|
||||||
|
<div>
|
||||||
|
<table style="margin: auto;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<!-- date -->
|
<!-- date -->
|
||||||
<tr>
|
<tr>
|
||||||
@@ -179,6 +167,5 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
</html>
|
|
||||||
@@ -6,12 +6,19 @@ import uvicorn
|
|||||||
from gallery.easel import build_app
|
from gallery.easel import build_app
|
||||||
from gallery.painting.gismeteo.api import GismeteoApi
|
from gallery.painting.gismeteo.api import GismeteoApi
|
||||||
from gallery.painting.matchtv.api import MatchTvApi
|
from gallery.painting.matchtv.api import MatchTvApi
|
||||||
|
from gallery.painting.openweather.api import OpenWeatherApi
|
||||||
|
from gallery.sketch.bundle import ApiBundle
|
||||||
from gallery.sketch.schedule.cached import CachedScheduleApi
|
from gallery.sketch.schedule.cached import CachedScheduleApi
|
||||||
from gallery.sketch.weather.cached import CachedWeatherApi
|
from gallery.sketch.weather.cached import CachedWeatherApi
|
||||||
|
|
||||||
weather_api = CachedWeatherApi(GismeteoApi())
|
api = ApiBundle(
|
||||||
schedule_api = CachedScheduleApi(MatchTvApi())
|
[
|
||||||
app = build_app(weather_api, schedule_api)
|
CachedScheduleApi(MatchTvApi()),
|
||||||
|
CachedWeatherApi(GismeteoApi()),
|
||||||
|
CachedWeatherApi(OpenWeatherApi()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app = build_app(api)
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ from typing import Iterable
|
|||||||
import dateparser
|
import dateparser
|
||||||
from bs4 import Tag
|
from bs4 import Tag
|
||||||
|
|
||||||
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
|
from gallery.sketch.weather.model import (
|
||||||
|
Cloudness,
|
||||||
|
Precipitation,
|
||||||
|
Sky,
|
||||||
|
WindDirection,
|
||||||
|
WindDirectionDeg,
|
||||||
|
)
|
||||||
|
|
||||||
from .core import BaseWidgetParser, RowParser
|
from .core import BaseWidgetParser, RowParser
|
||||||
|
|
||||||
@@ -61,8 +67,17 @@ class SkyParser(RowParser[Sky]):
|
|||||||
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
||||||
"без осадков": Precipitation.NO,
|
"без осадков": Precipitation.NO,
|
||||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||||
|
"сильный дождь": Precipitation.HEAVY_RAIN,
|
||||||
"дождь": Precipitation.RAIN,
|
"дождь": Precipitation.RAIN,
|
||||||
"ливень": Precipitation.SHOWER,
|
"ливень": Precipitation.SHOWER,
|
||||||
|
"снег": Precipitation.SNOW,
|
||||||
|
"небольшой снег": Precipitation.SNOW,
|
||||||
|
"сильный снег": Precipitation.HEAVY_SNOW,
|
||||||
|
"мокрый снег": Precipitation.SNOW,
|
||||||
|
"снег с дождём": Precipitation.SNOW,
|
||||||
|
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
|
||||||
|
"небольшой снег с дождём": Precipitation.SNOW,
|
||||||
|
"небольшой мокрый снег": Precipitation.SNOW,
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[Sky]:
|
def parse_row(self, tag: Tag) -> Iterable[Sky]:
|
||||||
@@ -106,7 +121,7 @@ class WindSpeedParser(RowParser[int]):
|
|||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||||
for item in tag.select(
|
for item in tag.select(
|
||||||
".widget-row[data-row=wind-speed] > .row-item > speed-value"
|
".widget-row-wind > .row-item > .wind-speed > speed-value"
|
||||||
):
|
):
|
||||||
yield int(item.attrs["value"])
|
yield int(item.attrs["value"])
|
||||||
|
|
||||||
@@ -115,7 +130,7 @@ class WindGustParser(RowParser[int]):
|
|||||||
KEY = "wind_gust"
|
KEY = "wind_gust"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||||
for item in tag.select(".widget-row[data-row=wind-gust] > .row-item"):
|
for item in tag.select(".widget-row-wind > .row-item > .wind-gust"):
|
||||||
value = item.select_one("speed-value")
|
value = item.select_one("speed-value")
|
||||||
yield int(value.attrs["value"]) if value else 0
|
yield int(value.attrs["value"]) if value else 0
|
||||||
|
|
||||||
@@ -124,26 +139,29 @@ class WindDirectionParser(RowParser[WindDirection]):
|
|||||||
KEY = "wind_direction"
|
KEY = "wind_direction"
|
||||||
|
|
||||||
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
|
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
|
||||||
|
"—": WindDirection.CALM,
|
||||||
"штиль": WindDirection.CALM,
|
"штиль": WindDirection.CALM,
|
||||||
"с": WindDirection.N,
|
"с": WindDirection.N,
|
||||||
"св": WindDirection.NO,
|
"св": WindDirection.NE,
|
||||||
"в": WindDirection.O,
|
"в": WindDirection.E,
|
||||||
"юв": WindDirection.SO,
|
"юв": WindDirection.SE,
|
||||||
"ю": WindDirection.S,
|
"ю": WindDirection.S,
|
||||||
"юз": WindDirection.SW,
|
"юз": WindDirection.SW,
|
||||||
"з": WindDirection.W,
|
"з": WindDirection.W,
|
||||||
"сз": WindDirection.NW,
|
"сз": WindDirection.NW,
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[WindDirection]:
|
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||||
for item in tag.select(
|
for item in tag.select(
|
||||||
".widget-row[data-row=wind-direction] > .row-item > .direction"
|
".widget-row-wind > .row-item > .wind-speed > .wind-direction"
|
||||||
):
|
):
|
||||||
wind_direction_str = item.text.lower()
|
wind_direction_str = item.text.lower().strip()
|
||||||
yield self.WIND_DIRECTION_MAP[wind_direction_str]
|
yield WindDirectionDeg.from_direction(
|
||||||
|
self.WIND_DIRECTION_MAP[wind_direction_str]
|
||||||
|
).value
|
||||||
|
|
||||||
|
|
||||||
class WindPrecipitationParser(RowParser[float]):
|
class PrecipitationParser(RowParser[float]):
|
||||||
KEY = "precipitation"
|
KEY = "precipitation"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||||
@@ -167,7 +185,9 @@ class HumidityParser(RowParser[int]):
|
|||||||
KEY = "humidity"
|
KEY = "humidity"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||||
for item in tag.select(".widget-row[data-row=humidity] > .row-item"):
|
for item in tag.select(
|
||||||
|
".widget-row[data-row=humidity] > .row-item, .widget-row[data-row=humidity-avg] > .row-item"
|
||||||
|
):
|
||||||
yield int(item.text)
|
yield int(item.text)
|
||||||
|
|
||||||
|
|
||||||
@@ -178,7 +198,7 @@ ROW_PARSERS: list[RowParser] = [
|
|||||||
WindSpeedParser(),
|
WindSpeedParser(),
|
||||||
WindGustParser(),
|
WindGustParser(),
|
||||||
WindDirectionParser(),
|
WindDirectionParser(),
|
||||||
WindPrecipitationParser(),
|
PrecipitationParser(),
|
||||||
PressureParser(),
|
PressureParser(),
|
||||||
HumidityParser(),
|
HumidityParser(),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,19 +29,25 @@ class MatchTvApi(ScheduleApi):
|
|||||||
async def get_channel_schedule(
|
async def get_channel_schedule(
|
||||||
self, channel_id: str, date: datetime.date
|
self, channel_id: str, date: datetime.date
|
||||||
) -> Schedule:
|
) -> Schedule:
|
||||||
endpoint = f"channel/{channel_id}/tvguide?date={date:%d-%m-%Y}"
|
endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}"
|
||||||
data = await self.SOURCE.request(endpoint)
|
data = await self.SOURCE.request(endpoint)
|
||||||
soup = BeautifulSoup(data, features="html.parser")
|
soup = BeautifulSoup(data, features="html.parser")
|
||||||
values = []
|
values = []
|
||||||
channel_name = soup.select_one(".caption__heading").text.split("|")[0].strip()
|
channel_name = (
|
||||||
|
soup.select_one(".p-tv-guide-header__title")
|
||||||
|
.text.replace("Телепрограмма ", "")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
current_day = datetime.datetime.combine(
|
current_day = datetime.datetime.combine(
|
||||||
date.today(), datetime.datetime.min.time()
|
date.today(), datetime.datetime.min.time()
|
||||||
)
|
)
|
||||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
end = current_day + datetime.timedelta(days=1, hours=6)
|
||||||
prev_value: ScheduleValue | None = None
|
prev_value: ScheduleValue | None = None
|
||||||
for item in soup.select(".teleprogram-schedule .teleprogram-schedule__item"):
|
for item in soup.select(
|
||||||
title = item.select_one(".teleprogram-item__title").text.strip()
|
".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission"
|
||||||
time_str = item.select_one(".teleprogram-item__time").text.strip()
|
):
|
||||||
|
title = item.select_one(".p-tv-guide-schedule-channel-transmission__title").text.strip()
|
||||||
|
time_str = item.select_one(".p-tv-guide-schedule-channel-transmission__time-block").text.strip()
|
||||||
hours, minutes = map(int, time_str.split(":"))
|
hours, minutes = map(int, time_str.split(":"))
|
||||||
item_date = current_day.replace(hour=hours, minute=minutes)
|
item_date = current_day.replace(hour=hours, minute=minutes)
|
||||||
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
||||||
|
|||||||
0
gallery/painting/openweather/__init__.py
Normal file
0
gallery/painting/openweather/__init__.py
Normal file
70
gallery/painting/openweather/api.py
Normal file
70
gallery/painting/openweather/api.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from aiocache import cached
|
||||||
|
|
||||||
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
|
from gallery.sketch.weather.catalog import BUNDLE, LocationId
|
||||||
|
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
|
||||||
|
from gallery.sketch.weather.util import merge_weather_values
|
||||||
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
|
from .openweather import Forecast, OpenWeather
|
||||||
|
from .parser import FORECAST_ITEM_PARSER
|
||||||
|
|
||||||
|
logger = logging.getLogger("openweather")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenWeatherApi(WeatherApi):
|
||||||
|
PROVIDER = "openweather"
|
||||||
|
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
|
||||||
|
|
||||||
|
async def get_locations(self) -> list[str]:
|
||||||
|
return [
|
||||||
|
LocationId.OREL,
|
||||||
|
LocationId.ZMIYEVKA,
|
||||||
|
]
|
||||||
|
|
||||||
|
@cached(
|
||||||
|
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
|
||||||
|
alias="redis",
|
||||||
|
ttl=TimeUnit.DAY,
|
||||||
|
)
|
||||||
|
async def _get_location_forecast(self, location_id: str) -> Forecast:
|
||||||
|
location = BUNDLE.get_item(location_id)
|
||||||
|
return await self.SOURCE.get_forecast(location.lat, location.lon)
|
||||||
|
|
||||||
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
|
data: Forecast = await self._get_location_forecast(location_id)
|
||||||
|
values = []
|
||||||
|
for item in data.list:
|
||||||
|
value = FORECAST_ITEM_PARSER.parse(item)
|
||||||
|
if value.date.date() == date:
|
||||||
|
values.append(value)
|
||||||
|
location = BUNDLE.get_item(location_id)
|
||||||
|
return WeatherResponse(
|
||||||
|
location=location.name,
|
||||||
|
date=date,
|
||||||
|
period="day",
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||||
|
data: Forecast = await self._get_location_forecast(location_id)
|
||||||
|
values_by_date: dict[datetime.datetime, list[WeatherValue]] = defaultdict(list)
|
||||||
|
for item in data.list:
|
||||||
|
value = FORECAST_ITEM_PARSER.parse(item)
|
||||||
|
item_date = value.date.replace(hour=0, minute=0)
|
||||||
|
values_by_date[item_date].append(value)
|
||||||
|
values = [
|
||||||
|
merge_weather_values(date, values)
|
||||||
|
for date, values in values_by_date.items()
|
||||||
|
]
|
||||||
|
location = BUNDLE.get_item(location_id)
|
||||||
|
return WeatherResponse(
|
||||||
|
location=location.name,
|
||||||
|
date=datetime.date.today(),
|
||||||
|
period="days",
|
||||||
|
values=list(sorted(values, key=lambda item: item.date)),
|
||||||
|
)
|
||||||
5
gallery/painting/openweather/mock/__init__.py
Normal file
5
gallery/painting/openweather/mock/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gallery.sketch.mock import MockData
|
||||||
|
|
||||||
|
OPENWEATHER_MOCK_DATA = MockData(Path(__file__).parent / "data")
|
||||||
1139
gallery/painting/openweather/mock/data/forecast.json
Normal file
1139
gallery/painting/openweather/mock/data/forecast.json
Normal file
File diff suppressed because it is too large
Load Diff
83
gallery/painting/openweather/openweather.py
Normal file
83
gallery/painting/openweather/openweather.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from gallery.sketch.source import ApiSource
|
||||||
|
|
||||||
|
|
||||||
|
class Model(BaseModel):
|
||||||
|
class Config:
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
|
class Main(Model):
|
||||||
|
temp: float
|
||||||
|
feels_like: float
|
||||||
|
temp_min: float
|
||||||
|
temp_max: float
|
||||||
|
pressure: int
|
||||||
|
sea_level: int
|
||||||
|
grnd_level: int
|
||||||
|
humidity: int
|
||||||
|
temp_kf: float
|
||||||
|
|
||||||
|
|
||||||
|
class Weather(Model):
|
||||||
|
id: int
|
||||||
|
main: str
|
||||||
|
description: str
|
||||||
|
icon: str
|
||||||
|
|
||||||
|
|
||||||
|
class Clouds(Model):
|
||||||
|
all: int
|
||||||
|
|
||||||
|
|
||||||
|
class Wind(Model):
|
||||||
|
speed: float
|
||||||
|
deg: int
|
||||||
|
gust: float
|
||||||
|
|
||||||
|
|
||||||
|
class Rain(Model):
|
||||||
|
interval_3h: float = Field(..., alias="3h")
|
||||||
|
|
||||||
|
|
||||||
|
class Sys(Model):
|
||||||
|
pod: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForecastItem(Model):
|
||||||
|
dt: int
|
||||||
|
main: Main
|
||||||
|
weather: list[Weather]
|
||||||
|
clouds: Clouds
|
||||||
|
wind: Wind
|
||||||
|
visibility: int
|
||||||
|
pop: float
|
||||||
|
rain: Rain | None = None
|
||||||
|
sys: Sys
|
||||||
|
dt_txt: str
|
||||||
|
|
||||||
|
|
||||||
|
class Forecast(Model):
|
||||||
|
cod: str
|
||||||
|
message: int
|
||||||
|
cnt: int
|
||||||
|
list: list[ForecastItem]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenWeather:
|
||||||
|
BASE_URL = "https://api.openweathermap.org"
|
||||||
|
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
self._api_key = api_key
|
||||||
|
self._source = ApiSource(self.BASE_URL)
|
||||||
|
|
||||||
|
async def get_forecast(self, lat: float, lon: float) -> Forecast:
|
||||||
|
endpoint = (
|
||||||
|
f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
|
||||||
|
)
|
||||||
|
response = await self._source.request(endpoint)
|
||||||
|
response_data = json.loads(response)
|
||||||
|
return Forecast(**response_data)
|
||||||
52
gallery/painting/openweather/parser.py
Normal file
52
gallery/painting/openweather/parser.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue
|
||||||
|
from gallery.sketch.weather.util import build_weather_value
|
||||||
|
|
||||||
|
from .openweather import ForecastItem
|
||||||
|
|
||||||
|
|
||||||
|
class ForecastItemParser:
|
||||||
|
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
||||||
|
"clear sky": Cloudness.CLEAR,
|
||||||
|
"few clouds": Cloudness.PARTLY_CLOUDY,
|
||||||
|
"scattered clouds": Cloudness.PARTLY_CLOUDY,
|
||||||
|
"broken clouds": Cloudness.CLOUDY,
|
||||||
|
"overcast clouds": Cloudness.MAINLY_CLOUDY,
|
||||||
|
"light rain": Cloudness.CLOUDY,
|
||||||
|
}
|
||||||
|
|
||||||
|
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
||||||
|
"light rain": Precipitation.SMALL_RAIN,
|
||||||
|
"rain": Precipitation.RAIN,
|
||||||
|
"heavy rain": Precipitation.SHOWER,
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse(self, item: ForecastItem) -> WeatherValue:
|
||||||
|
item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC)
|
||||||
|
item_date = (
|
||||||
|
item_date.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
.astimezone(tz=None)
|
||||||
|
.replace(tzinfo=None)
|
||||||
|
)
|
||||||
|
value = build_weather_value(item_date)
|
||||||
|
# TODO parse temperature interval flag
|
||||||
|
value.temperature = [round(item.main.temp)]
|
||||||
|
# value.temperature = [round(item.main.temp_max), round(item.main.temp_min)]
|
||||||
|
value.pressure = [round(item.main.pressure / 133.3 * 100)]
|
||||||
|
value.humidity = item.main.humidity
|
||||||
|
value.wind_speed = round(item.wind.speed)
|
||||||
|
value.wind_gust = round(item.wind.gust)
|
||||||
|
value.wind_direction = item.wind.deg
|
||||||
|
value.sky.cloudness = self.CLOUDNESS_MAP.get(
|
||||||
|
item.weather[0].description, Cloudness.CLEAR
|
||||||
|
)
|
||||||
|
value.sky.precipitation = self.PRECIPITATION_MAP.get(
|
||||||
|
item.weather[0].description, Precipitation.NO
|
||||||
|
)
|
||||||
|
if item.rain:
|
||||||
|
value.precipitation = round(item.rain.interval_3h, 1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
FORECAST_ITEM_PARSER = ForecastItemParser()
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
|
||||||
class Api:
|
class Api:
|
||||||
PROVIDER: str
|
PROVIDER: str
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provider(self) -> str:
|
def provider(self) -> str:
|
||||||
return self.PROVIDER
|
return self.PROVIDER
|
||||||
|
|
||||||
|
|
||||||
|
API = TypeVar("API", bound=Api)
|
||||||
|
|||||||
30
gallery/sketch/bundle.py
Normal file
30
gallery/sketch/bundle.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from .api import API, Api
|
||||||
|
from .schedule.api import ScheduleApi
|
||||||
|
from .weather.api import WeatherApi
|
||||||
|
|
||||||
|
|
||||||
|
class ApiBundle(list[Api]):
|
||||||
|
def __init__(self, values: list[Api]) -> None:
|
||||||
|
super().__init__(values)
|
||||||
|
|
||||||
|
def get_api_by_provider(self, provider: str) -> Api:
|
||||||
|
for value in self:
|
||||||
|
if value.PROVIDER == provider:
|
||||||
|
return value
|
||||||
|
raise ValueError(provider)
|
||||||
|
|
||||||
|
def get_api_by_type(self, api_type: Type[API]) -> API:
|
||||||
|
for value in self:
|
||||||
|
if isinstance(value, api_type):
|
||||||
|
return value
|
||||||
|
raise ValueError(api_type)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def weather(self) -> WeatherApi:
|
||||||
|
return self.get_api_by_type(WeatherApi)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def schedule(self) -> ScheduleApi:
|
||||||
|
return self.get_api_by_type(ScheduleApi)
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
from typing import Generic, TypeVar
|
from typing import Generic
|
||||||
|
|
||||||
from gallery.util import TimeUnit
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
from .api import Api
|
from .api import API, Api
|
||||||
|
|
||||||
API = TypeVar("API", bound=Api)
|
|
||||||
|
|
||||||
|
|
||||||
class CachedApi(Api, Generic[API]):
|
class CachedApi(Api, Generic[API]):
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ class CatalogBundle(Generic[T]):
|
|||||||
def __init__(self, items: list[T]) -> None:
|
def __init__(self, items: list[T]) -> None:
|
||||||
self._items_by_id = {item.id: item for item in items}
|
self._items_by_id = {item.id: item for item in items}
|
||||||
|
|
||||||
|
def get_item(self, item_id: str) -> T:
|
||||||
|
return self._items_by_id[item_id]
|
||||||
|
|
||||||
def select_items(self, ids: list[str]) -> list[T]:
|
def select_items(self, ids: list[str]) -> list[T]:
|
||||||
return [self._items_by_id[id_] for id_ in ids]
|
return [self._items_by_id[id_] for id_ in ids]
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ class MockData:
|
|||||||
def __init__(self, data_dir) -> None:
|
def __init__(self, data_dir) -> None:
|
||||||
self._data_dir = data_dir
|
self._data_dir = data_dir
|
||||||
|
|
||||||
|
def get_text(self, key: str) -> str:
|
||||||
|
return (self._data_dir / f"{key}").read_text()
|
||||||
|
|
||||||
def get_html(self, key: str) -> str:
|
def get_html(self, key: str) -> str:
|
||||||
return (self._data_dir / f"{key}.html").read_text()
|
return self.get_text(f"{key}.html")
|
||||||
|
|
||||||
def get_json(self, key: str) -> dict:
|
def get_json(self, key: str) -> dict:
|
||||||
data = json.loads((self._data_dir / f"{key}.json").read_text())
|
data = json.loads(self.get_text(f"{key}.json"))
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -15,7 +15,17 @@ class LocationId(str, Enum):
|
|||||||
|
|
||||||
BUNDLE = CatalogBundle(
|
BUNDLE = CatalogBundle(
|
||||||
[
|
[
|
||||||
Location(id=LocationId.OREL, name="Орёл"),
|
Location(
|
||||||
Location(id=LocationId.ZMIYEVKA, name="Змиёвка"),
|
id=LocationId.OREL,
|
||||||
|
name="Орёл",
|
||||||
|
lat=52.9687747,
|
||||||
|
lon=36.0694937,
|
||||||
|
),
|
||||||
|
Location(
|
||||||
|
id=LocationId.ZMIYEVKA,
|
||||||
|
name="Змиёвка",
|
||||||
|
lat=52.672192,
|
||||||
|
lon=36.380112,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class Model(BaseModel):
|
|||||||
class Location(Model):
|
class Location(Model):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
|
||||||
|
|
||||||
class Cloudness(str, Enum):
|
class Cloudness(str, Enum):
|
||||||
@@ -25,7 +27,10 @@ class Precipitation(str, Enum):
|
|||||||
NO = "no"
|
NO = "no"
|
||||||
SMALL_RAIN = "small_rain"
|
SMALL_RAIN = "small_rain"
|
||||||
RAIN = "rain"
|
RAIN = "rain"
|
||||||
|
HEAVY_RAIN = "heavy_rain"
|
||||||
SHOWER = "shower"
|
SHOWER = "shower"
|
||||||
|
SNOW = "snow"
|
||||||
|
HEAVY_SNOW = "heavy_snow"
|
||||||
|
|
||||||
|
|
||||||
class Sky(Model):
|
class Sky(Model):
|
||||||
@@ -38,22 +43,69 @@ class Sky(Model):
|
|||||||
class WindDirection(str, Enum):
|
class WindDirection(str, Enum):
|
||||||
CALM = "calm"
|
CALM = "calm"
|
||||||
N = "N"
|
N = "N"
|
||||||
NO = "NO"
|
NE = "NE"
|
||||||
O = "O"
|
E = "E"
|
||||||
SO = "SO"
|
SE = "SE"
|
||||||
S = "S"
|
S = "S"
|
||||||
SW = "SW"
|
SW = "SW"
|
||||||
W = "W"
|
W = "W"
|
||||||
NW = "NW"
|
NW = "NW"
|
||||||
|
|
||||||
|
|
||||||
|
class WindDirectionDeg(float):
|
||||||
|
@property
|
||||||
|
def direction(self) -> WindDirection:
|
||||||
|
return self.to_direction()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> float:
|
||||||
|
return self
|
||||||
|
|
||||||
|
# pylint:disable=too-many-return-statements
|
||||||
|
def to_direction(self) -> WindDirection:
|
||||||
|
if self > 337.5 or self <= 22.25:
|
||||||
|
return WindDirection.N
|
||||||
|
elif self <= 67.5:
|
||||||
|
return WindDirection.NE
|
||||||
|
elif self <= 112.5:
|
||||||
|
return WindDirection.E
|
||||||
|
elif self <= 157.5:
|
||||||
|
return WindDirection.SE
|
||||||
|
elif self <= 202.5:
|
||||||
|
return WindDirection.S
|
||||||
|
elif self <= 247.5:
|
||||||
|
return WindDirection.SW
|
||||||
|
elif self <= 292.5:
|
||||||
|
return WindDirection.W
|
||||||
|
elif self <= 337.5:
|
||||||
|
return WindDirection.NW
|
||||||
|
else:
|
||||||
|
return WindDirection.CALM
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg":
|
||||||
|
return cls(
|
||||||
|
{
|
||||||
|
WindDirection.CALM: -1,
|
||||||
|
WindDirection.N: 0,
|
||||||
|
WindDirection.NE: 45,
|
||||||
|
WindDirection.E: 90,
|
||||||
|
WindDirection.SE: 135,
|
||||||
|
WindDirection.S: 180,
|
||||||
|
WindDirection.SW: 225,
|
||||||
|
WindDirection.W: 270,
|
||||||
|
WindDirection.NW: 315,
|
||||||
|
}[direction]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WeatherValue(Model):
|
class WeatherValue(Model):
|
||||||
date: datetime.datetime
|
date: datetime.datetime
|
||||||
sky: Sky
|
sky: Sky
|
||||||
temperature: list[int]
|
temperature: list[int]
|
||||||
wind_speed: int
|
wind_speed: int
|
||||||
wind_gust: int
|
wind_gust: int
|
||||||
wind_direction: WindDirection
|
wind_direction: float
|
||||||
precipitation: float
|
precipitation: float
|
||||||
pressure: list[int]
|
pressure: list[int]
|
||||||
humidity: int
|
humidity: int
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import statistics
|
||||||
|
|
||||||
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirection
|
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg
|
||||||
|
|
||||||
|
|
||||||
def build_weather_value(date: datetime.datetime) -> WeatherValue:
|
def build_weather_value(date: datetime.datetime) -> WeatherValue:
|
||||||
@@ -15,8 +16,49 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
|
|||||||
temperature=[],
|
temperature=[],
|
||||||
wind_speed=0,
|
wind_speed=0,
|
||||||
wind_gust=0,
|
wind_gust=0,
|
||||||
wind_direction=WindDirection.CALM,
|
wind_direction=WindDirectionDeg(-1),
|
||||||
precipitation=0,
|
precipitation=0,
|
||||||
pressure=[],
|
pressure=[],
|
||||||
humidity=0,
|
humidity=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_weather_values(
|
||||||
|
date: datetime.datetime, values: list[WeatherValue]
|
||||||
|
) -> WeatherValue:
|
||||||
|
result = build_weather_value(date)
|
||||||
|
temperatures = []
|
||||||
|
pressures = []
|
||||||
|
humidities = []
|
||||||
|
wind_speeds = []
|
||||||
|
wind_gusts = []
|
||||||
|
wind_directions = []
|
||||||
|
cloudnesses = []
|
||||||
|
precipitations = []
|
||||||
|
precipitation = 0
|
||||||
|
for value in values:
|
||||||
|
temperatures += value.temperature
|
||||||
|
pressures += value.pressure
|
||||||
|
humidities.append(value.humidity)
|
||||||
|
wind_speeds.append(value.wind_speed)
|
||||||
|
wind_gusts.append(value.wind_gust)
|
||||||
|
wind_directions.append(value.wind_direction)
|
||||||
|
cloudnesses.append(value.sky.cloudness)
|
||||||
|
precipitations.append(value.sky.precipitation)
|
||||||
|
precipitation += value.precipitation
|
||||||
|
result.temperature = [max(temperatures), min(temperatures)]
|
||||||
|
result.pressure = [max(pressures), min(pressures)]
|
||||||
|
result.humidity = round(statistics.mean(humidities))
|
||||||
|
result.wind_speed = round(statistics.mean(wind_speeds))
|
||||||
|
result.wind_gust = round(statistics.mean(wind_gusts))
|
||||||
|
result.wind_direction = statistics.mean(wind_directions)
|
||||||
|
# TODO: merge cloudnesses
|
||||||
|
for item in cloudnesses:
|
||||||
|
if item != Cloudness.CLEAR:
|
||||||
|
result.sky.cloudness = item
|
||||||
|
# TODO: merge precipitations
|
||||||
|
for item in precipitations:
|
||||||
|
if item != Precipitation.NO:
|
||||||
|
result.sky.precipitation = item
|
||||||
|
result.precipitation = precipitation
|
||||||
|
return result
|
||||||
|
|||||||
27
tests/test_openweather_api.py
Normal file
27
tests/test_openweather_api.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gallery.painting.openweather.api import OpenWeatherApi
|
||||||
|
from gallery.painting.openweather.mock import OPENWEATHER_MOCK_DATA
|
||||||
|
from gallery.painting.openweather.openweather import Forecast
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="openweather_api", scope="module")
|
||||||
|
def openweather_api_fixture() -> OpenWeatherApi:
|
||||||
|
async def _get_location_forecast(location_id: str) -> Forecast:
|
||||||
|
return Forecast(**OPENWEATHER_MOCK_DATA.get_json("forecast"))
|
||||||
|
|
||||||
|
api = OpenWeatherApi()
|
||||||
|
api._get_location_forecast = _get_location_forecast
|
||||||
|
return api
|
||||||
|
|
||||||
|
|
||||||
|
async def test_day(openweather_api: OpenWeatherApi):
|
||||||
|
result = await openweather_api.get_day("orel-4432", datetime.date(2024, 8, 23))
|
||||||
|
assert len(result.values) == 8
|
||||||
|
|
||||||
|
|
||||||
|
async def test_days(openweather_api: OpenWeatherApi):
|
||||||
|
result = await openweather_api.get_days("orel-4432", 10)
|
||||||
|
assert len(result.values) == 6
|
||||||
Reference in New Issue
Block a user