Compare commits
51 Commits
3dd0a5410c
...
0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c76dae760 | |||
| 28951df9b5 | |||
| 5aa290c772 | |||
| 13a70990e5 | |||
| a3fab92e3d | |||
| 4fdb7e9717 | |||
| 1d13f6e05d | |||
| fcb000afb0 | |||
| 0730b9f567 | |||
| 081aa8d17f | |||
| 29fe06462e | |||
| 8b20eb1220 | |||
| 5333690c47 | |||
| d82fc4ea46 | |||
| df2f1d0d81 | |||
| 9192ca3e4a | |||
| e37ca6eeac | |||
| 657a62eab5 | |||
| d8e2be013a | |||
| a91ec12d22 | |||
| 9c862863e5 | |||
| fc59e52337 | |||
| 3e93a41400 | |||
| c2cd18386b | |||
| 2bca3dd75a | |||
| 469bd9bc1f | |||
| 027d1e2d55 | |||
| 7cf0012229 | |||
| edc014d98c | |||
| 1813ec213b | |||
| 1b700086f2 | |||
| 02a6ffc931 | |||
| 2dfedfea57 | |||
| 8012d9b8ed | |||
| 3a6faa85be | |||
| 1af61aa3c7 | |||
| 315838604e | |||
| f368e6717c | |||
| 91e2c9d123 | |||
| 91d9c37612 | |||
| b5f2c272bb | |||
| eec72c77ab | |||
| 160ec2b48b | |||
| 7c57f939c0 | |||
| c233b020fc | |||
| 869a8ae79f | |||
| 4c3b3aeafc | |||
| d1592150fd | |||
| 9351b9f53a | |||
| ecb574e286 | |||
| 94870a5c86 |
@@ -2,4 +2,7 @@ DOCKER_REPO=git.shmyga.ru
|
||||
DOCKER_GROUP=infernalgames
|
||||
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
|
||||
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
|
||||
DOCKER_PROJECTS=("gallery")
|
||||
PYTHON_VERSION=$(grep -m 1 'python' ./pyproject.toml | grep -oP 'python\s*=\s*"\^?\K[^"]+')
|
||||
DOCKER_PROJECTS=("gallery")
|
||||
|
||||
OPENWEATHER_KEY="<EMPTY>"
|
||||
4
.gitignore
vendored
@@ -1,4 +1,8 @@
|
||||
*.pyc
|
||||
*.mo
|
||||
.pytest_cache
|
||||
.venv
|
||||
#.vscode
|
||||
static/node_modules
|
||||
static/dist
|
||||
.env
|
||||
9
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"python-envs.pythonProjects": [
|
||||
{
|
||||
"path": ".",
|
||||
"envManager": "ms-python.python:poetry",
|
||||
"packageManager": "ms-python.python:poetry"
|
||||
}
|
||||
]
|
||||
}
|
||||
30
Dockerfile
@@ -1,23 +1,35 @@
|
||||
FROM python:3.12 AS builder
|
||||
FROM python:3.14 AS builder
|
||||
ENV POETRY_HOME="/opt/poetry"
|
||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
||||
RUN apt update && \
|
||||
apt install -y gettext
|
||||
WORKDIR /app
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
COPY pyproject.toml poetry.lock README.md ./
|
||||
RUN poetry config virtualenvs.in-project true
|
||||
RUN poetry install --with app --no-root
|
||||
RUN --mount=type=cache,target=/root/.cache/pypoetry/cache \
|
||||
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
|
||||
poetry install --with app --no-root
|
||||
COPY locales ./locales
|
||||
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
|
||||
|
||||
FROM python:3.12-slim
|
||||
FROM node:24 AS node-builder
|
||||
ENV PATH=/app/node_modules/.bin:$PATH
|
||||
WORKDIR /app
|
||||
COPY static/package.json static/package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
COPY static ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.14-slim
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
WORKDIR /app
|
||||
RUN apt update && \
|
||||
apt install -y locales && \
|
||||
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
|
||||
dpkg-reconfigure --frontend=noninteractive locales
|
||||
ENV LANG=ru_RU.UTF-8
|
||||
ENV LC_ALL=ru_RU.UTF-8
|
||||
|
||||
ENV TZ="Europe/Moscow"
|
||||
COPY --from=builder /app ./
|
||||
COPY --from=node-builder /app/dist ./static/dist
|
||||
COPY gallery gallery/
|
||||
COPY --from=builder --parents locales/**/*.mo ./
|
||||
|
||||
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# Gallery
|
||||
# API Gallery
|
||||
|
||||
Weather and TV program API
|
||||
|
||||

|
||||
|
||||
## View
|
||||
|
||||
https://api.shmyga.ru
|
||||
|
||||
## API Docs
|
||||
## Swagger
|
||||
|
||||
https://api.shmyga.ru/docs
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
build: .
|
||||
environment:
|
||||
- REDIS_HOST=redis
|
||||
- OPENWEATHER_KEY=$OPENWEATHER_KEY
|
||||
- DEBUG=1
|
||||
ports:
|
||||
- 8000:80
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
image: ${DOCKER_ROOT}/gallery
|
||||
environment:
|
||||
- REDIS_HOST=redis
|
||||
- OPENWEATHER_KEY=$OPENWEATHER_KEY
|
||||
depends_on:
|
||||
- redis
|
||||
ports:
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
docker compose -f docker-compose-develop.yaml up --build --watch
|
||||
BIN
docs/screenshot.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
@@ -8,6 +8,7 @@
|
||||
"python.testing.pytestArgs": ["tests", "-s"],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.terminal.useEnvFile": true,
|
||||
"python-envs.pythonProjects": [
|
||||
{
|
||||
"path": ".",
|
||||
@@ -33,12 +34,17 @@
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
"args": [
|
||||
"gallery.main:app",
|
||||
"--reload",
|
||||
"--log-config",
|
||||
"gallery/logging.yaml",
|
||||
],
|
||||
"args": ["gallery.main:app", "--reload", "--log-config", "gallery/logging.yaml"],
|
||||
"justMyCode": true,
|
||||
"consoleTitle": "gallery:app",
|
||||
},
|
||||
{
|
||||
"name": "gallery:static",
|
||||
"cwd": "${workspaceFolder}/static",
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
"command": "npm run dev",
|
||||
"consoleTitle": "gallery:static",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import locale as _locale
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from gallery.sketch.bundle import ApiBundle
|
||||
from gallery.util import root_path
|
||||
|
||||
from .route import api, doc, view
|
||||
|
||||
DEFAULT_LOCALE = "ru_RU.UTF-8"
|
||||
|
||||
|
||||
def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI:
|
||||
_locale.setlocale(_locale.LC_TIME, locale)
|
||||
def build_app(api_bundle: ApiBundle) -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Gallery",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
)
|
||||
app.state.api = api_bundle
|
||||
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
||||
doc.mount(app)
|
||||
api.mount(app)
|
||||
view.mount(app)
|
||||
app.include_router(api.router)
|
||||
app.include_router(view.router)
|
||||
return app
|
||||
|
||||
0
gallery/easel/depends/__init__.py
Normal file
17
gallery/easel/depends/api.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.sketch.api import API
|
||||
|
||||
|
||||
def api_resolver(api_type: type[API]):
|
||||
def get_api(request: AppRequest, provider: str) -> API:
|
||||
providers = request.app.state.api.get_api_providers(api_type)
|
||||
if provider not in providers:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"provider": f"'{provider}' not in {providers}"},
|
||||
)
|
||||
return request.app.state.api.get_api(api_type, provider)
|
||||
|
||||
return get_api
|
||||
9
gallery/easel/depends/schedule.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
|
||||
from .api import api_resolver
|
||||
|
||||
ScheduleApiDepends = Annotated[ScheduleApi, Depends(api_resolver(ScheduleApi))]
|
||||
9
gallery/easel/depends/weather.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
|
||||
from .api import api_resolver
|
||||
|
||||
WeatherApiDepends = Annotated[WeatherApi, Depends(api_resolver(WeatherApi))]
|
||||
@@ -1,8 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import schedule, weather
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
weather.mount(app)
|
||||
schedule.mount(app)
|
||||
router = APIRouter(prefix="/api")
|
||||
router.include_router(weather.router)
|
||||
router.include_router(schedule.router)
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import datetime
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.sketch.schedule.model import ChannelId, Schedule
|
||||
from gallery.easel.depends.schedule import ScheduleApiDepends
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
from gallery.sketch.schedule.model import Channel, Schedule
|
||||
|
||||
router = APIRouter(prefix="/schedule", tags=["Schedule"])
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
@app.get("/api/schedule/channels")
|
||||
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
|
||||
schedule_api = request.app.state.api.schedule
|
||||
return await schedule_api.get_channels()
|
||||
@router.get("/providers")
|
||||
async def get_api_weather_providers(request: AppRequest) -> list[str]:
|
||||
return request.app.state.api.get_api_providers(ScheduleApi)
|
||||
|
||||
@app.get("/api/schedule/{channel}/{date}")
|
||||
async def get_api_schedule_channel_schedule(
|
||||
request: AppRequest, channel: str, date: datetime.date
|
||||
) -> Schedule:
|
||||
schedule_api = request.app.state.api.schedule
|
||||
return await schedule_api.get_channel_schedule(ChannelId(channel), date)
|
||||
|
||||
@router.get("/{provider}/channels")
|
||||
async def find_api_schedule_channels(schedule_api: ScheduleApiDepends, query: str) -> list[Channel]:
|
||||
return await schedule_api.find_channels(query)
|
||||
|
||||
|
||||
@router.get("/{provider}/{channel}/{date}")
|
||||
async def get_api_schedule_channel_schedule(
|
||||
schedule_api: ScheduleApiDepends, channel: str, date: datetime.date
|
||||
) -> Schedule:
|
||||
return await schedule_api.get_schedule(channel, date)
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import datetime
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.sketch.weather.model import WeatherResponse
|
||||
from gallery.easel.depends.weather import WeatherApiDepends
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.model import Location, WeatherResponse
|
||||
|
||||
router = APIRouter(prefix="/weather", tags=["Weather"])
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
@app.get("/api/weather/locations")
|
||||
async def get_api_weather_locations(request: AppRequest) -> list[str]:
|
||||
weather_api = request.app.state.api.weather
|
||||
return await weather_api.get_locations()
|
||||
@router.get("/providers")
|
||||
async def get_api_weather_providers(request: AppRequest) -> list[str]:
|
||||
return request.app.state.api.get_api_providers(WeatherApi)
|
||||
|
||||
@app.get("/api/weather/{location}/day/{date}")
|
||||
async def get_api_weather_day(
|
||||
request: AppRequest, location: str, date: datetime.date
|
||||
) -> WeatherResponse:
|
||||
weather_api = request.app.state.api.weather
|
||||
return await weather_api.get_day(location, date)
|
||||
|
||||
@app.get("/api/weather/{location}/days/{days}")
|
||||
async def get_api_weather_days(
|
||||
request: AppRequest, location: str, days: int
|
||||
) -> WeatherResponse:
|
||||
weather_api = request.app.state.api.weather
|
||||
return await weather_api.get_days(location, days)
|
||||
@router.get("/{provider}/locations")
|
||||
async def find_api_weather_locations(weather_api: WeatherApiDepends, query: str) -> list[Location]:
|
||||
return await weather_api.find_locations(query)
|
||||
|
||||
|
||||
@router.get("/{provider}/{location}/day/{date}")
|
||||
async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: datetime.date) -> WeatherResponse:
|
||||
return await weather_api.get_day(location, date)
|
||||
|
||||
|
||||
@router.get("/{provider}/{location}/days/{days}")
|
||||
async def get_api_weather_days(weather_api: WeatherApiDepends, location: str, days: int) -> WeatherResponse:
|
||||
return await weather_api.get_days(location, days)
|
||||
|
||||
636
gallery/easel/route/doc/static/redoc.standalone.js
vendored
@@ -1,9 +1,11 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from . import common, schedule, weather
|
||||
from .root import router as root_router
|
||||
from .schedule import router as schedule_router
|
||||
from .translation import set_language
|
||||
from .weather import router as weather_router
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
common.mount(app)
|
||||
weather.mount(app)
|
||||
schedule.mount(app)
|
||||
router = APIRouter(tags=["view"], dependencies=[Depends(set_language)], include_in_schema=False)
|
||||
router.include_router(root_router)
|
||||
router.include_router(weather_router)
|
||||
router.include_router(schedule_router)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.version import __version__
|
||||
|
||||
|
||||
class Section(NamedTuple):
|
||||
link: str
|
||||
title: str
|
||||
|
||||
|
||||
SECTIONS = [
|
||||
Section("weather", "Погода"),
|
||||
Section("schedule", "Телепрограмма"),
|
||||
]
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/common", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def get_section_list(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="root_index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"sections": SECTIONS,
|
||||
},
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,114 +0,0 @@
|
||||
/*
|
||||
base
|
||||
*/
|
||||
body {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/*
|
||||
table
|
||||
*/
|
||||
table {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table,
|
||||
th,
|
||||
td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
/*
|
||||
a.button
|
||||
*/
|
||||
a.button {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.button.disabled {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
color: gray;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
/*
|
||||
app
|
||||
*/
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-link-home > * {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-image: url("/static/common/gallery.png");
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
ul.app-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
width: 30rem;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
ul.app-list > li {
|
||||
border: 1px solid lightgrey;
|
||||
}
|
||||
|
||||
ul.app-list > li > a {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 2rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul.app-list > li:hover {
|
||||
border-color: rgb(125, 125, 255);
|
||||
}
|
||||
|
||||
ul.app-list > li:hover > a {
|
||||
color: rgb(125, 125, 255);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{request.state.language}}">
|
||||
|
||||
<head>
|
||||
{% block head %}
|
||||
@@ -10,28 +10,115 @@
|
||||
content="ie=edge">
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet"
|
||||
href="/static/common/style.css?v={{version}}">
|
||||
href="/static/gallery.css?v={{version}}">
|
||||
<script type="module"
|
||||
src="/static/gallery.es.js?v={{version}}"></script>
|
||||
<link rel="icon"
|
||||
href="/static/common/favicon.ico?v={{version}}"
|
||||
href="/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>
|
||||
<body class="{{ is_widget and 'widget' or ''}}">
|
||||
<div class="app col-lg-8 mx-auto p-3 py-md-5">
|
||||
{% if not is_widget %}
|
||||
<header class="app-header pb-3 mb-5 border-bottom">
|
||||
<div>{{request.query_params.widget}}</div>
|
||||
<div class="link-list">
|
||||
<app-link href="/"
|
||||
icon="gear">API Gallery</app-link>
|
||||
{% block header %}{% endblock %}
|
||||
</div>
|
||||
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||
id="bd-language"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="{{_('Select language')}} (default)">
|
||||
<span class="fi fir fi-gb me-2 language-icon-active icon-header"></span>
|
||||
<span class="d-lg-none ms-2"
|
||||
id="bd-language-text">{{_("Select language")}}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end"
|
||||
aria-labelledby="bd-language-text">
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-language-value="en"
|
||||
aria-pressed="false">
|
||||
<span class="fi fir fi-gb me-2 language-icon-active"></span>
|
||||
{{_("English")}}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-language-value="ru"
|
||||
aria-pressed="false">
|
||||
<span class="fi fir fi-ru me-2 language-icon-active"></span>
|
||||
{{_("Russian")}}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||
id="bd-theme"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="Toggle theme (auto)">
|
||||
<span class="bi bi-circle-half me-2 opacity-50 theme-icon-active icon-header"></span>
|
||||
<span class="d-lg-none ms-2"
|
||||
id="bd-theme-text">{{_("Toggle theme")}}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end"
|
||||
aria-labelledby="bd-theme-text">
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-theme-value="light"
|
||||
aria-pressed="false">
|
||||
<span class="bi bi-sun-fill me-2 opacity-50 theme-icon"></span>
|
||||
{{_("Light")}}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center"
|
||||
data-bs-theme-value="dark"
|
||||
aria-pressed="false">
|
||||
<span class="bi bi-moon-stars-fill me-2 opacity-50 theme-icon"></span>
|
||||
{{_("Dark")}}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button"
|
||||
class="dropdown-item d-flex align-items-center active"
|
||||
data-bs-theme-value="auto"
|
||||
aria-pressed="true">
|
||||
<span class="bi bi-circle-half me-2 opacity-50 theme-icon"></span>
|
||||
{{_("Auto")}}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
{% endif %}
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% if not is_widget %}
|
||||
<footer class="app-footer pt-5 my-5 text-muted border-top">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>Created by shmyga · © 2026</span>
|
||||
<span>v{{ version }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{% 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 %}
|
||||
0
gallery/easel/route/view/common/utils/__init__.py
Normal file
53
gallery/easel/route/view/common/utils/template.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import datetime
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
from babel.dates import format_date
|
||||
from fastapi import Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.version import __version__
|
||||
|
||||
from ...translation import _
|
||||
from .tag import TagUtil
|
||||
|
||||
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
|
||||
|
||||
|
||||
def is_widget(request: Request) -> bool:
|
||||
return (request.url.hostname and request.url.hostname.startswith("weather")) or (
|
||||
request.query_params.get("widget") is not None
|
||||
)
|
||||
|
||||
|
||||
def base_context_processor(request: Request) -> dict:
|
||||
return {
|
||||
"is_widget": is_widget(request),
|
||||
"provider": request.query_params.get("provider"),
|
||||
}
|
||||
|
||||
|
||||
def build_templates(
|
||||
templates_dir: Path | None = None, filters: dict | None = None, context_processor: ContextProcessor | None = None
|
||||
) -> Jinja2Templates:
|
||||
directory = [Path(__file__).parent.parent / "templates"]
|
||||
if templates_dir:
|
||||
directory.append(templates_dir)
|
||||
context_processors: list[ContextProcessor] = [base_context_processor]
|
||||
if context_processor:
|
||||
context_processors.append(context_processor)
|
||||
templates = Jinja2Templates(directory=directory, context_processors=context_processors)
|
||||
templates.env.globals.update(
|
||||
{
|
||||
"_": _,
|
||||
"version": __version__,
|
||||
"format_date": format_date,
|
||||
"datetime": datetime,
|
||||
"tag_util": TagUtil,
|
||||
"DATE_FORMAT": "E, d MMMM Y",
|
||||
}
|
||||
)
|
||||
if filters:
|
||||
templates.env.filters.update(filters)
|
||||
return templates
|
||||
36
gallery/easel/route/view/root/__init__.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ..common.utils.template import build_templates
|
||||
|
||||
|
||||
class Section(NamedTuple):
|
||||
link: str
|
||||
title: str
|
||||
icon: str
|
||||
|
||||
|
||||
SECTIONS = [
|
||||
Section("weather", "Weather", "brightness-high"),
|
||||
Section("schedule", "TV program", "tv"),
|
||||
]
|
||||
|
||||
base_dir = Path(__file__).parent
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = build_templates(Path(__file__).parent / "templates")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def get_section_list(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="root_index.html",
|
||||
context={
|
||||
"sections": SECTIONS,
|
||||
},
|
||||
)
|
||||
33
gallery/easel/route/view/root/templates/root_index.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{_("Index")}}{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{_("View")}}</h1>
|
||||
<div class="list-group mb-3">
|
||||
{% for section in sections %}
|
||||
<app-link href="{{section.link}}"
|
||||
icon="{{section.icon}}"
|
||||
class="list-group-item list-group-item-action">
|
||||
{{_(section.title)}}
|
||||
</app-link>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<h1>{{_("Docs")}}</h1>
|
||||
<div class="list-group mb-3">
|
||||
<app-link href="/docs"
|
||||
target="_blank"
|
||||
icon="file-earmark-text"
|
||||
class="list-group-item list-group-item-action">
|
||||
Swagger UI
|
||||
</app-link>
|
||||
<app-link href="/redoc"
|
||||
target="_blank"
|
||||
icon="file-earmark-text"
|
||||
class="list-group-item list-group-item-action">
|
||||
ReDoc
|
||||
</app-link>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,82 +1,70 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.sketch.schedule.catalog import BUNDLE
|
||||
from gallery.version import __version__
|
||||
from gallery.easel.depends.api import api_resolver
|
||||
from gallery.easel.depends.schedule import ScheduleApiDepends
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
|
||||
from ..common.util import TagType, TagUtil
|
||||
from ..common.utils.tag import TagType, TagUtil
|
||||
from ..common.utils.template import build_templates
|
||||
from .filters import timedelta_format
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(
|
||||
directory=[
|
||||
base_dir.parent / "common/templates",
|
||||
base_dir / "templates",
|
||||
]
|
||||
def context_procesor(request: AppRequest) -> dict:
|
||||
return {
|
||||
"providers": request.app.state.api.get_api_providers(ScheduleApi),
|
||||
}
|
||||
|
||||
|
||||
templates = build_templates(
|
||||
Path(__file__).parent / "templates",
|
||||
{
|
||||
"timedelta_format": timedelta_format,
|
||||
},
|
||||
context_procesor,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/schedule")
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def get_schedule_index(request: AppRequest, provider: str | None = None, query: str | None = None):
|
||||
if query and provider:
|
||||
schedule_api = api_resolver(ScheduleApi)(request, provider)
|
||||
channels = await schedule_api.find_channels(query)
|
||||
else:
|
||||
channels = []
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"channels": channels,
|
||||
},
|
||||
)
|
||||
templates.env.filters["timedelta_format"] = timedelta_format
|
||||
|
||||
@app.get("/schedule", response_class=HTMLResponse)
|
||||
async def get_schedule_list(request: AppRequest):
|
||||
schedule_api = request.app.state.api.schedule
|
||||
channels = await schedule_api.get_channels()
|
||||
channels_data = BUNDLE.select_items(channels)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"channels": channels_data,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api = request.app.state.api.schedule
|
||||
results = await schedule_api.get_all_schedules(tag_value.date)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="schedule.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": results[0],
|
||||
"responses": results,
|
||||
"live": live,
|
||||
},
|
||||
)
|
||||
@router.get("/{provider}/{channel}", response_class=RedirectResponse)
|
||||
async def get_channel_default(channel: str):
|
||||
return RedirectResponse(f"{channel}/tag/today")
|
||||
|
||||
@app.get("/schedule/{channel}", response_class=RedirectResponse)
|
||||
async def get_channel_default(channel: str):
|
||||
return RedirectResponse(f"{channel}/tag/today")
|
||||
|
||||
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api = request.app.state.api.schedule
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="channel.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
@router.get("/{provider}/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_channel_tag(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await schedule_api.get_schedule(channel, tag_value.date)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="channel.html",
|
||||
context={
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,25 +0,0 @@
|
||||
table.schedule-table {
|
||||
width: 60rem;
|
||||
margin: auto;
|
||||
table-layout: auto
|
||||
}
|
||||
|
||||
table.schedule-table tr {
|
||||
border-bottom: 1px solid lightgray;
|
||||
}
|
||||
|
||||
table.schedule-table td {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table.schedule-table tr.live {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
table.schedule-table .title {
|
||||
margin-top: 0.5rem;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
font-size: 120%;
|
||||
background-color: lightgray;
|
||||
}
|
||||
@@ -1,40 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/schedule/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/schedule/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
|
||||
locale=request.state.language)}}
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<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="../..">⬆️</a>
|
||||
<span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
<app-link href="/schedule"
|
||||
icon="tv">{{_("TV program")}}</app-link>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<table class="schedule-table">
|
||||
<h4>
|
||||
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
|
||||
<i class="bi bi-arrow-left-square"></i>
|
||||
</a>
|
||||
<a class="icon-link"
|
||||
href="../../..">
|
||||
<i class="bi bi-arrow-up-square"></i>
|
||||
</a>
|
||||
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||
<a class="icon-link"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
|
||||
<i class="bi bi-arrow-right-square"></i>
|
||||
</a>
|
||||
</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for value in response.values %}
|
||||
<tr class="{{'live' if value.live else ''}}">
|
||||
<td>{{value.start.strftime('%H:%M')}}</td>
|
||||
<td>{{(value.end - value.start) | timedelta_format}}</td>
|
||||
<tr class="{{'table-success' if value.live else ''}}">
|
||||
<td>
|
||||
<span>{{value.start.strftime('%H:%M')}}</span>
|
||||
<span class="small ms-1">({{(value.end - value.start) | timedelta_format}})</span>
|
||||
</td>
|
||||
<td>{{value.label}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,21 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}ТВ{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/schedule/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/schedule/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}Телепрограмма{% endblock %}
|
||||
{% block title %}{{_("TV program")}}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<ul class="app-list">
|
||||
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li>
|
||||
{% for channel in channels %}
|
||||
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li>
|
||||
{% endfor %}
|
||||
<h1>{{_("TV program")}}</h1>
|
||||
<form action=""
|
||||
method="get"
|
||||
class="mb-4">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="query"
|
||||
name="query"
|
||||
placeholder="{{_('Enter the channel name')}}">
|
||||
<input type="hidden"
|
||||
class="form-control"
|
||||
id="provider"
|
||||
name="provider"
|
||||
value="{{provider or providers[0]}}">
|
||||
<button id="providerBtn"
|
||||
class="btn btn-secondary dropdown-toggle"
|
||||
type="text"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">{{provider or providers[0]}}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% for provider in providers %}
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
onclick="provider.value='{{provider}}'; providerBtn.innerText='{{provider}}'">{{provider}}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<button class="btn btn-primary"
|
||||
type="submit">{{_("Search")}}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if channels %}
|
||||
<ul id="channels"
|
||||
class="list-group mb-5">
|
||||
{% for channel in channels %}
|
||||
<schedule-channel channel="{{channel.model_dump() | tojson | forceescape}}"></schedule-channel>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<hr>
|
||||
{% endif %}
|
||||
<ul id="storedChannels"
|
||||
class="list-group mb-5">
|
||||
</ul>
|
||||
<script>
|
||||
(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const searchQuery = params.get('query');
|
||||
if (searchQuery) {
|
||||
document.querySelector('#query').value = searchQuery;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
scheduleChannelManager.loadChannels('#storedChannels');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,57 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}
|
||||
{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/schedule/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/schedule/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<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="..">⬆️</a>
|
||||
<span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
<table class="schedule-table {{'live' if live else ''}}">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for response in responses %}
|
||||
{% set values = (response.values|selectattr('live') if live else response.values)|list %}
|
||||
{% if values|length > 0 %}
|
||||
<tr class="title">
|
||||
<td colspan="3">
|
||||
<div>{{response.channel.name}}</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% for value in values %}
|
||||
<tr class="{{'live' if not live and value.live else ''}}">
|
||||
<td>{{value.start.strftime('%H:%M')}}</td>
|
||||
<td>{{(value.end - value.start) | timedelta_format}}</td>
|
||||
<td>{{value.label}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
31
gallery/easel/route/view/translation.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import gettext
|
||||
from contextvars import ContextVar
|
||||
|
||||
from fastapi import Cookie, Header, Request
|
||||
|
||||
from gallery.util import root_path
|
||||
|
||||
_translation: ContextVar[gettext.GNUTranslations | gettext.NullTranslations] = ContextVar("translation")
|
||||
|
||||
|
||||
async def set_language(
|
||||
request: Request,
|
||||
accept_language: str = Header("en"),
|
||||
language: str | None = Cookie(None),
|
||||
):
|
||||
# Simplify the header (e.g., "en-US,en;q=0.9" -> "en")
|
||||
lang = language or accept_language.split(",")[0].split("-")[0]
|
||||
|
||||
try:
|
||||
t = gettext.translation("messages", localedir=root_path / "locales", languages=[lang])
|
||||
except FileNotFoundError:
|
||||
t = gettext.NullTranslations()
|
||||
|
||||
token = _translation.set(t)
|
||||
request.state.language = lang
|
||||
yield lang
|
||||
_translation.reset(token)
|
||||
|
||||
|
||||
def _(message: str) -> str:
|
||||
return _translation.get().gettext(message)
|
||||
@@ -1,93 +1,101 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.sketch.weather.catalog import BUNDLE
|
||||
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
|
||||
from gallery.easel.depends.api import api_resolver
|
||||
from gallery.easel.depends.weather import WeatherApiDepends
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.model import WeatherResponse
|
||||
from gallery.version import __version__
|
||||
|
||||
from ..common.util import TagType, TagUtil
|
||||
from ..common.utils.tag import TagType, TagUtil
|
||||
from ..common.utils.template import build_templates
|
||||
from .filters import cloudness_icon, wind_direction_icon
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(
|
||||
directory=[
|
||||
base_dir.parent / "common/templates",
|
||||
base_dir / "templates",
|
||||
]
|
||||
def context_procesor(request: AppRequest) -> dict:
|
||||
return {
|
||||
"providers": request.app.state.api.get_api_providers(WeatherApi),
|
||||
}
|
||||
|
||||
|
||||
templates = build_templates(
|
||||
Path(__file__).parent / "templates",
|
||||
{
|
||||
"wind_direction_icon": wind_direction_icon,
|
||||
"cloudness_icon": cloudness_icon,
|
||||
},
|
||||
context_procesor,
|
||||
)
|
||||
|
||||
|
||||
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="weather.html",
|
||||
context={
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
||||
|
||||
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="weather.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/weather", response_class=HTMLResponse)
|
||||
async def get_weather_list(request: AppRequest):
|
||||
weather_api = request.app.state.api.weather
|
||||
locations = await weather_api.get_locations()
|
||||
locations_data = BUNDLE.select_items(locations)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"locations": locations_data,
|
||||
},
|
||||
)
|
||||
router = APIRouter(prefix="/weather")
|
||||
|
||||
@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: AppRequest):
|
||||
response = WEATHER_MOCK_DATA.get_response("day")
|
||||
return build_weather_response(request, response)
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def get_weather_index(request: AppRequest, provider: str | None = None, query: str | None = None):
|
||||
if query and provider:
|
||||
weather_api = api_resolver(WeatherApi)(request, provider)
|
||||
locations = await weather_api.find_locations(query)
|
||||
else:
|
||||
locations = []
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"locations": locations,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
|
||||
async def get_weather_days_mock(request: AppRequest):
|
||||
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: AppRequest, location: str, date: datetime.date):
|
||||
weather_api = request.app.state.api.weather
|
||||
response = await weather_api.get_day(location, date)
|
||||
return build_weather_response(request, response)
|
||||
@router.get("/{provider}/{location}", response_class=RedirectResponse)
|
||||
async def get_weather(location: str):
|
||||
return RedirectResponse(f"{location}/tag/today")
|
||||
|
||||
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
||||
async def get_weather_days(request: AppRequest, location: str, days: int):
|
||||
weather_api = request.app.state.api.weather
|
||||
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: AppRequest, location: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
weather_api = request.app.state.api.weather
|
||||
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)
|
||||
@router.get("/{provider}/{location}/day/{date}", response_class=HTMLResponse)
|
||||
async def get_weather_day(
|
||||
request: AppRequest,
|
||||
weather_api: WeatherApiDepends,
|
||||
location: str,
|
||||
date: datetime.date,
|
||||
):
|
||||
response = await weather_api.get_day(location, date)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
|
||||
@router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
|
||||
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
|
||||
response = await weather_api.get_days(location, days)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
|
||||
@router.get("/{provider}/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, location: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/zmiyevka-184640/tag/{tag}", response_class=RedirectResponse)
|
||||
async def get_zmiyevka_weather_compat(request: AppRequest):
|
||||
path_parts = request.url.path.split("/")
|
||||
path_parts.insert(2, "gismeteo")
|
||||
return RedirectResponse("/".join(path_parts))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import datetime
|
||||
|
||||
from gallery.sketch.weather.model import (
|
||||
Cloudness,
|
||||
Precipitation,
|
||||
@@ -9,40 +11,46 @@ from gallery.sketch.weather.model import (
|
||||
|
||||
def wind_direction_icon(wind_direction_deg: float) -> str:
|
||||
wind_direction = WindDirectionDeg(wind_direction_deg).direction
|
||||
return {
|
||||
WindDirection.N: "⬇️",
|
||||
WindDirection.NE: "↙️",
|
||||
WindDirection.E: "⬅️",
|
||||
WindDirection.SE: "↖️",
|
||||
WindDirection.S: "⬆️",
|
||||
WindDirection.SW: "↗️",
|
||||
WindDirection.W: "➡️",
|
||||
WindDirection.NW: "↘️",
|
||||
WindDirection.CALM: "",
|
||||
}.get(wind_direction, wind_direction)
|
||||
if wind_direction == WindDirection.CALM:
|
||||
return "wind-calm"
|
||||
else:
|
||||
return f"wind-from-{wind_direction.name.lower()}"
|
||||
|
||||
|
||||
def cloudness_icon(sky: Sky) -> list[str]:
|
||||
def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
|
||||
day = (3 < date.hour < 22) if period == "day" else True
|
||||
day_prefix = "day" if day else "night-alt"
|
||||
main_icon = ""
|
||||
if sky.thunder:
|
||||
if sky.cloudness == Cloudness.CLEAR:
|
||||
main_icon = "🌩️"
|
||||
if sky.precipitation == Precipitation.NO:
|
||||
main_icon = "⚡"
|
||||
else:
|
||||
main_icon = "⛈️"
|
||||
main_icon = {
|
||||
Precipitation.NO: "lightning",
|
||||
Precipitation.SMALL_RAIN: "storm-showers",
|
||||
Precipitation.RAIN: "thunderstorm",
|
||||
Precipitation.HEAVY_RAIN: "thunderstorm",
|
||||
Precipitation.SHOWER: "thunderstorm",
|
||||
Precipitation.SNOW: "storm-showers",
|
||||
Precipitation.HEAVY_SNOW: "storm-showers",
|
||||
}[sky.precipitation]
|
||||
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
|
||||
main_icon = f"{day_prefix}-{main_icon}"
|
||||
elif sky.precipitation == Precipitation.NO:
|
||||
main_icon = {
|
||||
Cloudness.CLEAR: "☀️",
|
||||
Cloudness.PARTLY_CLOUDY: "🌤️",
|
||||
Cloudness.CLOUDY: "⛅",
|
||||
Cloudness.MAINLY_CLOUDY: "☁️",
|
||||
Cloudness.CLEAR: "day-sunny" if day else "night-clear",
|
||||
Cloudness.PARTLY_CLOUDY: f"{day_prefix}-cloudy",
|
||||
Cloudness.CLOUDY: "cloud",
|
||||
Cloudness.MAINLY_CLOUDY: "cloudy",
|
||||
}[sky.cloudness]
|
||||
elif sky.precipitation in [Precipitation.SNOW, Precipitation.HEAVY_SNOW]:
|
||||
main_icon = "🌨️"
|
||||
else:
|
||||
main_icon = "🌧️"
|
||||
main_icon = {
|
||||
Precipitation.SMALL_RAIN: "showers",
|
||||
Precipitation.RAIN: "rain-mix",
|
||||
Precipitation.HEAVY_RAIN: "rain",
|
||||
Precipitation.SHOWER: "rain",
|
||||
Precipitation.SNOW: "snow",
|
||||
Precipitation.HEAVY_SNOW: "snow",
|
||||
Precipitation.HAIL: "hail",
|
||||
}[sky.precipitation]
|
||||
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
|
||||
main_icon = f"{day_prefix}-{main_icon}"
|
||||
icons = [main_icon]
|
||||
if sky.fog:
|
||||
icons.append("🌫️")
|
||||
return icons
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,72 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.5 KiB |
@@ -1,20 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Погода{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/weather/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/weather/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}Погода{% endblock %}
|
||||
{% block title %}{{_("Weather")}}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<ul class="app-list">
|
||||
<h1>{{_("Weather")}}</h1>
|
||||
<form action=""
|
||||
method="get"
|
||||
class="mb-4">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="query"
|
||||
name="query"
|
||||
placeholder="{{_('Enter the city name')}}">
|
||||
<input type="hidden"
|
||||
class="form-control"
|
||||
id="provider"
|
||||
name="provider"
|
||||
value="{{provider or providers[0]}}">
|
||||
<button id="providerBtn"
|
||||
class="btn btn-secondary dropdown-toggle"
|
||||
type="text"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">{{provider or providers[0]}}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% for provider in providers %}
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
onclick="provider.value='{{provider}}'; providerBtn.innerText='{{provider}}'">{{provider}}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<button class="btn btn-primary"
|
||||
type="submit">{{_("Search")}}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if locations %}
|
||||
<ul id="locations"
|
||||
class="list-group mb-5">
|
||||
{% for location in locations %}
|
||||
<li><a href="weather/{{location.id}}">{{location.name}}</a></li>
|
||||
<weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<hr>
|
||||
{% endif %}
|
||||
<ul id="storedLocations"
|
||||
class="list-group mb-5">
|
||||
</ul>
|
||||
<script>
|
||||
(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const searchQuery = params.get('query');
|
||||
if (searchQuery) {
|
||||
document.querySelector('#query').value = searchQuery;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
weatherLocationManager.loadLocations('#storedLocations');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,32 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet"
|
||||
href="/static/weather/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/weather/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
{% endblock %}
|
||||
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% 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 %}
|
||||
<app-link href="/weather"
|
||||
icon="brightness-high">{{_("Weather")}}</app-link>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div>
|
||||
<table style="margin: auto;">
|
||||
<h4>
|
||||
{% if response.period == 'day' %}
|
||||
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
|
||||
<i class="bi bi-arrow-left-square"></i>
|
||||
</a>
|
||||
<a class="icon-link"
|
||||
href="../tag/days-10">
|
||||
<i class="bi bi-arrow-up-square"></i>
|
||||
</a>
|
||||
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||
<a class="icon-link"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
|
||||
<i class="bi bi-arrow-right-square"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||
<span>- {{format_date(response.date + datetime.timedelta(days=(response.values | length - 1)), DATE_FORMAT,
|
||||
locale=request.state.language)}}</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-weather table-borderless table-compact text-center w-auto"
|
||||
style="font-size: 130%;">
|
||||
<tbody>
|
||||
<!-- date -->
|
||||
<tr>
|
||||
@@ -39,9 +44,9 @@
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
|
||||
<span class="value">
|
||||
<span class="value {{'text-danger' if value.date.weekday() in [5,6] else ''}}">
|
||||
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
|
||||
{{value.date.strftime('%a %d')}}
|
||||
{{format_date(value.date, 'E d', locale=request.state.language)}}
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
@@ -52,14 +57,16 @@
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Облачность
|
||||
{{_("Cloudiness")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="cloudness">
|
||||
{% for icon in value.sky | cloudness_icon %}
|
||||
<div class="icon">{{icon}}</div>
|
||||
<td class="cloudness"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ value.sky }}">
|
||||
{% for icon in value.sky | cloudness_icon(value.date, response.period) %}
|
||||
<div class="wi wi-{{icon}} wi-xl text-primary"></div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
@@ -68,7 +75,7 @@
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Температура, °C
|
||||
{{_("Temperature, °C")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -87,13 +94,15 @@
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Направление ветра
|
||||
{{_("Wind direction")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind">
|
||||
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
|
||||
<td class="wind"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ value.wind_direction }}">
|
||||
<div class="wi wi-wind-deg wi-{{value.wind_direction | wind_direction_icon}} wi-l text-primary"></div>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
@@ -101,7 +110,7 @@
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Скорость ветра, м/с
|
||||
{{_("Wind speed, m/s")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -121,14 +130,14 @@
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Осадки, мм
|
||||
{{_("Precipitation, mm")}}
|
||||
</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>
|
||||
<span class="value">{{(value.precipitation | round(2)) if value.precipitation else ' '}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
@@ -136,7 +145,7 @@
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Давление, мм рт. ст.
|
||||
{{_("Pressure, mmHg")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -154,7 +163,7 @@
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Влажность, %
|
||||
{{_("Humidity, %")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from gallery.sketch.source import ApiSource
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.catalog import LocationId
|
||||
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
|
||||
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
|
||||
|
||||
from . import datehelp
|
||||
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
|
||||
@@ -34,7 +35,7 @@ class GismeteoApi(WeatherApi):
|
||||
)
|
||||
|
||||
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
|
||||
result: List[Dict[str, Any]] = []
|
||||
result: list[dict[str, Any]] = []
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
location = LOCATION_PARSER.parse_location(data)
|
||||
widget = ONE_DAY_PARSER.parse_widget(soup)
|
||||
@@ -52,7 +53,7 @@ class GismeteoApi(WeatherApi):
|
||||
)
|
||||
|
||||
def _parse_manydays(self, data: str) -> WeatherResponse:
|
||||
result: List[Dict[str, Any]] = []
|
||||
result: list[dict[str, Any]] = []
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
location = LOCATION_PARSER.parse_location(data)
|
||||
widget = DAYS_PARSER.parse_widget(soup)
|
||||
@@ -69,13 +70,44 @@ class GismeteoApi(WeatherApi):
|
||||
values=values,
|
||||
)
|
||||
|
||||
async def get_locations(self) -> list[str]:
|
||||
return [
|
||||
LocationId.OREL,
|
||||
LocationId.ZMIYEVKA,
|
||||
]
|
||||
async def find_locations(self, query: str) -> list[Location]:
|
||||
geo = "ru"
|
||||
latitude = 52.968498
|
||||
longitude = 36.0695
|
||||
data = json.loads(
|
||||
await self.SOURCE.request(
|
||||
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
|
||||
)
|
||||
)
|
||||
result = []
|
||||
for item in data["data"]:
|
||||
result.append(
|
||||
Location(
|
||||
id=f"{item['slug']}-{item['id']}",
|
||||
name=item["translations"]["kk"]["city"]["name"],
|
||||
provider=self.provider,
|
||||
lat=item["coordinates"]["latitude"],
|
||||
lon=item["coordinates"]["longitude"],
|
||||
country=item["translations"]["kk"]["country"]["name"],
|
||||
country_code=item["country"]["code"].lower(),
|
||||
district=(
|
||||
item["translations"]["kk"]["district"]["name"] if item["translations"]["kk"]["district"] else ""
|
||||
),
|
||||
subdistrict=(
|
||||
item["translations"]["kk"]["subdistrict"]["name"]
|
||||
if "subdistrict" in item["translations"]["kk"]
|
||||
else ""
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
max_date = datetime.date.today() + datetime.timedelta(days=9)
|
||||
if date > max_date:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail={"max_date": max_date.strftime("%Y-%m-%d")}
|
||||
)
|
||||
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
|
||||
return self._parse_oneday(date, data)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from bs4 import Tag
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class WidgetParser:
|
||||
def parse_widget(self, tag: Tag) -> Tag:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from gallery.sketch.mock import MockData
|
||||
|
||||
GISMETEO_MOCK_DATA = MockData(Path(__file__).parent / "data")
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
@@ -15,6 +16,8 @@ from gallery.sketch.weather.model import (
|
||||
|
||||
from .core import BaseWidgetParser, RowParser
|
||||
|
||||
logger = logging.getLogger("gismeteo")
|
||||
|
||||
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
|
||||
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
|
||||
|
||||
@@ -36,16 +39,11 @@ class DateParser(RowParser[datetime.datetime]):
|
||||
KEY = "date"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
|
||||
datetime_date_tag = tag.select_one(
|
||||
".widget-row.widget-row-datetime-date > .row-item"
|
||||
)
|
||||
if datetime_date_tag:
|
||||
date_str = datetime_date_tag.find(text=True, recursive=False).text
|
||||
date = dateparser.parse(date_str, languages=["ru"])
|
||||
for item in tag.select(".widget-row.widget-row-datetime-time > .row-item"):
|
||||
time_str = item.text
|
||||
time = dateparser.parse(time_str, languages=["ru"])
|
||||
time = time.replace(year=date.year, month=date.month, day=date.day)
|
||||
datetime_time_row = tag.select_one(".widget-row.widget-row-datetime-time")
|
||||
if datetime_time_row:
|
||||
for item in datetime_time_row.select(".row-item > time-value"):
|
||||
timestamp = int(item.attrs["timestamp"])
|
||||
time = datetime.datetime.fromtimestamp(timestamp)
|
||||
yield time
|
||||
else:
|
||||
for item in tag.select(".widget-row.widget-row-date > .row-item"):
|
||||
@@ -59,6 +57,7 @@ class SkyParser(RowParser[Sky]):
|
||||
|
||||
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
||||
"ясно": Cloudness.CLEAR,
|
||||
"безоблачно": Cloudness.CLEAR,
|
||||
"малооблачно": Cloudness.PARTLY_CLOUDY,
|
||||
"облачно": Cloudness.CLOUDY,
|
||||
"пасмурно": Cloudness.MAINLY_CLOUDY,
|
||||
@@ -66,36 +65,51 @@ class SkyParser(RowParser[Sky]):
|
||||
|
||||
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
||||
"без осадков": Precipitation.NO,
|
||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||
"сильный дождь": Precipitation.HEAVY_RAIN,
|
||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||
"сильный дождь": Precipitation.HEAVY_RAIN,
|
||||
"ливневый дождь": Precipitation.SHOWER,
|
||||
"дождь": Precipitation.RAIN,
|
||||
"ливень": Precipitation.SHOWER,
|
||||
"снег": Precipitation.SNOW,
|
||||
"небольшой снег": Precipitation.SNOW,
|
||||
"сильный снег": Precipitation.HEAVY_SNOW,
|
||||
"мокрый снег": Precipitation.SNOW,
|
||||
"небольшой снег": Precipitation.SNOW,
|
||||
"сильный снег": Precipitation.HEAVY_SNOW,
|
||||
"мокрый снег": Precipitation.SNOW,
|
||||
"снег с дождём": Precipitation.SNOW,
|
||||
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
|
||||
"небольшой снег с дождём": Precipitation.SNOW,
|
||||
"небольшой мокрый снег": Precipitation.SNOW,
|
||||
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
|
||||
"небольшой снег с дождём": Precipitation.SNOW,
|
||||
"небольшой мокрый снег": Precipitation.SNOW,
|
||||
"град": Precipitation.HAIL,
|
||||
}
|
||||
|
||||
THUNDER = "гроза"
|
||||
FOG = "дымка"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[Sky]:
|
||||
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
|
||||
sky_str = item.attrs["data-tooltip"]
|
||||
values = {item.strip().lower() for item in sky_str.split(",")}
|
||||
cloudness = Cloudness.CLEAR
|
||||
precipitation = Precipitation.NO
|
||||
thunder = "гроза" in values
|
||||
fog = "дымка" in values
|
||||
thunder = False
|
||||
fog = False
|
||||
if self.THUNDER in values:
|
||||
thunder = True
|
||||
values.remove(self.THUNDER)
|
||||
if self.FOG in values:
|
||||
fog = True
|
||||
values.remove(self.FOG)
|
||||
for k, v in self.CLOUDNESS_MAP.items():
|
||||
if k in values:
|
||||
cloudness = v
|
||||
values.remove(k)
|
||||
break
|
||||
for k, v in self.PRECIPITATION_MAP.items():
|
||||
if k in values:
|
||||
precipitation = v
|
||||
values.remove(k)
|
||||
break
|
||||
if values:
|
||||
logger.warning("unknown sky values: %s:", values)
|
||||
yield Sky(
|
||||
cloudness=cloudness,
|
||||
precipitation=precipitation,
|
||||
@@ -108,21 +122,15 @@ class TemperatureParser(RowParser[list[int]]):
|
||||
KEY = "temperature"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
||||
for item in tag.select(
|
||||
".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"
|
||||
):
|
||||
yield [
|
||||
int(value.attrs["value"]) for value in item.select("temperature-value")
|
||||
]
|
||||
for item in tag.select(".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"):
|
||||
yield [int(value.attrs["value"]) for value in item.select("temperature-value")]
|
||||
|
||||
|
||||
class WindSpeedParser(RowParser[int]):
|
||||
KEY = "wind_speed"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(
|
||||
".widget-row-wind > .row-item > .wind-speed > speed-value"
|
||||
):
|
||||
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > speed-value"):
|
||||
yield int(item.attrs["value"])
|
||||
|
||||
|
||||
@@ -152,32 +160,28 @@ class WindDirectionParser(RowParser[WindDirection]):
|
||||
}
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||
for item in tag.select(
|
||||
".widget-row-wind > .row-item > .wind-speed > .wind-direction"
|
||||
):
|
||||
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > .wind-direction"):
|
||||
wind_direction_str = item.text.lower().strip()
|
||||
yield WindDirectionDeg.from_direction(
|
||||
self.WIND_DIRECTION_MAP[wind_direction_str]
|
||||
).value
|
||||
yield WindDirectionDeg.from_direction(self.WIND_DIRECTION_MAP[wind_direction_str]).value
|
||||
|
||||
|
||||
class PrecipitationParser(RowParser[float]):
|
||||
KEY = "precipitation"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||
for item in tag.select(
|
||||
".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"
|
||||
):
|
||||
yield float(item.text.replace(",", "."))
|
||||
for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item"):
|
||||
value = item.select_one("precipitation-value")
|
||||
if value:
|
||||
yield float(value.attrs["value"])
|
||||
else:
|
||||
yield 0
|
||||
|
||||
|
||||
class PressureParser(RowParser[list[int]]):
|
||||
KEY = "pressure"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
||||
for item in tag.select(
|
||||
".widget-row-chart[data-row=pressure] > .chart > .values > .value"
|
||||
):
|
||||
for item in tag.select(".widget-row-chart[data-row=pressure] > .chart > .values > .value"):
|
||||
yield [int(value.attrs["value"]) for value in item.select("pressure-value")]
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
|
||||
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||
from gallery.sketch.source import ApiSource
|
||||
|
||||
logger = logging.getLogger("matchtv")
|
||||
@@ -14,43 +13,30 @@ class MatchTvApi(ScheduleApi):
|
||||
PROVIDER = "matchtv"
|
||||
SOURCE = ApiSource("https://matchtv.ru")
|
||||
|
||||
async def get_channels(self) -> list[ChannelId]:
|
||||
return [
|
||||
ChannelId.MATCH_TV,
|
||||
ChannelId.MATCH_IGRA,
|
||||
ChannelId.MATCH_ARENA,
|
||||
ChannelId.MATCH_FUTBOL_1,
|
||||
ChannelId.MATCH_FUTBOL_2,
|
||||
ChannelId.MATCH_FUTBOL_3,
|
||||
ChannelId.MATCH_STRANA,
|
||||
]
|
||||
async def find_channels(self, query: str) -> list[Channel]:
|
||||
endpoint = "api/v1/channels"
|
||||
data = json.loads(await self.SOURCE.request(endpoint))
|
||||
result = []
|
||||
query = query.lower()
|
||||
for item in data["result"]:
|
||||
if query in item["name"].lower() or query in item["alias"]:
|
||||
channel_id = item["alias"]
|
||||
name = item["name"].split("|")[0].strip()
|
||||
result.append(Channel(id=channel_id, name=name, provider=self.provider))
|
||||
return result
|
||||
|
||||
async def get_channel_schedule(
|
||||
self, channel_id: ChannelId, date: datetime.date
|
||||
) -> Schedule:
|
||||
endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}"
|
||||
data = await self.SOURCE.request(endpoint)
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||
endpoint = f"api/v1/channels/{channel_id}/tv-schedule?date={date:%Y%m%d}"
|
||||
data = json.loads(await self.SOURCE.request(endpoint))
|
||||
channel_data = data["result"]["channels"][0]
|
||||
channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=self.provider)
|
||||
values = []
|
||||
channel_name = (
|
||||
soup.select_one(".p-tv-guide-header__title")
|
||||
.text.replace("Телепрограмма ", "")
|
||||
.strip()
|
||||
)
|
||||
current_day = datetime.datetime.combine(
|
||||
date.today(), datetime.datetime.min.time()
|
||||
)
|
||||
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
|
||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
||||
prev_value: ScheduleValue | None = None
|
||||
for item in soup.select(
|
||||
".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission"
|
||||
):
|
||||
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()
|
||||
for item in channel_data["schedule"]:
|
||||
title = item["title"]
|
||||
time_str = item["time"]
|
||||
hours, minutes = map(int, time_str.split(":"))
|
||||
item_date = current_day.replace(hour=hours, minute=minutes)
|
||||
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
||||
@@ -63,5 +49,7 @@ class MatchTvApi(ScheduleApi):
|
||||
prev_value.end = item_date
|
||||
prev_value = value
|
||||
return Schedule(
|
||||
channel=Channel(id=channel_id, name=channel_name), date=date, values=values
|
||||
channel=channel,
|
||||
date=date,
|
||||
values=values,
|
||||
)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from gallery.sketch.mock import MockData
|
||||
|
||||
MATCHTV_MOCK_DATA = MockData(Path(__file__).parent / "data")
|
||||
@@ -1,16 +1,18 @@
|
||||
import datetime
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from os import environ
|
||||
|
||||
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.model import Location, WeatherResponse, WeatherValue
|
||||
from gallery.sketch.weather.util import merge_weather_values
|
||||
from gallery.util import TimeUnit
|
||||
|
||||
from .openweather import Forecast, OpenWeather
|
||||
from .openweather import Forecast
|
||||
from .openweather import Location as OpenWeatherLocation
|
||||
from .openweather import OpenWeather
|
||||
from .parser import FORECAST_ITEM_PARSER
|
||||
|
||||
logger = logging.getLogger("openweather")
|
||||
@@ -18,13 +20,19 @@ logger = logging.getLogger("openweather")
|
||||
|
||||
class OpenWeatherApi(WeatherApi):
|
||||
PROVIDER = "openweather"
|
||||
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
|
||||
SOURCE = OpenWeather(environ["OPENWEATHER_KEY"])
|
||||
|
||||
async def get_locations(self) -> list[str]:
|
||||
return [
|
||||
LocationId.OREL,
|
||||
LocationId.ZMIYEVKA,
|
||||
]
|
||||
@classmethod
|
||||
def _parse_location(cls, location_id: str) -> tuple[float, float]:
|
||||
return tuple(map(float, location_id.split(":", maxsplit=2)))
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.location",
|
||||
alias="redis",
|
||||
ttl=TimeUnit.DAY,
|
||||
)
|
||||
async def _get_location(self, location_id: str) -> OpenWeatherLocation:
|
||||
return await self.SOURCE.get_location(*self._parse_location(location_id))
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
|
||||
@@ -32,17 +40,33 @@ class OpenWeatherApi(WeatherApi):
|
||||
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)
|
||||
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
|
||||
|
||||
async def find_locations(self, query: str) -> list[Location]:
|
||||
result = await self.SOURCE.find_locations(query)
|
||||
return [
|
||||
Location(
|
||||
id=f"{item.lat}:{item.lon}",
|
||||
name=item.name,
|
||||
provider=self.provider,
|
||||
lat=item.lat,
|
||||
lon=item.lon,
|
||||
country=item.country,
|
||||
country_code=item.country.lower(),
|
||||
district=item.state or "",
|
||||
subdistrict="",
|
||||
)
|
||||
for item in result
|
||||
]
|
||||
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
location: OpenWeatherLocation = await self._get_location(location_id)
|
||||
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,
|
||||
@@ -51,17 +75,14 @@ class OpenWeatherApi(WeatherApi):
|
||||
)
|
||||
|
||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||
location: OpenWeatherLocation = await self._get_location(location_id)
|
||||
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)
|
||||
values = [merge_weather_values(date, values) for date, values in values_by_date.items()]
|
||||
return WeatherResponse(
|
||||
location=location.name,
|
||||
date=datetime.date.today(),
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from gallery.sketch.mock import MockData
|
||||
|
||||
OPENWEATHER_MOCK_DATA = MockData(Path(__file__).parent / "data")
|
||||
@@ -53,7 +53,7 @@ class ForecastItem(Model):
|
||||
weather: list[Weather]
|
||||
clouds: Clouds
|
||||
wind: Wind
|
||||
visibility: int
|
||||
visibility: int | None = None
|
||||
pop: float
|
||||
rain: Rain | None = None
|
||||
sys: Sys
|
||||
@@ -67,6 +67,15 @@ class Forecast(Model):
|
||||
list: list[ForecastItem]
|
||||
|
||||
|
||||
class Location(Model):
|
||||
name: str
|
||||
lat: float
|
||||
lon: float
|
||||
country: str
|
||||
state: str | None = None
|
||||
local_names: dict[str, str] | None = None
|
||||
|
||||
|
||||
class OpenWeather:
|
||||
BASE_URL = "https://api.openweathermap.org"
|
||||
|
||||
@@ -75,9 +84,20 @@ class OpenWeather:
|
||||
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"
|
||||
)
|
||||
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)
|
||||
return Forecast.model_validate(response_data)
|
||||
|
||||
async def find_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]
|
||||
|
||||
async def get_location(self, lat: float, lon: float) -> Location:
|
||||
limit = 1
|
||||
endpoint = f"geo/1.0/reverse?lat={lat}&lon={lon}&limit={limit}&appid={self._api_key}"
|
||||
response = await self._source.request(endpoint)
|
||||
response_data = json.loads(response)
|
||||
return Location.model_validate(response_data[0])
|
||||
|
||||
@@ -24,11 +24,7 @@ class ForecastItemParser:
|
||||
|
||||
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)
|
||||
)
|
||||
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)]
|
||||
@@ -38,12 +34,8 @@ class ForecastItemParser:
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
|
||||
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||
from gallery.sketch.source import ApiSource
|
||||
|
||||
logger = logging.getLogger("matchtv")
|
||||
|
||||
CHANNELS_MAP: dict[ChannelId, str] = {
|
||||
ChannelId.MATCH_TV: "match-tv-49",
|
||||
ChannelId.MATCH_IGRA: "match-igra-1174",
|
||||
ChannelId.MATCH_ARENA: "match-arena-1173",
|
||||
ChannelId.MATCH_FUTBOL_1: "match-futbol-1-646",
|
||||
ChannelId.MATCH_FUTBOL_2: "match-futbol-2-593",
|
||||
ChannelId.MATCH_FUTBOL_3: "match-futbol-3-797",
|
||||
ChannelId.MATCH_STRANA: "match-strana-1356",
|
||||
ChannelId.MATCH_PLANETA: "match-planeta-1177",
|
||||
# ChannelId.EUROSPORT: "eurosport-677",
|
||||
# ChannelId.EUROSPORT_2: "eurosport-2-720",
|
||||
ChannelId.START: "start-103",
|
||||
}
|
||||
|
||||
HEADERS: dict[str, str] = {
|
||||
"Accept": (
|
||||
@@ -57,22 +45,28 @@ class YandexTvApi(ScheduleApi):
|
||||
PROVIDER = "yandextv"
|
||||
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
|
||||
|
||||
async def get_channels(self) -> list[ChannelId]:
|
||||
return list(CHANNELS_MAP.keys())
|
||||
async def find_channels(self, query: str) -> list[Channel]:
|
||||
url = (
|
||||
"https://suggest-multi.yandex.ru/suggest-tv2?"
|
||||
f"v=4&uil=ru&lr=10&count_channels=4&count_programs=4&sn=50&part={query}"
|
||||
)
|
||||
_, values = json.loads(await self.SOURCE.request(url))
|
||||
result = []
|
||||
for _, name, content in values:
|
||||
if content["label"] == "Каналы":
|
||||
channel_id = content["url"].split("/")[-1]
|
||||
result.append(Channel(id=channel_id, name=name, provider=self.provider))
|
||||
return result
|
||||
|
||||
async def get_channel_schedule(
|
||||
self, channel_id: ChannelId, date: datetime.date
|
||||
) -> Schedule:
|
||||
endpoint = f"channel/{CHANNELS_MAP[channel_id]}?date={date:%Y-%m-%d}"
|
||||
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||
endpoint = f"channels/{channel_id}?date={date:%Y-%m-%d}"
|
||||
data = await self.SOURCE.request(endpoint)
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
if soup.select_one(".CheckboxCaptcha") is not None:
|
||||
raise RuntimeError("Captcha")
|
||||
values = []
|
||||
channel_name = soup.select_one(".channel-header__text").text.strip()
|
||||
current_day = datetime.datetime.combine(
|
||||
date.today(), datetime.datetime.min.time()
|
||||
)
|
||||
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
|
||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
||||
prev_value: ScheduleValue | None = None
|
||||
for item in soup.select(".channel-schedule .channel-schedule__event"):
|
||||
@@ -90,5 +84,7 @@ class YandexTvApi(ScheduleApi):
|
||||
prev_value.end = item_date
|
||||
prev_value = value
|
||||
return Schedule(
|
||||
channel=Channel(id=channel_id, name=channel_name), date=date, values=values
|
||||
channel=Channel(id=channel_id, name=channel_name, provider=self.provider),
|
||||
date=date,
|
||||
values=values,
|
||||
)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from gallery.sketch.mock import MockData
|
||||
|
||||
YANDEXTV_MOCK_DATA = MockData(Path(__file__).parent / "data")
|
||||
@@ -2,8 +2,13 @@ from typing import TypeVar
|
||||
|
||||
|
||||
class Api:
|
||||
TYPE: str
|
||||
PROVIDER: str
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self.TYPE
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return self.PROVIDER
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
from collections import defaultdict
|
||||
|
||||
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)
|
||||
class ApiBundle:
|
||||
def __init__(self, values: list[Api]):
|
||||
self._values = values
|
||||
self._providers_by_api = defaultdict(list)
|
||||
self._api_map = {}
|
||||
for value in self._values:
|
||||
self._providers_by_api[value.type].append(value.provider)
|
||||
self._api_map[(value.type, value.provider)] = value
|
||||
|
||||
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_providers(self, api_type: type[API]) -> list[str]:
|
||||
return self._providers_by_api[api_type.TYPE]
|
||||
|
||||
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)
|
||||
def get_api(self, api_type: type[API], provider: str) -> API:
|
||||
return self._api_map[(api_type.TYPE, provider)]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class CatalogBundle(Generic[T]):
|
||||
def __init__(self, items: list[T]) -> None:
|
||||
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]:
|
||||
return [self._items_by_id[id_] for id_ in ids]
|
||||
@@ -1,17 +0,0 @@
|
||||
import json
|
||||
|
||||
|
||||
class MockData:
|
||||
|
||||
def __init__(self, data_dir) -> None:
|
||||
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:
|
||||
return self.get_text(f"{key}.html")
|
||||
|
||||
def get_json(self, key: str) -> dict:
|
||||
data = json.loads(self.get_text(f"{key}.json"))
|
||||
return data
|
||||
@@ -1,28 +1,14 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
from ..api import Api
|
||||
from .model import ChannelId, Schedule
|
||||
from .model import Channel, Schedule
|
||||
|
||||
|
||||
class ScheduleApi(Api):
|
||||
INTERVAL: float = 0.5
|
||||
TYPE = "schedule"
|
||||
|
||||
async def get_channels(self) -> list[ChannelId]:
|
||||
async def find_channels(self, query: str) -> list[Channel]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_channel_schedule(
|
||||
self, channel_id: ChannelId, date: datetime.date
|
||||
) -> Schedule:
|
||||
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
|
||||
channels = await self.get_channels()
|
||||
results = []
|
||||
for channel in channels:
|
||||
results.append(
|
||||
await self.get_channel_schedule(channel_id=channel, date=date)
|
||||
)
|
||||
if self.INTERVAL > 0:
|
||||
await asyncio.sleep(self.INTERVAL)
|
||||
return results
|
||||
|
||||
@@ -6,20 +6,20 @@ from gallery.sketch.cached import CachedApi, CachePreset
|
||||
from gallery.util import TimeUnit
|
||||
|
||||
from .api import ScheduleApi
|
||||
from .model import ChannelId, Schedule
|
||||
from .model import Channel, Schedule
|
||||
|
||||
CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
|
||||
|
||||
|
||||
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
||||
CACHE_KEY = "schedule"
|
||||
CACHE_KEY = ScheduleApi.TYPE
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",
|
||||
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.find.{query}",
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_channels(self) -> list[ChannelId]:
|
||||
return await self._api.get_channels()
|
||||
async def find_channels(self, query: str) -> list[Channel]:
|
||||
return await self._api.find_channels(query)
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self, channel_id, date: (
|
||||
@@ -27,16 +27,5 @@ class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
||||
),
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_channel_schedule(
|
||||
self, channel_id: ChannelId, date: datetime.date
|
||||
) -> Schedule:
|
||||
return await self._api.get_channel_schedule(channel_id, date)
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self, date: (
|
||||
f"api.{self.CACHE_KEY}.{self.provider}.all.{date}"
|
||||
),
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
|
||||
return await self._api.get_all_schedules(date)
|
||||
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||
return await self._api.get_schedule(channel_id, date)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
from gallery.sketch.catalog import CatalogBundle
|
||||
|
||||
from .model import Channel, ChannelId
|
||||
|
||||
BUNDLE = CatalogBundle(
|
||||
[
|
||||
Channel(id=ChannelId.MATCH_TV, name="Матч ТВ"),
|
||||
Channel(id=ChannelId.MATCH_IGRA, name="Матч! Игра"),
|
||||
Channel(id=ChannelId.MATCH_ARENA, name="Матч! Арена"),
|
||||
Channel(id=ChannelId.MATCH_FUTBOL_1, name="Футбол 1"),
|
||||
Channel(id=ChannelId.MATCH_FUTBOL_2, name="Футбол 2"),
|
||||
Channel(id=ChannelId.MATCH_FUTBOL_3, name="Футбол 3"),
|
||||
Channel(id=ChannelId.MATCH_STRANA, name="Матч! Страна"),
|
||||
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
|
||||
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
|
||||
Channel(id=ChannelId.EUROSPORT, name="Europsort"),
|
||||
Channel(id=ChannelId.EUROSPORT_2, name="Europsort 2"),
|
||||
Channel(id=ChannelId.START, name="Старт!"),
|
||||
Channel(id=ChannelId.TEST, name="Тест"),
|
||||
]
|
||||
)
|
||||
@@ -1,5 +1,4 @@
|
||||
import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -9,27 +8,10 @@ class Model(BaseModel):
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class ChannelId(StrEnum):
|
||||
MATCH_TV = "matchtv"
|
||||
MATCH_IGRA = "igra"
|
||||
MATCH_ARENA = "arena"
|
||||
MATCH_FUTBOL_1 = "futbol-1"
|
||||
MATCH_FUTBOL_2 = "futbol-2"
|
||||
MATCH_FUTBOL_3 = "futbol-3"
|
||||
MATCH_STRANA = "strana"
|
||||
MATCH_PLANETA = "planeta"
|
||||
EUROSPORT = "eurosport"
|
||||
EUROSPORT_2 = "eurosport-2"
|
||||
START = "start"
|
||||
TEST = "test"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class Channel(Model):
|
||||
id: ChannelId
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
|
||||
|
||||
class ScheduleValue(Model):
|
||||
|
||||
@@ -7,9 +7,7 @@ logger = logging.getLogger("source")
|
||||
|
||||
class ApiSource:
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/126.0.0.0 Safari/537.36"
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
)
|
||||
DEFAULT_TIMEOUT = 30.0
|
||||
|
||||
@@ -28,7 +26,10 @@ class ApiSource:
|
||||
self._headers = headers
|
||||
|
||||
async def request(self, endpoint: str) -> str:
|
||||
url = f"{self._base_url}/{endpoint}"
|
||||
if endpoint.startswith("https:"):
|
||||
url = endpoint
|
||||
else:
|
||||
url = f"{self._base_url}/{endpoint}"
|
||||
logger.info(url)
|
||||
headers = {"User-Agent": self._user_agent, **(self._headers or {})}
|
||||
async with aiohttp.ClientSession(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import datetime
|
||||
|
||||
from ..api import Api
|
||||
from .model import WeatherResponse
|
||||
from .model import Location, WeatherResponse
|
||||
|
||||
|
||||
class WeatherApi(Api):
|
||||
TYPE = "weather"
|
||||
|
||||
async def get_locations(self) -> list[str]:
|
||||
async def find_locations(self, query: str) -> list[Location]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
|
||||
@@ -5,20 +5,20 @@ from aiocache import cached
|
||||
from gallery.sketch.cached import DEFAULT_CACHE_PRESET, CachedApi
|
||||
|
||||
from .api import WeatherApi
|
||||
from .model import WeatherResponse
|
||||
from .model import Location, WeatherResponse
|
||||
|
||||
CACHE_PRESET = DEFAULT_CACHE_PRESET
|
||||
|
||||
|
||||
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
||||
CACHE_KEY = "weather"
|
||||
CACHE_KEY = WeatherApi.TYPE
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.locations",
|
||||
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_locations(self) -> list[str]:
|
||||
return await self._api.get_locations()
|
||||
async def find_locations(self, query: str) -> list[Location]:
|
||||
return await self._api.find_locations(query)
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self, location_id, date: (
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
from gallery.sketch.catalog import CatalogBundle
|
||||
|
||||
from .model import Location
|
||||
|
||||
|
||||
class LocationId(str, Enum):
|
||||
OREL = "orel-4432"
|
||||
ZMIYEVKA = "zmiyevka-184640"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
BUNDLE = CatalogBundle(
|
||||
[
|
||||
Location(
|
||||
id=LocationId.OREL,
|
||||
name="Орёл",
|
||||
lat=52.9687747,
|
||||
lon=36.0694937,
|
||||
),
|
||||
Location(
|
||||
id=LocationId.ZMIYEVKA,
|
||||
name="Змиёвка",
|
||||
lat=52.672192,
|
||||
lon=36.380112,
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from gallery.sketch.mock import MockData
|
||||
from gallery.sketch.weather.model import WeatherResponse
|
||||
|
||||
|
||||
class WeatherMockData(MockData):
|
||||
def get_response(self, key: str) -> WeatherResponse:
|
||||
return WeatherResponse(**self.get_json(key))
|
||||
|
||||
|
||||
WEATHER_MOCK_DATA = WeatherMockData(Path(__file__).parent / "data")
|
||||
@@ -1 +0,0 @@
|
||||
{"location":"Орел","date":"2024-07-29","period":"day","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[20],"wind_speed":1,"wind_gust":1,"wind_direction":"SW","precipitation":0.0,"pressure":[744],"humidity":85},{"date":"2024-07-29T03:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[18],"wind_speed":1,"wind_gust":1,"wind_direction":"W","precipitation":0.6,"pressure":[742],"humidity":96},{"date":"2024-07-29T06:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":1,"wind_gust":2,"wind_direction":"S","precipitation":4.9,"pressure":[741],"humidity":95},{"date":"2024-07-29T09:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":7,"wind_direction":"S","precipitation":3.8,"pressure":[740],"humidity":83},{"date":"2024-07-29T12:00:00","sky":{"cloudness":"clear","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":0.0,"pressure":[740],"humidity":54},{"date":"2024-07-29T15:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[738],"humidity":48},{"date":"2024-07-29T18:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":63},{"date":"2024-07-29T21:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[17],"wind_speed":3,"wind_gust":7,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":77}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"location":"Орел","date":"2024-07-29","period":"days","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[21,17],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":9.3,"pressure":[744,737],"humidity":96},{"date":"2024-07-30T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":true,"fog":false},"temperature":[19,14],"wind_speed":2,"wind_gust":7,"wind_direction":"N","precipitation":11.0,"pressure":[737,733],"humidity":100},{"date":"2024-07-31T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[22,14],"wind_speed":3,"wind_gust":10,"wind_direction":"NW","precipitation":1.8,"pressure":[741,738],"humidity":99},{"date":"2024-07-01T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":10,"wind_direction":"W","precipitation":0.1,"pressure":[741,740],"humidity":97},{"date":"2024-07-02T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,17],"wind_speed":2,"wind_gust":8,"wind_direction":"W","precipitation":0.2,"pressure":[740],"humidity":84},{"date":"2024-07-03T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":1,"wind_gust":4,"wind_direction":"N","precipitation":0.0,"pressure":[740,739],"humidity":99},{"date":"2024-07-04T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":3,"wind_gust":6,"wind_direction":"N","precipitation":0.0,"pressure":[743,740],"humidity":92},{"date":"2024-07-05T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":true,"fog":false},"temperature":[25,15],"wind_speed":3,"wind_gust":7,"wind_direction":"NW","precipitation":2.1,"pressure":[744,743],"humidity":98},{"date":"2024-07-06T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":5,"wind_direction":"NW","precipitation":0.3,"pressure":[745,744],"humidity":98},{"date":"2024-07-07T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[26,14],"wind_speed":2,"wind_gust":5,"wind_direction":"NW","precipitation":0.2,"pressure":[747,745],"humidity":95}]}
|
||||
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
from enum import Enum
|
||||
from enum import StrEnum, auto
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -12,25 +13,31 @@ class Model(BaseModel):
|
||||
class Location(Model):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
lat: float
|
||||
lon: float
|
||||
country: str
|
||||
country_code: str
|
||||
district: str
|
||||
subdistrict: str
|
||||
|
||||
|
||||
class Cloudness(str, Enum):
|
||||
CLEAR = "clear"
|
||||
PARTLY_CLOUDY = "party_cloudy"
|
||||
CLOUDY = "cloudy"
|
||||
MAINLY_CLOUDY = "mainly_cloudy"
|
||||
class Cloudness(StrEnum):
|
||||
CLEAR = auto()
|
||||
PARTLY_CLOUDY = auto()
|
||||
CLOUDY = auto()
|
||||
MAINLY_CLOUDY = auto()
|
||||
|
||||
|
||||
class Precipitation(str, Enum):
|
||||
NO = "no"
|
||||
SMALL_RAIN = "small_rain"
|
||||
RAIN = "rain"
|
||||
HEAVY_RAIN = "heavy_rain"
|
||||
SHOWER = "shower"
|
||||
SNOW = "snow"
|
||||
HEAVY_SNOW = "heavy_snow"
|
||||
class Precipitation(StrEnum):
|
||||
NO = auto()
|
||||
SMALL_RAIN = auto()
|
||||
RAIN = auto()
|
||||
HEAVY_RAIN = auto()
|
||||
SHOWER = auto()
|
||||
SNOW = auto()
|
||||
HEAVY_SNOW = auto()
|
||||
HAIL = auto()
|
||||
|
||||
|
||||
class Sky(Model):
|
||||
@@ -40,16 +47,16 @@ class Sky(Model):
|
||||
fog: bool
|
||||
|
||||
|
||||
class WindDirection(str, Enum):
|
||||
CALM = "calm"
|
||||
N = "N"
|
||||
NE = "NE"
|
||||
E = "E"
|
||||
SE = "SE"
|
||||
S = "S"
|
||||
SW = "SW"
|
||||
W = "W"
|
||||
NW = "NW"
|
||||
class WindDirection(StrEnum):
|
||||
CALM = auto()
|
||||
N = auto()
|
||||
NE = auto()
|
||||
E = auto()
|
||||
SE = auto()
|
||||
S = auto()
|
||||
SW = auto()
|
||||
W = auto()
|
||||
NW = auto()
|
||||
|
||||
|
||||
class WindDirectionDeg(float):
|
||||
@@ -63,7 +70,9 @@ class WindDirectionDeg(float):
|
||||
|
||||
# pylint:disable=too-many-return-statements
|
||||
def to_direction(self) -> WindDirection:
|
||||
if self > 337.5 or self <= 22.25:
|
||||
if self == -1:
|
||||
return WindDirection.CALM
|
||||
elif self > 337.5 or self <= 22.25:
|
||||
return WindDirection.N
|
||||
elif self <= 67.5:
|
||||
return WindDirection.NE
|
||||
@@ -80,10 +89,10 @@ class WindDirectionDeg(float):
|
||||
elif self <= 337.5:
|
||||
return WindDirection.NW
|
||||
else:
|
||||
return WindDirection.CALM
|
||||
raise ValueError(self)
|
||||
|
||||
@classmethod
|
||||
def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg":
|
||||
def from_direction(cls, direction: WindDirection) -> Self:
|
||||
return cls(
|
||||
{
|
||||
WindDirection.CALM: -1,
|
||||
|
||||
@@ -23,9 +23,7 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
|
||||
)
|
||||
|
||||
|
||||
def merge_weather_values(
|
||||
date: datetime.datetime, values: list[WeatherValue]
|
||||
) -> WeatherValue:
|
||||
def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) -> WeatherValue:
|
||||
result = build_weather_value(date)
|
||||
temperatures = []
|
||||
pressures = []
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TimeUnit:
|
||||
SECOND = 1
|
||||
MINUTE = 60 * SECOND
|
||||
HOUR = 60 * MINUTE
|
||||
DAY = 24 * HOUR
|
||||
|
||||
|
||||
root_path = Path(__file__).parent.parent
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
__version__ = "0.1.0"
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["tool"]["poetry"]["version"]
|
||||
|
||||
77
locales/ru/LC_MESSAGES/messages.po
Normal file
@@ -0,0 +1,77 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Gallery\n"
|
||||
"Last-Translator: shmyga <shmyga.z@gmail.com>\n"
|
||||
"Language: ru\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
msgid "Index"
|
||||
msgstr "Содержание"
|
||||
|
||||
msgid "View"
|
||||
msgstr "Просмотр"
|
||||
|
||||
msgid "Docs"
|
||||
msgstr "Документация"
|
||||
|
||||
msgid "Toggle theme"
|
||||
msgstr "Переключить тему"
|
||||
|
||||
msgid "Light"
|
||||
msgstr "Светлая"
|
||||
|
||||
msgid "Dark"
|
||||
msgstr "Тёмная"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
msgid "Select language"
|
||||
msgstr "Выберите язык"
|
||||
|
||||
msgid "English"
|
||||
msgstr "Английский"
|
||||
|
||||
msgid "Russian"
|
||||
msgstr "Русский"
|
||||
|
||||
# weather
|
||||
msgid "Weather"
|
||||
msgstr "Погода"
|
||||
|
||||
msgid "Enter the city name"
|
||||
msgstr "Введите название города"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
msgid "Cloudiness"
|
||||
msgstr "Облачность"
|
||||
|
||||
msgid "Temperature, °C"
|
||||
msgstr "Температура, °C"
|
||||
|
||||
msgid "Wind direction"
|
||||
msgstr "Направление ветра"
|
||||
|
||||
msgid "Wind speed, m/s"
|
||||
msgstr "Скорость ветра, м/с"
|
||||
|
||||
msgid "Precipitation, mm"
|
||||
msgstr "Осадки, мм"
|
||||
|
||||
msgid "Pressure, mmHg"
|
||||
msgstr "Давление, мм рт. ст."
|
||||
|
||||
msgid "Humidity, %"
|
||||
msgstr "Влажность, %"
|
||||
|
||||
# tv
|
||||
msgid "TV program"
|
||||
msgstr "Телепрограмма"
|
||||
|
||||
msgid "Live broadcasts"
|
||||
msgstr "Прямые трансляции"
|
||||
3417
poetry.lock
generated
@@ -1,31 +1,32 @@
|
||||
[tool.poetry]
|
||||
name = "gallery"
|
||||
version = "0.1.0"
|
||||
version = "0.4.1"
|
||||
description = ""
|
||||
authors = ["shmyga <shmyga.z@gmail.com>"]
|
||||
readme = "README.md"
|
||||
packages = [{ include = "gallery" }]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
aiohttp = "^3.9.5"
|
||||
beautifulsoup4 = "^4.12.3"
|
||||
dateparser = "^1.2.0"
|
||||
pydantic = "^2.8.2"
|
||||
aiocache = {extras = ["redis"], version = "^0.12.2"}
|
||||
python = "^3.14"
|
||||
aiohttp = "^3.14"
|
||||
beautifulsoup4 = "^4.15"
|
||||
dateparser = "^1.4"
|
||||
pydantic = "^2.13"
|
||||
aiocache = { extras = ["redis"], version = "^0.12" }
|
||||
|
||||
[tool.poetry.group.app.dependencies]
|
||||
fastapi = "^0.111.1"
|
||||
jinja2 = "^3.1.4"
|
||||
fastapi = {extras = ["standard"], version = "^0.139.0"}
|
||||
jinja2 = "^3.1"
|
||||
babel = "^2.18"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
pytest = "^8.3.1"
|
||||
pytest-asyncio = "^0.23.8"
|
||||
pytest = "^9.1"
|
||||
pytest-asyncio = "^1.4"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pylint = "^3.2.6"
|
||||
black = "^24.4.2"
|
||||
isort = "^5.13.2"
|
||||
pylint = "^4.0"
|
||||
black = "^26.5"
|
||||
isort = "^8.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
@@ -34,9 +35,13 @@ build-backend = "poetry.core.masonry.api"
|
||||
[tool.poetry.scripts]
|
||||
gallery = "gallery.main:run"
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-p no:warnings"
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.poetry_bumpversion.file."gallery/version.py"]
|
||||
8
scripts/format
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
TARGET="gallery"
|
||||
|
||||
poetry run isort $TARGET
|
||||
poetry run black $TARGET -q
|
||||
@@ -2,4 +2,8 @@
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
poetry run pylint gallery
|
||||
TARGET="gallery"
|
||||
|
||||
poetry run pylint $TARGET -sn
|
||||
poetry run isort $TARGET --check-only
|
||||
poetry run black $TARGET -q --check --diff
|
||||
|
||||
6
scripts/locales
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
cd locales/ru/LC_MESSAGES || exit
|
||||
msgfmt messages.po
|
||||
22
scripts/setup
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
# env
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env-base .env
|
||||
fi
|
||||
source .env
|
||||
|
||||
# python
|
||||
poetry env use ${PYTHON_VERSION}
|
||||
poetry install
|
||||
|
||||
# static
|
||||
cd static || exit
|
||||
|
||||
if [[ -f $HOME/.nvm/nvm.sh ]]; then
|
||||
source "$HOME/.nvm/nvm.sh"
|
||||
nvm use
|
||||
fi
|
||||
npm ci
|
||||
@@ -2,4 +2,4 @@
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
poetry run pytest tests
|
||||
OPENWEATHER_KEY="" poetry run pytest tests
|
||||
|
||||
19
scripts/version
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 [version]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -z "$(git status -s)" ]]; then
|
||||
echo "Uncomitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
poetry version $1
|
||||
(cd static && npm version $1 --allow-same-version)
|
||||
git add .
|
||||
git commit -m "ci(version): $1"
|
||||
git tag $1
|
||||
2
static/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
1
static/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
1241
static/package-lock.json
generated
Normal file
21
static/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "gallery",
|
||||
"version": "0.4.1",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"author": "shmyga <shmyga.z@gmail.com>",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"flag-icons": "^7.5.0",
|
||||
"sass": "^1.99.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
17
static/src/components.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
class AppLinkElement extends HTMLElement {
|
||||
static observedAttributes = ["icon", "href", "target"];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = `
|
||||
<a href="${this.getAttribute("href")}" target="${this.getAttribute("target") || ""}"
|
||||
class="d-flex align-items-center text-body text-decoration-none">
|
||||
<span class="fs-4">
|
||||
<span class="bi bi-${this.getAttribute("icon")} me-1"></span>
|
||||
<span>${this.textContent}</span>
|
||||
</span>
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("app-link", AppLinkElement);
|
||||
12
static/src/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "./components";
|
||||
import "./language";
|
||||
import "./main.scss";
|
||||
import "./theme";
|
||||
import "./weather/weather";
|
||||
import "./schedule/schedule";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
const tooltipList = [...tooltipTriggerList].map((tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||
});
|
||||
64
static/src/language.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
(() => {
|
||||
const getStoredLanguage = () => {
|
||||
const m = document.cookie.match(/language=(\w+)/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const setStoredLanguage = (language: string) => (document.cookie = `language=${language}; max-age=34560000; path=/`);
|
||||
|
||||
const getPreferredLanguage = () => {
|
||||
const storedLanguage = getStoredLanguage();
|
||||
if (storedLanguage) {
|
||||
return storedLanguage;
|
||||
}
|
||||
const result = window.navigator.language.split("-")[0];
|
||||
return ["en", "ru"].includes(result) ? result : "en";
|
||||
};
|
||||
|
||||
const setLanguage = (language: string) => {};
|
||||
|
||||
setLanguage(getPreferredLanguage());
|
||||
|
||||
const showActiveLanguage = (language: string, focus = false) => {
|
||||
const languageSwitcher = document.querySelector("#bd-language");
|
||||
|
||||
if (!languageSwitcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const languageSwitcherText = document.querySelector("#bd-language-text");
|
||||
const activeLanguageIcon = document.querySelector(".language-icon-active");
|
||||
const btnToActive = document.querySelector(`[data-bs-language-value="${language}"]`);
|
||||
const activeLanguageIconClass = btnToActive.querySelector(".fi").className.match(/fi-[\w-]+/)[0];
|
||||
|
||||
document.querySelectorAll("[data-bs-language-value]").forEach((element) => {
|
||||
element.classList.remove("active");
|
||||
element.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
btnToActive.classList.add("active");
|
||||
btnToActive.setAttribute("aria-pressed", "true");
|
||||
const classesToRemove = Array.from(activeLanguageIcon.classList).filter((className) => className.startsWith("fi-"));
|
||||
activeLanguageIcon.classList.remove(...classesToRemove);
|
||||
activeLanguageIcon.classList.add(activeLanguageIconClass);
|
||||
const languageSwitcherLabel = `${languageSwitcherText.textContent} (${btnToActive.dataset.bsLanguageValue})`;
|
||||
languageSwitcher.setAttribute("aria-label", languageSwitcherLabel);
|
||||
|
||||
if (focus) {
|
||||
languageSwitcher.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
showActiveLanguage(getPreferredLanguage());
|
||||
|
||||
document.querySelectorAll("[data-bs-language-value]").forEach((toggle) => {
|
||||
toggle.addEventListener("click", () => {
|
||||
const language = toggle.getAttribute("data-bs-language-value") || "";
|
||||
setStoredLanguage(language);
|
||||
setLanguage(language);
|
||||
showActiveLanguage(language, true);
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
48
static/src/lib/bootstrap-icons.scss
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
.icon-link {
|
||||
vertical-align: -0.25rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
vertical-align: -0.125em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
|
||||
mask-size: contain;
|
||||
mask-position: 50%;
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
|
||||
&.bi-circle-half {
|
||||
mask-image: url(bootstrap-icons/icons/circle-half.svg);
|
||||
}
|
||||
&.bi-moon-stars-fill {
|
||||
mask-image: url(bootstrap-icons/icons/moon-stars-fill.svg);
|
||||
}
|
||||
&.bi-brightness-high {
|
||||
mask-image: url(bootstrap-icons/icons/brightness-high.svg);
|
||||
}
|
||||
&.bi-gear {
|
||||
mask-image: url(bootstrap-icons/icons/gear.svg);
|
||||
}
|
||||
&.bi-sun-fill {
|
||||
mask-image: url(bootstrap-icons/icons/sun-fill.svg);
|
||||
}
|
||||
&.bi-tv {
|
||||
mask-image: url(bootstrap-icons/icons/tv.svg);
|
||||
}
|
||||
&.bi-arrow-left-square {
|
||||
mask-image: url(bootstrap-icons/icons/arrow-left-square.svg);
|
||||
}
|
||||
&.bi-arrow-right-square {
|
||||
mask-image: url(bootstrap-icons/icons/arrow-right-square.svg);
|
||||
}
|
||||
&.bi-arrow-up-square {
|
||||
mask-image: url(bootstrap-icons/icons/arrow-up-square.svg);
|
||||
}
|
||||
&.bi-file-earmark-text {
|
||||
mask-image: url(bootstrap-icons/icons/file-earmark-text.svg);
|
||||
}
|
||||
}
|
||||
52
static/src/lib/bootstrap.scss
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
@import "bootstrap/scss/mixins/banner";
|
||||
@include bsBanner("");
|
||||
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/utilities";
|
||||
|
||||
// Layout & components
|
||||
@import "bootstrap/scss/root";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/type";
|
||||
//@import "bootstrap/scss/images";
|
||||
//@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
@import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
//@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
//@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/nav";
|
||||
@import "bootstrap/scss/navbar";
|
||||
//@import "bootstrap/scss/card";
|
||||
//@import "bootstrap/scss/accordion";
|
||||
//@import "bootstrap/scss/breadcrumb";
|
||||
//@import "bootstrap/scss/pagination";
|
||||
@import "bootstrap/scss/badge";
|
||||
//@import "bootstrap/scss/alert";
|
||||
//@import "bootstrap/scss/progress";
|
||||
@import "bootstrap/scss/list-group";
|
||||
//@import "bootstrap/scss/close";
|
||||
//@import "bootstrap/scss/toasts";
|
||||
//@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/tooltip";
|
||||
//@import "bootstrap/scss/popover";
|
||||
//@import "bootstrap/scss/carousel";
|
||||
//@import "bootstrap/scss/spinners";
|
||||
//@import "bootstrap/scss/offcanvas";
|
||||
//@import "bootstrap/scss/placeholders";
|
||||
|
||||
// Helpers
|
||||
@import "bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
// scss-docs-end import-stack
|
||||