Compare commits
76 Commits
d3ef03a6a0
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| b0245a1714 | |||
| 91b530f84c | |||
| 2b1dfad929 | |||
| 1df83ec34d | |||
| 1c4dac74ca | |||
| 75343a21b9 | |||
| 83fabdd538 | |||
| 29364bb7d4 | |||
| 195bd76608 | |||
| 436c6c7364 | |||
| 0a734f9c43 | |||
| 6171ec8a26 | |||
| 72fd148a54 | |||
| 12774db70f | |||
| 0008e99c66 | |||
| bad5dff2b2 | |||
| e2af31b812 | |||
| 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 | |||
| 3dd0a5410c | |||
| a0e6f30e3b | |||
| 29fa6435ce | |||
| a886322d0e | |||
| 6112147b40 | |||
| ad8144df37 | |||
| f303d0e1f4 | |||
| 3e80ccb0df |
13
.editorconfig
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
max_line_length = 120
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
9
.env-base
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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[^"]+')
|
||||||
|
PYTHON_VERSION=$(grep -m 1 'python' ./pyproject.toml | grep -oP 'python\s*=\s*"\^?\K[^"]+')
|
||||||
|
DOCKER_PROJECTS=("gallery")
|
||||||
|
DOCKER_TAG=latest
|
||||||
|
|
||||||
|
OPENWEATHER_KEY="<EMPTY>"
|
||||||
5
.gitignore
vendored
@@ -1,4 +1,9 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
|
*.mo
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.venv
|
.venv
|
||||||
#.vscode
|
#.vscode
|
||||||
|
static/node_modules
|
||||||
|
static/dist
|
||||||
|
.env
|
||||||
|
dist
|
||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
32
Dockerfile
@@ -1,23 +1,35 @@
|
|||||||
FROM python:3.12 AS builder
|
FROM python:3.14 AS builder
|
||||||
ENV POETRY_HOME="/opt/poetry"
|
ENV POETRY_HOME="/opt/poetry"
|
||||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
ENV PATH="$POETRY_HOME/bin:$PATH"
|
||||||
|
RUN apt update && \
|
||||||
|
apt install -y gettext
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
COPY pyproject.toml poetry.lock ./
|
COPY pyproject.toml poetry.lock README.md ./
|
||||||
RUN poetry config virtualenvs.in-project true
|
RUN poetry config virtualenvs.in-project true
|
||||||
RUN poetry install --with app
|
RUN --mount=type=cache,target=/root/.cache/pypoetry/cache \
|
||||||
|
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
|
||||||
|
poetry install --extras 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"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
WORKDIR /app
|
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"
|
ENV TZ="Europe/Moscow"
|
||||||
COPY --from=builder /app ./
|
COPY --from=builder /app ./
|
||||||
|
COPY --from=node-builder /app/dist ./static/dist
|
||||||
COPY gallery gallery/
|
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"]
|
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]
|
||||||
|
|||||||
14
README.md
@@ -1 +1,13 @@
|
|||||||
# Gallery
|
# API Gallery
|
||||||
|
|
||||||
|
Weather and TV program API
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## View
|
||||||
|
|
||||||
|
https://api.shmyga.ru
|
||||||
|
|
||||||
|
## Swagger
|
||||||
|
|
||||||
|
https://api.shmyga.ru/docs
|
||||||
|
|||||||
26
docker-compose-develop.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
name: gallery
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
container_name: gallery-redis
|
||||||
|
image: redis:alpine
|
||||||
|
stop_grace_period: 3s
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
app:
|
||||||
|
container_name: gallery-app-develop
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- OPENWEATHER_KEY=$OPENWEATHER_KEY
|
||||||
|
- DEBUG=1
|
||||||
|
ports:
|
||||||
|
- 8000:80
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: sync
|
||||||
|
path: ./gallery
|
||||||
|
target: /app/gallery
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
|
name: gallery-${DOCKER_TAG}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
container_name: gallery-redis
|
container_name: gallery-${DOCKER_TAG}-redis
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
stop_grace_period: 3s
|
stop_grace_period: 3s
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
command: [ "redis-server", "--bind", "0.0.0.0", "--port", "6379" ]
|
|
||||||
app:
|
app:
|
||||||
container_name: gallery-app
|
container_name: gallery-${DOCKER_TAG}-app
|
||||||
build: .
|
image: ${DOCKER_ROOT}/gallery:${DOCKER_TAG}
|
||||||
# image: shmyga/gallery
|
|
||||||
environment:
|
environment:
|
||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
|
- OPENWEATHER_KEY=$OPENWEATHER_KEY
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 127.0.0.1:8000:80
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|||||||
BIN
docs/screenshot.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
@@ -1,19 +1,30 @@
|
|||||||
{
|
{
|
||||||
"folders": [
|
"folders": [
|
||||||
{
|
{
|
||||||
"path": "."
|
"path": ".",
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"python.testing.pytestArgs": ["tests", "-s"],
|
"python.testing.pytestArgs": ["tests", "-s"],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python.terminal.useEnvFile": true,
|
||||||
|
"python-envs.pythonProjects": [
|
||||||
|
{
|
||||||
|
"path": ".",
|
||||||
|
"envManager": "ms-python.python:poetry",
|
||||||
|
"packageManager": "ms-python.python:poetry",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"files.associations": {
|
||||||
|
"*.html": "jinja-html",
|
||||||
|
},
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"**/__pycache__": true
|
"**/__pycache__": true,
|
||||||
},
|
},
|
||||||
"terminal.integrated.env.linux": {
|
"terminal.integrated.env.linux": {
|
||||||
"PYTHONPATH": "${workspaceFolder}"
|
"PYTHONPATH": "${workspaceFolder}",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"launch": {
|
"launch": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
@@ -23,13 +34,18 @@
|
|||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "uvicorn",
|
"module": "uvicorn",
|
||||||
"args": [
|
"args": ["gallery.main:app", "--reload", "--log-config", "gallery/logging.yaml"],
|
||||||
"gallery.main:app",
|
"justMyCode": true,
|
||||||
"--reload",
|
"consoleTitle": "gallery:app",
|
||||||
"--log-config",
|
},
|
||||||
"gallery/logging.yaml"
|
{
|
||||||
]
|
"name": "gallery:static",
|
||||||
}
|
"cwd": "${workspaceFolder}/static",
|
||||||
]
|
"request": "launch",
|
||||||
}
|
"type": "node-terminal",
|
||||||
|
"command": "npm run dev",
|
||||||
|
"consoleTitle": "gallery:static",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,21 @@
|
|||||||
import locale as _locale
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from gallery.sketch.schedule.api import ScheduleApi
|
from gallery.sketch.bundle import ApiBundle
|
||||||
from gallery.sketch.weather.api import WeatherApi
|
from gallery.util import root_path
|
||||||
|
|
||||||
from .route import doc
|
from .route import api, doc, view
|
||||||
from .route.api import schedule as schedule_api_route
|
|
||||||
from .route.api import weather as weather_api_route
|
|
||||||
from .route.view import common as common_view_route
|
|
||||||
from .route.view import schedule as schedule_view_route
|
|
||||||
from .route.view import weather as weather_view_route
|
|
||||||
|
|
||||||
|
|
||||||
def build_app(
|
def build_app(api_bundle: ApiBundle) -> FastAPI:
|
||||||
weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8"
|
|
||||||
) -> FastAPI:
|
|
||||||
_locale.setlocale(_locale.LC_TIME, locale)
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Gallery",
|
title="Gallery",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
)
|
)
|
||||||
app.state.weather_api = weather_api
|
app.state.api = api_bundle
|
||||||
app.state.schedule_api = schedule_api
|
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
||||||
doc.mount(app)
|
doc.mount(app)
|
||||||
weather_api_route.mount(app)
|
app.include_router(api.router)
|
||||||
schedule_api_route.mount(app)
|
app.include_router(view.router)
|
||||||
common_view_route.mount(app)
|
|
||||||
weather_view_route.mount(app)
|
|
||||||
schedule_view_route.mount(app)
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
15
gallery/easel/core.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from gallery.sketch.bundle import ApiBundle
|
||||||
|
|
||||||
|
|
||||||
|
class State:
|
||||||
|
api: ApiBundle
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
state: State
|
||||||
|
|
||||||
|
|
||||||
|
class AppRequest(Request):
|
||||||
|
app: App
|
||||||
0
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))]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import schedule, weather
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
router.include_router(weather.router)
|
||||||
|
router.include_router(schedule.router)
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
from fastapi import FastAPI
|
import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
|
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):
|
@router.get("/providers")
|
||||||
pass
|
async def get_api_weather_providers(request: AppRequest) -> list[str]:
|
||||||
|
return request.app.state.api.get_api_providers(ScheduleApi)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
import datetime
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
|
from gallery.easel.depends.weather import WeatherApiDepends
|
||||||
from gallery.sketch.weather.api import WeatherApi
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
from gallery.sketch.weather.model import WeatherResponse
|
from gallery.sketch.weather.model import Location, WeatherResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/weather", tags=["Weather"])
|
||||||
|
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
@router.get("/providers")
|
||||||
@app.get("/api/weather/locations")
|
async def get_api_weather_providers(request: AppRequest) -> list[str]:
|
||||||
async def get_api_weather_locations(request: Request) -> list[str]:
|
return request.app.state.api.get_api_providers(WeatherApi)
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
|
||||||
return await weather_api.get_locations()
|
|
||||||
|
|
||||||
@app.get("/api/weather/{location}/day/{date}")
|
|
||||||
async def get_api_weather_day(
|
@router.get("/{provider}/locations")
|
||||||
request: Request, location: str, date: datetime.date
|
async def find_api_weather_locations(weather_api: WeatherApiDepends, query: str) -> list[Location]:
|
||||||
) -> WeatherResponse:
|
return await weather_api.find_locations(query)
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
|
||||||
|
|
||||||
|
@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)
|
return await weather_api.get_day(location, date)
|
||||||
|
|
||||||
@app.get("/api/weather/{location}/days/{days}")
|
|
||||||
async def get_api_weather_days(
|
@router.get("/{provider}/{location}/days/{days}")
|
||||||
request: Request, location: str, days: int
|
async def get_api_weather_days(weather_api: WeatherApiDepends, location: str, days: int) -> WeatherResponse:
|
||||||
) -> WeatherResponse:
|
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
|
||||||
return await weather_api.get_days(location, days)
|
return await weather_api.get_days(location, days)
|
||||||
|
|||||||
636
gallery/easel/route/doc/static/redoc.standalone.js
vendored
@@ -0,0 +1,11 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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="index.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"sections": SECTIONS,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,104 +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: column;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-link-home > * {
|
|
||||||
margin-left: 2rem;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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: blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.app-list > li:hover > a {
|
|
||||||
color: blue;
|
|
||||||
}
|
|
||||||
125
gallery/easel/route/view/common/templates/base.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{request.state.language}}">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
{% block head %}
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible"
|
||||||
|
content="ie=edge">
|
||||||
|
<title>{% block title %}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="/static/gallery.css?v={{version}}">
|
||||||
|
<script type="module"
|
||||||
|
src="/static/gallery.js?v={{version}}"></script>
|
||||||
|
<link rel="icon"
|
||||||
|
href="/favicon.ico?v={{version}}"
|
||||||
|
type="image/x-icon">
|
||||||
|
{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="X-UA-Compatible"
|
|
||||||
content="ie=edge">
|
|
||||||
<title>Информация</title>
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="icon"
|
|
||||||
href="/static/common/favicon.ico?v={{version}}"
|
|
||||||
type="image/x-icon">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="app-container">
|
|
||||||
<h3 class="app-header">
|
|
||||||
<a class="app-link-home"
|
|
||||||
href="/">
|
|
||||||
<div></div>
|
|
||||||
</a>
|
|
||||||
<div class="app-title">
|
|
||||||
<span>Информация</span>
|
|
||||||
</div>
|
|
||||||
</h3>
|
|
||||||
<ul class="app-list">
|
|
||||||
{% for section in sections %}
|
|
||||||
<li>
|
|
||||||
<a href="{{section.link}}">
|
|
||||||
<span class="icon"
|
|
||||||
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
|
|
||||||
<span>{{section.title}}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
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,79 +1,68 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
|
from gallery.easel.depends.api import api_resolver
|
||||||
|
from gallery.easel.depends.schedule import ScheduleApiDepends
|
||||||
from gallery.sketch.schedule.api import ScheduleApi
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
from gallery.sketch.schedule.catalog import BUNDLE
|
|
||||||
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 timedelta_format
|
from .filters import timedelta_format
|
||||||
|
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
def context_procesor(request: AppRequest) -> dict:
|
||||||
base_dir = Path(__file__).parent
|
return {
|
||||||
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
|
"providers": request.app.state.api.get_api_providers(ScheduleApi),
|
||||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
}
|
||||||
templates.env.filters["timedelta_format"] = timedelta_format
|
|
||||||
|
|
||||||
@app.get("/schedule", response_class=HTMLResponse)
|
|
||||||
async def get_schedule_list(request: Request):
|
templates = build_templates(
|
||||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
Path(__file__).parent / "templates",
|
||||||
channels = await schedule_api.get_channels()
|
{
|
||||||
channels_data = BUNDLE.select_items(channels)
|
"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(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="index.html",
|
name="index.html",
|
||||||
context={
|
context={
|
||||||
"version": __version__,
|
|
||||||
"channels": channels_data,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
|
||||||
async def get_schedule_tag(request: Request, tag: str, live: bool = False):
|
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
|
||||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
|
||||||
channels = await schedule_api.get_channels()
|
|
||||||
responses = [
|
|
||||||
await schedule_api.get_channel_schedule(channel, tag_value.date)
|
|
||||||
for channel in channels
|
|
||||||
]
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="schedule.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"tag_util": TagUtil,
|
|
||||||
"datetime": datetime,
|
|
||||||
"channels": channels,
|
"channels": channels,
|
||||||
"response": responses[0],
|
|
||||||
"responses": responses,
|
|
||||||
"live": live,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/schedule/{channel}", response_class=RedirectResponse)
|
|
||||||
async def get_channel_default(channel: str):
|
@router.get("/{provider}/{channel}", response_class=RedirectResponse)
|
||||||
|
async def get_channel_default(channel: str):
|
||||||
return RedirectResponse(f"{channel}/tag/today")
|
return RedirectResponse(f"{channel}/tag/today")
|
||||||
|
|
||||||
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
|
||||||
async def get_channel_tag(request: Request, channel: str, tag: str):
|
@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)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
|
||||||
if tag_value.type == TagType.DAY:
|
if tag_value.type == TagType.DAY:
|
||||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
response = await schedule_api.get_schedule(channel, tag_value.date)
|
||||||
else:
|
else:
|
||||||
raise ValueError(tag)
|
raise ValueError(tag)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="channel.html",
|
name="channel.html",
|
||||||
context={
|
context={
|
||||||
"version": __version__,
|
|
||||||
"tag_util": TagUtil,
|
"tag_util": TagUtil,
|
||||||
"datetime": datetime,
|
"datetime": datetime,
|
||||||
"response": response,
|
"response": response,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,18 +0,0 @@
|
|||||||
tr {
|
|
||||||
border-bottom: 1px solid lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.live {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 120%;
|
|
||||||
}
|
|
||||||
@@ -1,57 +1,47 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}
|
||||||
|
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
|
||||||
|
locale=request.state.language)}}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
<head>
|
{% block header %}
|
||||||
<meta charset="UTF-8">
|
<app-link href="/schedule"
|
||||||
<meta name="viewport"
|
icon="tv">{{_("TV program")}}</app-link>
|
||||||
content="width=device-width, initial-scale=1.0">
|
{% endblock %}
|
||||||
<meta http-equiv="X-UA-Compatible"
|
|
||||||
content="ie=edge">
|
|
||||||
<title>Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/schedule/style.css?v={{version}}">
|
|
||||||
<link rel="icon"
|
|
||||||
href="/static/schedule/favicon.ico?v={{version}}"
|
|
||||||
type="image/x-icon">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="app-container">
|
{% block content %}
|
||||||
<h3 class="app-header">
|
<h4>
|
||||||
<a class="app-link-home"
|
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||||
href="/">
|
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
|
||||||
<div></div>
|
<i class="bi bi-arrow-left-square"></i>
|
||||||
</a>
|
</a>
|
||||||
<div class="app-title">
|
<a class="icon-link"
|
||||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
href="../../..">
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
<i class="bi bi-arrow-up-square"></i>
|
||||||
<a class="button"
|
</a>
|
||||||
href="../..">⬆️</a>
|
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||||
<span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<a class="icon-link"
|
||||||
<a class="button"
|
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
<i class="bi bi-arrow-right-square"></i>
|
||||||
</div>
|
</a>
|
||||||
</h3>
|
</h4>
|
||||||
|
<table class="table">
|
||||||
<table>
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<tr class="{{'live' if value.live else ''}}">
|
<tr class="{{'table-success' if value.live else ''}}">
|
||||||
<td>{{value.start.strftime('%H:%M')}}</td>
|
<td>
|
||||||
<td>{{(value.end - value.start) | timedelta_format}}</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>
|
<td>{{value.label}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
{% endblock %}
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,38 +1,62 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}{{_("TV program")}}{% endblock %}
|
||||||
|
|
||||||
<head>
|
{% block content %}
|
||||||
<meta charset="UTF-8">
|
<h1>{{_("TV program")}}</h1>
|
||||||
<meta name="viewport"
|
<form action=""
|
||||||
content="width=device-width, initial-scale=1.0">
|
method="get"
|
||||||
<meta http-equiv="X-UA-Compatible"
|
class="mb-4">
|
||||||
content="ie=edge">
|
<div class="input-group mb-3">
|
||||||
<title>ТВ</title>
|
<input type="text"
|
||||||
<link rel="stylesheet"
|
class="form-control"
|
||||||
href="/static/common/style.css?v={{version}}">
|
id="query"
|
||||||
<link rel="stylesheet"
|
name="query"
|
||||||
href="/static/schedule/style.css?v={{version}}">
|
placeholder="{{_('Enter the channel name')}}">
|
||||||
<link rel="icon"
|
<input type="hidden"
|
||||||
href="/static/schedule/favicon.ico?v={{version}}"
|
class="form-control"
|
||||||
type="image/x-icon">
|
id="provider"
|
||||||
</head>
|
name="provider"
|
||||||
|
value="{{provider or providers[0]}}">
|
||||||
<body class="app-container">
|
<button id="providerBtn"
|
||||||
<h3 class="app-header">
|
class="btn btn-secondary dropdown-toggle"
|
||||||
<a class="app-link-home"
|
type="text"
|
||||||
href="/">
|
data-bs-toggle="dropdown"
|
||||||
<div></div>
|
aria-expanded="false">{{provider or providers[0]}}</button>
|
||||||
</a>
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<div class="app-title">
|
{% for provider in providers %}
|
||||||
<span>Телепрограмма</span>
|
<li>
|
||||||
</div>
|
<a class="dropdown-item"
|
||||||
</h3>
|
onclick="provider.value='{{provider}}'; providerBtn.innerText='{{provider}}'">{{provider}}</a>
|
||||||
<ul class="app-list">
|
</li>
|
||||||
<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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</body>
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
</html>
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
scheduleChannelManager.loadChannels('#storedChannels');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="X-UA-Compatible"
|
|
||||||
content="ie=edge">
|
|
||||||
<title>ТВ</title>
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/schedule/style.css?v={{version}}">
|
|
||||||
<link rel="icon"
|
|
||||||
href="/static/schedule/favicon.ico?v={{version}}"
|
|
||||||
type="image/x-icon">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="app-container">
|
|
||||||
<h3 class="app-header">
|
|
||||||
<a class="app-link-home"
|
|
||||||
href="/">
|
|
||||||
<div></div>
|
|
||||||
</a>
|
|
||||||
<div class="app-title">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<table class="{{'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>
|
|
||||||
<td colspan="3">
|
|
||||||
<div class="title">{{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>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
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,84 +1,91 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
|
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.api import WeatherApi
|
||||||
from gallery.sketch.weather.catalog import BUNDLE
|
|
||||||
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
|
|
||||||
from gallery.sketch.weather.model import WeatherResponse
|
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 .filters import cloudness_icon, wind_direction_icon
|
from ..common.utils.template import build_templates
|
||||||
|
from .filters import cloudness_icon, weather_icon_svg, wind_direction_icon
|
||||||
|
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
def context_procesor(request: AppRequest) -> dict:
|
||||||
base_dir = Path(__file__).parent
|
return {
|
||||||
app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
|
"providers": request.app.state.api.get_api_providers(WeatherApi),
|
||||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
}
|
||||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
|
||||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
|
||||||
|
|
||||||
def build_weather_response(request: Request, response: WeatherResponse):
|
|
||||||
|
templates = build_templates(
|
||||||
|
Path(__file__).parent / "templates",
|
||||||
|
{
|
||||||
|
"wind_direction_icon": wind_direction_icon,
|
||||||
|
"cloudness_icon": cloudness_icon,
|
||||||
|
"weather_icon_svg": weather_icon_svg,
|
||||||
|
},
|
||||||
|
context_procesor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="weather.html",
|
name="weather.html",
|
||||||
context={
|
context={
|
||||||
"version": __version__,
|
|
||||||
"tag_util": TagUtil,
|
|
||||||
"datetime": datetime,
|
|
||||||
"response": response,
|
"response": response,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/weather", response_class=HTMLResponse)
|
|
||||||
async def get_weather_list(request: Request):
|
router = APIRouter(prefix="/weather")
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
|
||||||
locations = await weather_api.get_locations()
|
|
||||||
locations_data = BUNDLE.select_items(locations)
|
@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(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="index.html",
|
name="index.html",
|
||||||
context={
|
context={
|
||||||
"version": __version__,
|
"locations": locations,
|
||||||
"locations": locations_data,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/weather/{location}", response_class=RedirectResponse)
|
|
||||||
async def get_weather_default(location: str):
|
@router.get("/{provider}/{location}", response_class=RedirectResponse)
|
||||||
|
async def get_weather(location: str):
|
||||||
return RedirectResponse(f"{location}/tag/today")
|
return RedirectResponse(f"{location}/tag/today")
|
||||||
|
|
||||||
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
|
|
||||||
async def get_weather_day_mock(request: Request):
|
|
||||||
response = WEATHER_MOCK_DATA.get_response("day")
|
|
||||||
return build_weather_response(request, response)
|
|
||||||
|
|
||||||
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
|
@router.get("/{provider}/{location}/day/{date}", response_class=HTMLResponse)
|
||||||
async def get_weather_days_mock(request: Request):
|
async def get_weather_day(
|
||||||
response = WEATHER_MOCK_DATA.get_response("days")
|
request: AppRequest,
|
||||||
return build_weather_response(request, response)
|
weather_api: WeatherApiDepends,
|
||||||
|
location: str,
|
||||||
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
date: datetime.date,
|
||||||
async def get_weather_day(request: Request, location: str, date: datetime.date):
|
):
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
|
||||||
response = await weather_api.get_day(location, date)
|
response = await weather_api.get_day(location, date)
|
||||||
return build_weather_response(request, response)
|
return build_weather_response(request, response)
|
||||||
|
|
||||||
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
|
||||||
async def get_weather_days(request: Request, location: str, days: int):
|
@router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
|
||||||
response = await weather_api.get_days(location, days)
|
response = await weather_api.get_days(location, days)
|
||||||
return build_weather_response(request, response)
|
return build_weather_response(request, response)
|
||||||
|
|
||||||
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
|
||||||
async def get_weather_tag(request: Request, location: str, tag: str):
|
@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)
|
tag_value = TagUtil.parse_tag(tag)
|
||||||
weather_api: WeatherApi = request.app.state.weather_api
|
|
||||||
if tag_value.type == TagType.DAY:
|
if tag_value.type == TagType.DAY:
|
||||||
response = await weather_api.get_day(location, tag_value.date)
|
response = await weather_api.get_day(location, tag_value.date)
|
||||||
elif tag_value.type == TagType.DAYS:
|
elif tag_value.type == TagType.DAYS:
|
||||||
@@ -86,3 +93,10 @@ def mount(app: FastAPI):
|
|||||||
else:
|
else:
|
||||||
raise ValueError(tag)
|
raise ValueError(tag)
|
||||||
return build_weather_response(request, response)
|
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,39 +1,81 @@
|
|||||||
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
|
import datetime
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from gallery.sketch.weather.model import (
|
||||||
|
Cloudness,
|
||||||
|
Precipitation,
|
||||||
|
Sky,
|
||||||
|
WindDirection,
|
||||||
|
WindDirectionDeg,
|
||||||
|
)
|
||||||
|
from gallery.util import root_path
|
||||||
|
|
||||||
|
|
||||||
def wind_direction_icon(wind_direction: WindDirection) -> str:
|
def wind_direction_icon(wind_direction_deg: float) -> str:
|
||||||
return {
|
wind_direction = WindDirectionDeg(wind_direction_deg).direction
|
||||||
WindDirection.N: "⬇️",
|
if wind_direction == WindDirection.CALM:
|
||||||
WindDirection.NO: "↙️",
|
return "wind-calm"
|
||||||
WindDirection.O: "⬅️",
|
else:
|
||||||
WindDirection.SO: "↖️",
|
return f"wind-from-{wind_direction.name.lower()}"
|
||||||
WindDirection.S: "⬆️",
|
|
||||||
WindDirection.SW: "↗️",
|
|
||||||
WindDirection.W: "➡️",
|
|
||||||
WindDirection.NW: "↘️",
|
|
||||||
WindDirection.CALM: "",
|
|
||||||
}.get(wind_direction, wind_direction)
|
|
||||||
|
|
||||||
|
|
||||||
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 = ""
|
main_icon = ""
|
||||||
if sky.thunder:
|
if sky.thunder:
|
||||||
if sky.cloudness == Cloudness.CLEAR:
|
main_icon = {
|
||||||
main_icon = "🌩️"
|
Precipitation.NO: "lightning",
|
||||||
if sky.precipitation == Precipitation.NO:
|
Precipitation.SMALL_RAIN: "storm-showers",
|
||||||
main_icon = "⚡"
|
Precipitation.RAIN: "thunderstorm",
|
||||||
else:
|
Precipitation.HEAVY_RAIN: "thunderstorm",
|
||||||
main_icon = "⛈️"
|
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:
|
elif sky.precipitation == Precipitation.NO:
|
||||||
main_icon = {
|
main_icon = {
|
||||||
Cloudness.CLEAR: "☀️",
|
Cloudness.CLEAR: "day-sunny" if day else "night-clear",
|
||||||
Cloudness.PARTLY_CLOUDY: "🌤️",
|
Cloudness.PARTLY_CLOUDY: f"{day_prefix}-cloudy",
|
||||||
Cloudness.CLOUDY: "⛅",
|
Cloudness.CLOUDY: "cloud",
|
||||||
Cloudness.MAINLY_CLOUDY: "☁️",
|
Cloudness.MAINLY_CLOUDY: "cloudy",
|
||||||
}[sky.cloudness]
|
}[sky.cloudness]
|
||||||
else:
|
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]
|
icons = [main_icon]
|
||||||
if sky.fog:
|
|
||||||
icons.append("🌫️")
|
|
||||||
return icons
|
return icons
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherIconBundle:
|
||||||
|
def __init__(self):
|
||||||
|
self._icon_path = root_path / "static/dist/weather-icons/icons"
|
||||||
|
self._cache = {}
|
||||||
|
|
||||||
|
def _load_icon(self, icon: str) -> Markup:
|
||||||
|
icon_file = self._icon_path / f"wi-{icon}.svg"
|
||||||
|
return Markup(icon_file.read_text())
|
||||||
|
|
||||||
|
def get(self, icon: str) -> Markup:
|
||||||
|
if icon not in self._cache:
|
||||||
|
self._cache[icon] = self._load_icon(icon)
|
||||||
|
return self._cache[icon]
|
||||||
|
|
||||||
|
|
||||||
|
WEATHER_ICON_BUNDLE = WeatherIconBundle()
|
||||||
|
|
||||||
|
|
||||||
|
def weather_icon_svg(icon: str) -> Markup:
|
||||||
|
return WEATHER_ICON_BUNDLE.get(icon)
|
||||||
|
|||||||
|
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,37 +1,62 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}{{_("Weather")}}{% endblock %}
|
||||||
|
|
||||||
<head>
|
{% block content %}
|
||||||
<meta charset="UTF-8">
|
<h1>{{_("Weather")}}</h1>
|
||||||
<meta name="viewport"
|
<form action=""
|
||||||
content="width=device-width, initial-scale=1.0">
|
method="get"
|
||||||
<meta http-equiv="X-UA-Compatible"
|
class="mb-4">
|
||||||
content="ie=edge">
|
<div class="input-group mb-3">
|
||||||
<title>Погода</title>
|
<input type="text"
|
||||||
<link rel="stylesheet"
|
class="form-control"
|
||||||
href="/static/common/style.css?v={{version}}">
|
id="query"
|
||||||
<link rel="stylesheet"
|
name="query"
|
||||||
href="/static/weather/style.css?v={{version}}">
|
placeholder="{{_('Enter the city name')}}">
|
||||||
<link rel="icon"
|
<input type="hidden"
|
||||||
href="/static/weather/favicon.ico?v={{version}}"
|
class="form-control"
|
||||||
type="image/x-icon">
|
id="provider"
|
||||||
</head>
|
name="provider"
|
||||||
|
value="{{provider or providers[0]}}">
|
||||||
<body class="app-container">
|
<button id="providerBtn"
|
||||||
<h3 class="app-header">
|
class="btn btn-secondary dropdown-toggle"
|
||||||
<a class="app-link-home"
|
type="text"
|
||||||
href="/">
|
data-bs-toggle="dropdown"
|
||||||
<div></div>
|
aria-expanded="false">{{provider or providers[0]}}</button>
|
||||||
</a>
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<div class="app-title">
|
{% for provider in providers %}
|
||||||
<span>Погода</span>
|
<li>
|
||||||
</div>
|
<a class="dropdown-item"
|
||||||
</h3>
|
onclick="provider.value='{{provider}}'; providerBtn.innerText='{{provider}}'">{{provider}}</a>
|
||||||
<ul class="app-list">
|
</li>
|
||||||
{% for location in locations %}
|
|
||||||
<li><a href="weather/{{location.id}}">{{location.name}}</a></li>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</body>
|
<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 %}
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
</html>
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
weatherLocationManager.loadLocations('#storedLocations');
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,44 +1,37 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
||||||
|
|
||||||
<head>
|
{% block header %}
|
||||||
<meta charset="UTF-8">
|
<app-link href="/weather"
|
||||||
<meta name="viewport"
|
icon="brightness-high">{{_("Weather")}}</app-link>
|
||||||
content="width=device-width, initial-scale=1.0">
|
{% endblock %}
|
||||||
<meta http-equiv="X-UA-Compatible"
|
|
||||||
content="ie=edge">
|
|
||||||
<title>Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/common/style.css?v={{version}}">
|
|
||||||
<link rel="stylesheet"
|
|
||||||
href="/static/weather/style.css?v={{version}}">
|
|
||||||
<link rel="icon"
|
|
||||||
href="/static/weather/favicon.ico?v={{version}}"
|
|
||||||
type="image/x-icon">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="app-container">
|
{% block content %}
|
||||||
<h3 class="app-header">
|
<h4>
|
||||||
<a class="app-link-home"
|
|
||||||
href="/">
|
|
||||||
<div></div>
|
|
||||||
</a>
|
|
||||||
<div class="app-title">
|
|
||||||
{% if response.period == 'day' %}
|
{% if response.period == 'day' %}
|
||||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
|
||||||
<a class="button"
|
<i class="bi bi-arrow-left-square"></i>
|
||||||
href="../tag/days-10">⬆️</a>
|
</a>
|
||||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<a class="icon-link"
|
||||||
<a class="button"
|
href="../tag/days-10">
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
<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 %}
|
{% endif %}
|
||||||
{% if response.period == 'days' %}
|
{% if response.period == 'days' %}
|
||||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</h4>
|
||||||
</h3>
|
<div class="table-responsive">
|
||||||
<table>
|
<table class="table table-weather table-borderless table-compact text-center w-auto"
|
||||||
|
style="font-size: 130%;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<!-- date -->
|
<!-- date -->
|
||||||
<tr>
|
<tr>
|
||||||
@@ -51,9 +44,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if response.period == 'days' %}
|
{% if response.period == 'days' %}
|
||||||
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
|
<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())}}">
|
<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>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -64,14 +57,17 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Облачность
|
{{_("Cloudiness")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<td class="cloudness">
|
<td class="cloudness"
|
||||||
{% for icon in value.sky | cloudness_icon %}
|
data-bs-toggle="tooltip"
|
||||||
<div class="icon">{{icon}}</div>
|
data-bs-title="{{ value.sky }}">
|
||||||
|
{% for icon in value.sky | cloudness_icon(value.date, response.period) %}
|
||||||
|
<div class="wi-svg wi-l wi-border"
|
||||||
|
style="margin: 0 auto;">{{ icon | weather_icon_svg }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -80,7 +76,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Температура, °C
|
{{_("Temperature, °C")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -99,13 +95,16 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Направление ветра
|
{{_("Wind direction")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<td class="wind">
|
<td class="wind"
|
||||||
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{{ value.wind_direction }}">
|
||||||
|
<div class="wi-svg wi-s wi-{{value.wind_direction | wind_direction_icon}}"
|
||||||
|
style="margin: 0 auto;">{{ 'wind-deg' | weather_icon_svg }}</div>
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -113,7 +112,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Скорость ветра, м/с
|
{{_("Wind speed, m/s")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -133,14 +132,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Осадки, мм
|
{{_("Precipitation, mm")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<td class="precipitation"
|
<td class="precipitation"
|
||||||
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
|
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>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -148,7 +147,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Давление, мм рт. ст.
|
{{_("Pressure, mmHg")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -166,7 +165,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Влажность, %
|
{{_("Humidity, %")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -179,6 +178,5 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
</html>
|
|
||||||
@@ -6,19 +6,28 @@ import uvicorn
|
|||||||
from gallery.easel import build_app
|
from gallery.easel import build_app
|
||||||
from gallery.painting.gismeteo.api import GismeteoApi
|
from gallery.painting.gismeteo.api import GismeteoApi
|
||||||
from gallery.painting.matchtv.api import MatchTvApi
|
from gallery.painting.matchtv.api import MatchTvApi
|
||||||
|
from gallery.painting.openweather.api import OpenWeatherApi
|
||||||
|
from gallery.painting.yandextv.api import YandexTvApi
|
||||||
|
from gallery.sketch.bundle import ApiBundle
|
||||||
from gallery.sketch.schedule.cached import CachedScheduleApi
|
from gallery.sketch.schedule.cached import CachedScheduleApi
|
||||||
from gallery.sketch.weather.cached import CachedWeatherApi
|
from gallery.sketch.weather.cached import CachedWeatherApi
|
||||||
|
|
||||||
weather_api = CachedWeatherApi(GismeteoApi())
|
api = ApiBundle(
|
||||||
schedule_api = CachedScheduleApi(MatchTvApi())
|
[
|
||||||
app = build_app(weather_api, schedule_api)
|
CachedScheduleApi(YandexTvApi()),
|
||||||
|
CachedScheduleApi(MatchTvApi()),
|
||||||
|
CachedWeatherApi(GismeteoApi()),
|
||||||
|
CachedWeatherApi(OpenWeatherApi()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
app = build_app(api)
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"gallery.main:app",
|
"gallery.main:app",
|
||||||
host="0.0.0.0",
|
host=environ.get("GALLERY_HOST", "0.0.0.0"),
|
||||||
port=8000,
|
port=int(environ.get("GALLERY_PORT", 8000)),
|
||||||
log_config=str(Path(__file__).parent / "logging.yaml"),
|
log_config=str(Path(__file__).parent / "logging.yaml"),
|
||||||
reload="DEBUG" in environ,
|
reload="DEBUG" in environ,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List
|
from typing import Any
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
from gallery.sketch.source import ApiSource
|
from gallery.sketch.source import ApiSource
|
||||||
from gallery.sketch.weather.api import WeatherApi
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
from gallery.sketch.weather.catalog import LocationId
|
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
|
||||||
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
|
|
||||||
|
|
||||||
from . import datehelp
|
from . import datehelp
|
||||||
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
|
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:
|
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")
|
soup = BeautifulSoup(data, features="html.parser")
|
||||||
location = LOCATION_PARSER.parse_location(data)
|
location = LOCATION_PARSER.parse_location(data)
|
||||||
widget = ONE_DAY_PARSER.parse_widget(soup)
|
widget = ONE_DAY_PARSER.parse_widget(soup)
|
||||||
@@ -52,7 +53,7 @@ class GismeteoApi(WeatherApi):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _parse_manydays(self, data: str) -> WeatherResponse:
|
def _parse_manydays(self, data: str) -> WeatherResponse:
|
||||||
result: List[Dict[str, Any]] = []
|
result: list[dict[str, Any]] = []
|
||||||
soup = BeautifulSoup(data, features="html.parser")
|
soup = BeautifulSoup(data, features="html.parser")
|
||||||
location = LOCATION_PARSER.parse_location(data)
|
location = LOCATION_PARSER.parse_location(data)
|
||||||
widget = DAYS_PARSER.parse_widget(soup)
|
widget = DAYS_PARSER.parse_widget(soup)
|
||||||
@@ -69,13 +70,44 @@ class GismeteoApi(WeatherApi):
|
|||||||
values=values,
|
values=values,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_locations(self) -> list[str]:
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
return [
|
geo = "ru"
|
||||||
LocationId.OREL,
|
latitude = 52.968498
|
||||||
LocationId.ZMIYEVKA,
|
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:
|
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)}")
|
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
|
||||||
return self._parse_oneday(date, data)
|
return self._parse_oneday(date, data)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from bs4 import Tag
|
|||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class WidgetParser:
|
class WidgetParser:
|
||||||
def parse_widget(self, tag: Tag) -> Tag:
|
def parse_widget(self, tag: Tag) -> Tag:
|
||||||
raise NotImplementedError
|
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,14 +1,23 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
import dateparser
|
import dateparser
|
||||||
from bs4 import Tag
|
from bs4 import Tag
|
||||||
|
|
||||||
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
|
from gallery.sketch.weather.model import (
|
||||||
|
Cloudness,
|
||||||
|
Precipitation,
|
||||||
|
Sky,
|
||||||
|
WindDirection,
|
||||||
|
WindDirectionDeg,
|
||||||
|
)
|
||||||
|
|
||||||
from .core import BaseWidgetParser, RowParser
|
from .core import BaseWidgetParser, RowParser
|
||||||
|
|
||||||
|
logger = logging.getLogger("gismeteo")
|
||||||
|
|
||||||
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
|
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
|
||||||
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
|
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
|
||||||
|
|
||||||
@@ -30,16 +39,11 @@ class DateParser(RowParser[datetime.datetime]):
|
|||||||
KEY = "date"
|
KEY = "date"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
|
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
|
||||||
datetime_date_tag = tag.select_one(
|
datetime_time_row = tag.select_one(".widget-row.widget-row-datetime-time")
|
||||||
".widget-row.widget-row-datetime-date > .row-item"
|
if datetime_time_row:
|
||||||
)
|
for item in datetime_time_row.select(".row-item > time-value"):
|
||||||
if datetime_date_tag:
|
timestamp = int(item.attrs["timestamp"])
|
||||||
date_str = datetime_date_tag.find(text=True, recursive=False).text
|
time = datetime.datetime.fromtimestamp(timestamp)
|
||||||
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)
|
|
||||||
yield time
|
yield time
|
||||||
else:
|
else:
|
||||||
for item in tag.select(".widget-row.widget-row-date > .row-item"):
|
for item in tag.select(".widget-row.widget-row-date > .row-item"):
|
||||||
@@ -53,6 +57,7 @@ class SkyParser(RowParser[Sky]):
|
|||||||
|
|
||||||
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
||||||
"ясно": Cloudness.CLEAR,
|
"ясно": Cloudness.CLEAR,
|
||||||
|
"безоблачно": Cloudness.CLEAR,
|
||||||
"малооблачно": Cloudness.PARTLY_CLOUDY,
|
"малооблачно": Cloudness.PARTLY_CLOUDY,
|
||||||
"облачно": Cloudness.CLOUDY,
|
"облачно": Cloudness.CLOUDY,
|
||||||
"пасмурно": Cloudness.MAINLY_CLOUDY,
|
"пасмурно": Cloudness.MAINLY_CLOUDY,
|
||||||
@@ -61,26 +66,51 @@ class SkyParser(RowParser[Sky]):
|
|||||||
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
||||||
"без осадков": Precipitation.NO,
|
"без осадков": Precipitation.NO,
|
||||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||||
|
"сильный дождь": Precipitation.HEAVY_RAIN,
|
||||||
|
"очень сильный дождь": Precipitation.HEAVY_RAIN,
|
||||||
|
"ливневый дождь": Precipitation.SHOWER,
|
||||||
"дождь": Precipitation.RAIN,
|
"дождь": Precipitation.RAIN,
|
||||||
"ливень": Precipitation.SHOWER,
|
"ливень": Precipitation.SHOWER,
|
||||||
|
"снег": Precipitation.SNOW,
|
||||||
|
"небольшой снег": Precipitation.SNOW,
|
||||||
|
"сильный снег": Precipitation.HEAVY_SNOW,
|
||||||
|
"мокрый снег": Precipitation.SNOW,
|
||||||
|
"снег с дождём": Precipitation.SNOW,
|
||||||
|
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
|
||||||
|
"небольшой снег с дождём": Precipitation.SNOW,
|
||||||
|
"небольшой мокрый снег": Precipitation.SNOW,
|
||||||
|
"град": Precipitation.HAIL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
THUNDER = "гроза"
|
||||||
|
FOG = "дымка"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[Sky]:
|
def parse_row(self, tag: Tag) -> Iterable[Sky]:
|
||||||
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
|
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
|
||||||
sky_str = item.attrs["data-tooltip"]
|
sky_str = item.attrs["data-tooltip"]
|
||||||
values = {item.strip().lower() for item in sky_str.split(",")}
|
values = {item.strip().lower() for item in sky_str.split(",")}
|
||||||
cloudness = Cloudness.CLEAR
|
cloudness = Cloudness.CLEAR
|
||||||
precipitation = Precipitation.NO
|
precipitation = Precipitation.NO
|
||||||
thunder = "гроза" in values
|
thunder = False
|
||||||
fog = "дымка" in values
|
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():
|
for k, v in self.CLOUDNESS_MAP.items():
|
||||||
if k in values:
|
if k in values:
|
||||||
cloudness = v
|
cloudness = v
|
||||||
|
values.remove(k)
|
||||||
break
|
break
|
||||||
for k, v in self.PRECIPITATION_MAP.items():
|
for k, v in self.PRECIPITATION_MAP.items():
|
||||||
if k in values:
|
if k in values:
|
||||||
precipitation = v
|
precipitation = v
|
||||||
|
values.remove(k)
|
||||||
break
|
break
|
||||||
|
if values:
|
||||||
|
logger.warning("unknown sky values: %s:", values)
|
||||||
yield Sky(
|
yield Sky(
|
||||||
cloudness=cloudness,
|
cloudness=cloudness,
|
||||||
precipitation=precipitation,
|
precipitation=precipitation,
|
||||||
@@ -93,21 +123,15 @@ class TemperatureParser(RowParser[list[int]]):
|
|||||||
KEY = "temperature"
|
KEY = "temperature"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
||||||
for item in tag.select(
|
for item in tag.select(".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"):
|
||||||
".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"
|
yield [int(value.attrs["value"]) for value in item.select("temperature-value")]
|
||||||
):
|
|
||||||
yield [
|
|
||||||
int(value.attrs["value"]) for value in item.select("temperature-value")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class WindSpeedParser(RowParser[int]):
|
class WindSpeedParser(RowParser[int]):
|
||||||
KEY = "wind_speed"
|
KEY = "wind_speed"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||||
for item in tag.select(
|
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > speed-value"):
|
||||||
".widget-row[data-row=wind-speed] > .row-item > speed-value"
|
|
||||||
):
|
|
||||||
yield int(item.attrs["value"])
|
yield int(item.attrs["value"])
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +139,7 @@ class WindGustParser(RowParser[int]):
|
|||||||
KEY = "wind_gust"
|
KEY = "wind_gust"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||||
for item in tag.select(".widget-row[data-row=wind-gust] > .row-item"):
|
for item in tag.select(".widget-row-wind > .row-item > .wind-gust"):
|
||||||
value = item.select_one("speed-value")
|
value = item.select_one("speed-value")
|
||||||
yield int(value.attrs["value"]) if value else 0
|
yield int(value.attrs["value"]) if value else 0
|
||||||
|
|
||||||
@@ -124,42 +148,42 @@ class WindDirectionParser(RowParser[WindDirection]):
|
|||||||
KEY = "wind_direction"
|
KEY = "wind_direction"
|
||||||
|
|
||||||
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
|
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
|
||||||
|
"—": WindDirection.CALM,
|
||||||
|
"": WindDirection.CALM,
|
||||||
"штиль": WindDirection.CALM,
|
"штиль": WindDirection.CALM,
|
||||||
"с": WindDirection.N,
|
"с": WindDirection.N,
|
||||||
"св": WindDirection.NO,
|
"св": WindDirection.NE,
|
||||||
"в": WindDirection.O,
|
"в": WindDirection.E,
|
||||||
"юв": WindDirection.SO,
|
"юв": WindDirection.SE,
|
||||||
"ю": WindDirection.S,
|
"ю": WindDirection.S,
|
||||||
"юз": WindDirection.SW,
|
"юз": WindDirection.SW,
|
||||||
"з": WindDirection.W,
|
"з": WindDirection.W,
|
||||||
"сз": WindDirection.NW,
|
"сз": WindDirection.NW,
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[WindDirection]:
|
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||||
for item in tag.select(
|
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > .wind-direction"):
|
||||||
".widget-row[data-row=wind-direction] > .row-item > .direction"
|
wind_direction_str = item.text.lower().strip()
|
||||||
):
|
yield WindDirectionDeg.from_direction(self.WIND_DIRECTION_MAP[wind_direction_str]).value
|
||||||
wind_direction_str = item.text.lower()
|
|
||||||
yield self.WIND_DIRECTION_MAP[wind_direction_str]
|
|
||||||
|
|
||||||
|
|
||||||
class WindPrecipitationParser(RowParser[float]):
|
class PrecipitationParser(RowParser[float]):
|
||||||
KEY = "precipitation"
|
KEY = "precipitation"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||||
for item in tag.select(
|
for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item"):
|
||||||
".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"
|
value = item.select_one("precipitation-value")
|
||||||
):
|
if value:
|
||||||
yield float(item.text.replace(",", "."))
|
yield float(value.attrs["value"])
|
||||||
|
else:
|
||||||
|
yield 0
|
||||||
|
|
||||||
|
|
||||||
class PressureParser(RowParser[list[int]]):
|
class PressureParser(RowParser[list[int]]):
|
||||||
KEY = "pressure"
|
KEY = "pressure"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
||||||
for item in tag.select(
|
for item in tag.select(".widget-row-chart[data-row=pressure] > .chart > .values > .value"):
|
||||||
".widget-row-chart[data-row=pressure] > .chart > .values > .value"
|
|
||||||
):
|
|
||||||
yield [int(value.attrs["value"]) for value in item.select("pressure-value")]
|
yield [int(value.attrs["value"]) for value in item.select("pressure-value")]
|
||||||
|
|
||||||
|
|
||||||
@@ -167,7 +191,9 @@ class HumidityParser(RowParser[int]):
|
|||||||
KEY = "humidity"
|
KEY = "humidity"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||||
for item in tag.select(".widget-row[data-row=humidity] > .row-item"):
|
for item in tag.select(
|
||||||
|
".widget-row[data-row=humidity] > .row-item, .widget-row[data-row=humidity-avg] > .row-item"
|
||||||
|
):
|
||||||
yield int(item.text)
|
yield int(item.text)
|
||||||
|
|
||||||
|
|
||||||
@@ -178,7 +204,7 @@ ROW_PARSERS: list[RowParser] = [
|
|||||||
WindSpeedParser(),
|
WindSpeedParser(),
|
||||||
WindGustParser(),
|
WindGustParser(),
|
||||||
WindDirectionParser(),
|
WindDirectionParser(),
|
||||||
WindPrecipitationParser(),
|
PrecipitationParser(),
|
||||||
PressureParser(),
|
PressureParser(),
|
||||||
HumidityParser(),
|
HumidityParser(),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from gallery.sketch.schedule.api import ScheduleApi
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
from gallery.sketch.schedule.catalog import ChannelId
|
|
||||||
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||||
from gallery.sketch.source import ApiSource
|
from gallery.sketch.source import ApiSource
|
||||||
|
|
||||||
@@ -15,33 +13,30 @@ class MatchTvApi(ScheduleApi):
|
|||||||
PROVIDER = "matchtv"
|
PROVIDER = "matchtv"
|
||||||
SOURCE = ApiSource("https://matchtv.ru")
|
SOURCE = ApiSource("https://matchtv.ru")
|
||||||
|
|
||||||
async def get_channels(self) -> list[str]:
|
async def find_channels(self, query: str) -> list[Channel]:
|
||||||
return [
|
endpoint = "api/v1/channels"
|
||||||
ChannelId.MATCH_TV,
|
data = json.loads(await self.SOURCE.request(endpoint))
|
||||||
ChannelId.MATCH_IGRA,
|
result = []
|
||||||
ChannelId.MATCH_ARENA,
|
query = query.lower()
|
||||||
ChannelId.MATCH_FUTBOL_1,
|
for item in data["result"]:
|
||||||
ChannelId.MATCH_FUTBOL_2,
|
if query in item["name"].lower() or query in item["alias"]:
|
||||||
ChannelId.MATCH_FUTBOL_3,
|
channel_id = item["alias"]
|
||||||
ChannelId.MATCH_STRANA,
|
name = item["name"].split("|")[0].strip()
|
||||||
]
|
result.append(Channel(id=channel_id, name=name, provider=self.provider))
|
||||||
|
return result
|
||||||
|
|
||||||
async def get_channel_schedule(
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
self, channel_id: str, date: datetime.date
|
endpoint = f"api/v1/channels/{channel_id}/tv-schedule?date={date:%Y%m%d}"
|
||||||
) -> Schedule:
|
data = json.loads(await self.SOURCE.request(endpoint))
|
||||||
endpoint = f"channel/{channel_id}/tvguide?date={date:%d-%m-%Y}"
|
channel_data = data["result"]["channels"][0]
|
||||||
data = await self.SOURCE.request(endpoint)
|
channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=self.provider)
|
||||||
soup = BeautifulSoup(data, features="html.parser")
|
|
||||||
values = []
|
values = []
|
||||||
channel_name = soup.select_one(".caption__heading").text.split("|")[0].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)
|
end = current_day + datetime.timedelta(days=1, hours=6)
|
||||||
prev_value: ScheduleValue | None = None
|
prev_value: ScheduleValue | None = None
|
||||||
for item in soup.select(".teleprogram-schedule .teleprogram-schedule__item"):
|
for item in channel_data["schedule"]:
|
||||||
title = item.select_one(".teleprogram-item__title").text.strip()
|
title = item["title"]
|
||||||
time_str = item.select_one(".teleprogram-item__time").text.strip()
|
time_str = item["time"]
|
||||||
hours, minutes = map(int, time_str.split(":"))
|
hours, minutes = map(int, time_str.split(":"))
|
||||||
item_date = current_day.replace(hour=hours, minute=minutes)
|
item_date = current_day.replace(hour=hours, minute=minutes)
|
||||||
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
||||||
@@ -54,5 +49,7 @@ class MatchTvApi(ScheduleApi):
|
|||||||
prev_value.end = item_date
|
prev_value.end = item_date
|
||||||
prev_value = value
|
prev_value = value
|
||||||
return Schedule(
|
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")
|
|
||||||
0
gallery/painting/openweather/__init__.py
Normal file
91
gallery/painting/openweather/api.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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.model import Location, WeatherResponse, WeatherValue
|
||||||
|
from gallery.sketch.weather.util import merge_weather_values
|
||||||
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
|
from .openweather import Forecast
|
||||||
|
from .openweather import Location as OpenWeatherLocation
|
||||||
|
from .openweather import OpenWeather
|
||||||
|
from .parser import FORECAST_ITEM_PARSER
|
||||||
|
|
||||||
|
logger = logging.getLogger("openweather")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenWeatherApi(WeatherApi):
|
||||||
|
PROVIDER = "openweather"
|
||||||
|
SOURCE = OpenWeather(environ["OPENWEATHER_KEY"])
|
||||||
|
|
||||||
|
@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",
|
||||||
|
alias="redis",
|
||||||
|
ttl=TimeUnit.DAY,
|
||||||
|
)
|
||||||
|
async def _get_location_forecast(self, location_id: str) -> Forecast:
|
||||||
|
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)
|
||||||
|
return WeatherResponse(
|
||||||
|
location=location.name,
|
||||||
|
date=date,
|
||||||
|
period="day",
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()]
|
||||||
|
return WeatherResponse(
|
||||||
|
location=location.name,
|
||||||
|
date=datetime.date.today(),
|
||||||
|
period="days",
|
||||||
|
values=list(sorted(values, key=lambda item: item.date)),
|
||||||
|
)
|
||||||
103
gallery/painting/openweather/openweather.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from gallery.sketch.source import ApiSource
|
||||||
|
|
||||||
|
|
||||||
|
class Model(BaseModel):
|
||||||
|
class Config:
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
|
class Main(Model):
|
||||||
|
temp: float
|
||||||
|
feels_like: float
|
||||||
|
temp_min: float
|
||||||
|
temp_max: float
|
||||||
|
pressure: int
|
||||||
|
sea_level: int
|
||||||
|
grnd_level: int
|
||||||
|
humidity: int
|
||||||
|
temp_kf: float
|
||||||
|
|
||||||
|
|
||||||
|
class Weather(Model):
|
||||||
|
id: int
|
||||||
|
main: str
|
||||||
|
description: str
|
||||||
|
icon: str
|
||||||
|
|
||||||
|
|
||||||
|
class Clouds(Model):
|
||||||
|
all: int
|
||||||
|
|
||||||
|
|
||||||
|
class Wind(Model):
|
||||||
|
speed: float
|
||||||
|
deg: int
|
||||||
|
gust: float
|
||||||
|
|
||||||
|
|
||||||
|
class Rain(Model):
|
||||||
|
interval_3h: float = Field(..., alias="3h")
|
||||||
|
|
||||||
|
|
||||||
|
class Sys(Model):
|
||||||
|
pod: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForecastItem(Model):
|
||||||
|
dt: int
|
||||||
|
main: Main
|
||||||
|
weather: list[Weather]
|
||||||
|
clouds: Clouds
|
||||||
|
wind: Wind
|
||||||
|
visibility: int | None = None
|
||||||
|
pop: float
|
||||||
|
rain: Rain | None = None
|
||||||
|
sys: Sys
|
||||||
|
dt_txt: str
|
||||||
|
|
||||||
|
|
||||||
|
class Forecast(Model):
|
||||||
|
cod: str
|
||||||
|
message: int
|
||||||
|
cnt: int
|
||||||
|
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"
|
||||||
|
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
self._api_key = api_key
|
||||||
|
self._source = ApiSource(self.BASE_URL)
|
||||||
|
|
||||||
|
async def get_forecast(self, lat: float, lon: float) -> Forecast:
|
||||||
|
endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
|
||||||
|
response = await self._source.request(endpoint)
|
||||||
|
response_data = json.loads(response)
|
||||||
|
return Forecast.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])
|
||||||
44
gallery/painting/openweather/parser.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue
|
||||||
|
from gallery.sketch.weather.util import build_weather_value
|
||||||
|
|
||||||
|
from .openweather import ForecastItem
|
||||||
|
|
||||||
|
|
||||||
|
class ForecastItemParser:
|
||||||
|
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
||||||
|
"clear sky": Cloudness.CLEAR,
|
||||||
|
"few clouds": Cloudness.PARTLY_CLOUDY,
|
||||||
|
"scattered clouds": Cloudness.PARTLY_CLOUDY,
|
||||||
|
"broken clouds": Cloudness.CLOUDY,
|
||||||
|
"overcast clouds": Cloudness.MAINLY_CLOUDY,
|
||||||
|
"light rain": Cloudness.CLOUDY,
|
||||||
|
}
|
||||||
|
|
||||||
|
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
||||||
|
"light rain": Precipitation.SMALL_RAIN,
|
||||||
|
"rain": Precipitation.RAIN,
|
||||||
|
"heavy rain": Precipitation.SHOWER,
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse(self, item: ForecastItem) -> WeatherValue:
|
||||||
|
item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC)
|
||||||
|
item_date = item_date.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None).replace(tzinfo=None)
|
||||||
|
value = build_weather_value(item_date)
|
||||||
|
# TODO parse temperature interval flag
|
||||||
|
value.temperature = [round(item.main.temp)]
|
||||||
|
# value.temperature = [round(item.main.temp_max), round(item.main.temp_min)]
|
||||||
|
value.pressure = [round(item.main.pressure / 133.3 * 100)]
|
||||||
|
value.humidity = item.main.humidity
|
||||||
|
value.wind_speed = round(item.wind.speed)
|
||||||
|
value.wind_gust = round(item.wind.gust)
|
||||||
|
value.wind_direction = item.wind.deg
|
||||||
|
value.sky.cloudness = self.CLOUDNESS_MAP.get(item.weather[0].description, Cloudness.CLEAR)
|
||||||
|
value.sky.precipitation = self.PRECIPITATION_MAP.get(item.weather[0].description, Precipitation.NO)
|
||||||
|
if item.rain:
|
||||||
|
value.precipitation = round(item.rain.interval_3h, 1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
FORECAST_ITEM_PARSER = ForecastItemParser()
|
||||||
0
gallery/painting/yandextv/__init__.py
Normal file
90
gallery/painting/yandextv/api.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
|
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||||
|
from gallery.sketch.source import ApiSource
|
||||||
|
|
||||||
|
logger = logging.getLogger("matchtv")
|
||||||
|
|
||||||
|
|
||||||
|
HEADERS: dict[str, str] = {
|
||||||
|
"Accept": (
|
||||||
|
"text/html,"
|
||||||
|
"application/xhtml+xml,"
|
||||||
|
"application/xml;q=0.9,"
|
||||||
|
"image/avif,image/webp,"
|
||||||
|
"image/apng,*/*;q=0.8,"
|
||||||
|
"application/signed-exchange;v=b3;q=0.9"
|
||||||
|
),
|
||||||
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Host": "tv.yandex.ru",
|
||||||
|
"sec-ch-ua": '"Chromium";v="100", " Not A;Brand";v="99"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": '"Linux"',
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
"Sec-Fetch-User": "?1",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/100.0.4896.133 "
|
||||||
|
"Safari/537.36"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class YandexTvApi(ScheduleApi):
|
||||||
|
PROVIDER = "yandextv"
|
||||||
|
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
|
||||||
|
|
||||||
|
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_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())
|
||||||
|
end = current_day + datetime.timedelta(days=1, hours=6)
|
||||||
|
prev_value: ScheduleValue | None = None
|
||||||
|
for item in soup.select(".channel-schedule .channel-schedule__event"):
|
||||||
|
title = item.select_one(".channel-schedule__title").text.strip()
|
||||||
|
time_str = item.select_one(".channel-schedule__time").text.strip()
|
||||||
|
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:
|
||||||
|
current_day += datetime.timedelta(days=1)
|
||||||
|
item_date += datetime.timedelta(days=1)
|
||||||
|
live = item.select_one(".channel-schedule__info .icon_live") is not None
|
||||||
|
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
|
||||||
|
values.append(value)
|
||||||
|
if prev_value is not None:
|
||||||
|
prev_value.end = item_date
|
||||||
|
prev_value = value
|
||||||
|
return Schedule(
|
||||||
|
channel=Channel(id=channel_id, name=channel_name, provider=self.provider),
|
||||||
|
date=date,
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
|
||||||
class Api:
|
class Api:
|
||||||
|
TYPE: str
|
||||||
PROVIDER: str
|
PROVIDER: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self) -> str:
|
||||||
|
return self.TYPE
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provider(self) -> str:
|
def provider(self) -> str:
|
||||||
return self.PROVIDER
|
return self.PROVIDER
|
||||||
|
|
||||||
|
|
||||||
|
API = TypeVar("API", bound=Api)
|
||||||
|
|||||||
19
gallery/sketch/bundle.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from .api import API, Api
|
||||||
|
|
||||||
|
|
||||||
|
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_providers(self, api_type: type[API]) -> list[str]:
|
||||||
|
return self._providers_by_api[api_type.TYPE]
|
||||||
|
|
||||||
|
def get_api(self, api_type: type[API], provider: str) -> API:
|
||||||
|
return self._api_map[(api_type.TYPE, provider)]
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
from typing import Generic, TypeVar
|
from typing import Generic, NamedTuple
|
||||||
|
|
||||||
from gallery.util import TimeUnit
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
from .api import Api
|
from .api import API, Api
|
||||||
|
|
||||||
API = TypeVar("API", bound=Api)
|
|
||||||
|
class CachePreset(NamedTuple):
|
||||||
|
ttl: int = TimeUnit.HOUR
|
||||||
|
alias: str = "redis"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CACHE_PRESET = CachePreset()
|
||||||
|
|
||||||
|
|
||||||
class CachedApi(Api, Generic[API]):
|
class CachedApi(Api, Generic[API]):
|
||||||
CACHE_TTL: int = TimeUnit.HOUR
|
|
||||||
CACHE_ALIAS: str = "redis"
|
|
||||||
CACHE_KEY: str
|
CACHE_KEY: str
|
||||||
|
|
||||||
def __init__(self, api: API):
|
def __init__(self, api: API):
|
||||||
|
|||||||
@@ -1,11 +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 select_items(self, ids: list[str]) -> list[T]:
|
|
||||||
return [self._items_by_id[id_] for id_ in ids]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
class MockData:
|
|
||||||
|
|
||||||
def __init__(self, data_dir) -> None:
|
|
||||||
self._data_dir = data_dir
|
|
||||||
|
|
||||||
def get_html(self, key: str) -> str:
|
|
||||||
return (self._data_dir / f"{key}.html").read_text()
|
|
||||||
|
|
||||||
def get_json(self, key: str) -> dict:
|
|
||||||
data = json.loads((self._data_dir / f"{key}.json").read_text())
|
|
||||||
return data
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from ..api import Api
|
from ..api import Api
|
||||||
from .model import Schedule
|
from .model import Channel, Schedule
|
||||||
|
|
||||||
|
|
||||||
class ScheduleApi(Api):
|
class ScheduleApi(Api):
|
||||||
async def get_channels(self) -> list[str]:
|
TYPE = "schedule"
|
||||||
|
|
||||||
|
async def find_channels(self, query: str) -> list[Channel]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def get_channel_schedule(
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
self, channel_id: str, date: datetime.date
|
|
||||||
) -> Schedule:
|
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -2,31 +2,30 @@ import datetime
|
|||||||
|
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
|
|
||||||
from gallery.sketch.cached import CachedApi
|
from gallery.sketch.cached import CachedApi, CachePreset
|
||||||
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
from .api import ScheduleApi
|
from .api import ScheduleApi
|
||||||
from .model import Schedule
|
from .model import Channel, Schedule
|
||||||
|
|
||||||
|
CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
|
||||||
|
|
||||||
|
|
||||||
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
||||||
CACHE_KEY = "schedule"
|
CACHE_KEY = ScheduleApi.TYPE
|
||||||
|
|
||||||
@cached(
|
@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}",
|
||||||
alias=CachedApi.CACHE_ALIAS,
|
**CACHE_PRESET._asdict(),
|
||||||
ttl=CachedApi.CACHE_TTL,
|
|
||||||
)
|
)
|
||||||
async def get_channels(self) -> list[str]:
|
async def find_channels(self, query: str) -> list[Channel]:
|
||||||
return await self._api.get_channels()
|
return await self._api.find_channels(query)
|
||||||
|
|
||||||
@cached(
|
@cached(
|
||||||
key_builder=lambda fun, self, channel_id, date: (
|
key_builder=lambda fun, self, channel_id, date: (
|
||||||
f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}"
|
f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}"
|
||||||
),
|
),
|
||||||
alias=CachedApi.CACHE_ALIAS,
|
**CACHE_PRESET._asdict(),
|
||||||
ttl=CachedApi.CACHE_TTL,
|
|
||||||
)
|
)
|
||||||
async def get_channel_schedule(
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
self, channel_id: str, date: datetime.date
|
return await self._api.get_schedule(channel_id, date)
|
||||||
) -> Schedule:
|
|
||||||
return await self._api.get_channel_schedule(channel_id, date)
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
from gallery.sketch.catalog import CatalogBundle
|
|
||||||
|
|
||||||
from .model import Channel
|
|
||||||
|
|
||||||
|
|
||||||
class ChannelId(str, Enum):
|
|
||||||
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"
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
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="Матч! Страна"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -11,6 +11,7 @@ class Model(BaseModel):
|
|||||||
class Channel(Model):
|
class Channel(Model):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
provider: str
|
||||||
|
|
||||||
|
|
||||||
class ScheduleValue(Model):
|
class ScheduleValue(Model):
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ logger = logging.getLogger("source")
|
|||||||
|
|
||||||
class ApiSource:
|
class ApiSource:
|
||||||
DEFAULT_USER_AGENT = (
|
DEFAULT_USER_AGENT = (
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/126.0.0.0 Safari/537.36"
|
|
||||||
)
|
)
|
||||||
DEFAULT_TIMEOUT = 30.0
|
DEFAULT_TIMEOUT = 30.0
|
||||||
|
|
||||||
@@ -19,18 +17,21 @@ class ApiSource:
|
|||||||
user_agent: str = DEFAULT_USER_AGENT,
|
user_agent: str = DEFAULT_USER_AGENT,
|
||||||
timeout: float = DEFAULT_TIMEOUT,
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
cookies: dict[str, str] | None = None,
|
cookies: dict[str, str] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
):
|
):
|
||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
self._user_agent = user_agent
|
self._user_agent = user_agent
|
||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
self._cookies = cookies
|
self._cookies = cookies
|
||||||
|
self._headers = headers
|
||||||
|
|
||||||
async def request(self, endpoint: str) -> str:
|
async def request(self, endpoint: str) -> str:
|
||||||
|
if endpoint.startswith("https:"):
|
||||||
|
url = endpoint
|
||||||
|
else:
|
||||||
url = f"{self._base_url}/{endpoint}"
|
url = f"{self._base_url}/{endpoint}"
|
||||||
logger.info(url)
|
logger.info(url)
|
||||||
headers = {
|
headers = {"User-Agent": self._user_agent, **(self._headers or {})}
|
||||||
"User-Agent": self._user_agent,
|
|
||||||
}
|
|
||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
headers=headers,
|
headers=headers,
|
||||||
cookies=self._cookies,
|
cookies=self._cookies,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from ..api import Api
|
from ..api import Api
|
||||||
from .model import WeatherResponse
|
from .model import Location, WeatherResponse
|
||||||
|
|
||||||
|
|
||||||
class WeatherApi(Api):
|
class WeatherApi(Api):
|
||||||
|
TYPE = "weather"
|
||||||
|
|
||||||
async def get_locations(self) -> list[str]:
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
|
|||||||
@@ -2,29 +2,29 @@ import datetime
|
|||||||
|
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
|
|
||||||
from gallery.sketch.cached import CachedApi
|
from gallery.sketch.cached import DEFAULT_CACHE_PRESET, CachedApi
|
||||||
|
|
||||||
from .api import WeatherApi
|
from .api import WeatherApi
|
||||||
from .model import WeatherResponse
|
from .model import Location, WeatherResponse
|
||||||
|
|
||||||
|
CACHE_PRESET = DEFAULT_CACHE_PRESET
|
||||||
|
|
||||||
|
|
||||||
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
||||||
CACHE_KEY = "weather"
|
CACHE_KEY = WeatherApi.TYPE
|
||||||
|
|
||||||
@cached(
|
@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}",
|
||||||
alias=CachedApi.CACHE_ALIAS,
|
**CACHE_PRESET._asdict(),
|
||||||
ttl=CachedApi.CACHE_TTL,
|
|
||||||
)
|
)
|
||||||
async def get_locations(self) -> list[str]:
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
return await self._api.get_locations()
|
return await self._api.find_locations(query)
|
||||||
|
|
||||||
@cached(
|
@cached(
|
||||||
key_builder=lambda fun, self, location_id, date: (
|
key_builder=lambda fun, self, location_id, date: (
|
||||||
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
|
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
|
||||||
),
|
),
|
||||||
alias=CachedApi.CACHE_ALIAS,
|
**CACHE_PRESET._asdict(),
|
||||||
ttl=CachedApi.CACHE_TTL,
|
|
||||||
)
|
)
|
||||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
return await self._api.get_day(location_id, date)
|
return await self._api.get_day(location_id, date)
|
||||||
@@ -33,8 +33,7 @@ class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
|||||||
key_builder=lambda fun, self, location_id, date: (
|
key_builder=lambda fun, self, location_id, date: (
|
||||||
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
|
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
|
||||||
),
|
),
|
||||||
alias=CachedApi.CACHE_ALIAS,
|
**CACHE_PRESET._asdict(),
|
||||||
ttl=CachedApi.CACHE_TTL,
|
|
||||||
)
|
)
|
||||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||||
return await self._api.get_days(location_id, days)
|
return await self._api.get_days(location_id, days)
|
||||||
|
|||||||
@@ -1,21 +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="Орёл"),
|
|
||||||
Location(id=LocationId.ZMIYEVKA, name="Змиёвка"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -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
|
import datetime
|
||||||
from enum import Enum
|
from enum import StrEnum, auto
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -12,20 +13,31 @@ class Model(BaseModel):
|
|||||||
class Location(Model):
|
class Location(Model):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
provider: str
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
country: str
|
||||||
|
country_code: str
|
||||||
|
district: str
|
||||||
|
subdistrict: str
|
||||||
|
|
||||||
|
|
||||||
class Cloudness(str, Enum):
|
class Cloudness(StrEnum):
|
||||||
CLEAR = "clear"
|
CLEAR = auto()
|
||||||
PARTLY_CLOUDY = "party_cloudy"
|
PARTLY_CLOUDY = auto()
|
||||||
CLOUDY = "cloudy"
|
CLOUDY = auto()
|
||||||
MAINLY_CLOUDY = "mainly_cloudy"
|
MAINLY_CLOUDY = auto()
|
||||||
|
|
||||||
|
|
||||||
class Precipitation(str, Enum):
|
class Precipitation(StrEnum):
|
||||||
NO = "no"
|
NO = auto()
|
||||||
SMALL_RAIN = "small_rain"
|
SMALL_RAIN = auto()
|
||||||
RAIN = "rain"
|
RAIN = auto()
|
||||||
SHOWER = "shower"
|
HEAVY_RAIN = auto()
|
||||||
|
SHOWER = auto()
|
||||||
|
SNOW = auto()
|
||||||
|
HEAVY_SNOW = auto()
|
||||||
|
HAIL = auto()
|
||||||
|
|
||||||
|
|
||||||
class Sky(Model):
|
class Sky(Model):
|
||||||
@@ -35,16 +47,65 @@ class Sky(Model):
|
|||||||
fog: bool
|
fog: bool
|
||||||
|
|
||||||
|
|
||||||
class WindDirection(str, Enum):
|
class WindDirection(StrEnum):
|
||||||
CALM = "calm"
|
CALM = auto()
|
||||||
N = "N"
|
N = auto()
|
||||||
NO = "NO"
|
NE = auto()
|
||||||
O = "O"
|
E = auto()
|
||||||
SO = "SO"
|
SE = auto()
|
||||||
S = "S"
|
S = auto()
|
||||||
SW = "SW"
|
SW = auto()
|
||||||
W = "W"
|
W = auto()
|
||||||
NW = "NW"
|
NW = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class WindDirectionDeg(float):
|
||||||
|
@property
|
||||||
|
def direction(self) -> WindDirection:
|
||||||
|
return self.to_direction()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> float:
|
||||||
|
return self
|
||||||
|
|
||||||
|
# pylint:disable=too-many-return-statements
|
||||||
|
def to_direction(self) -> WindDirection:
|
||||||
|
if self == -1:
|
||||||
|
return WindDirection.CALM
|
||||||
|
elif self > 337.5 or self <= 22.25:
|
||||||
|
return WindDirection.N
|
||||||
|
elif self <= 67.5:
|
||||||
|
return WindDirection.NE
|
||||||
|
elif self <= 112.5:
|
||||||
|
return WindDirection.E
|
||||||
|
elif self <= 157.5:
|
||||||
|
return WindDirection.SE
|
||||||
|
elif self <= 202.5:
|
||||||
|
return WindDirection.S
|
||||||
|
elif self <= 247.5:
|
||||||
|
return WindDirection.SW
|
||||||
|
elif self <= 292.5:
|
||||||
|
return WindDirection.W
|
||||||
|
elif self <= 337.5:
|
||||||
|
return WindDirection.NW
|
||||||
|
else:
|
||||||
|
raise ValueError(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_direction(cls, direction: WindDirection) -> Self:
|
||||||
|
return cls(
|
||||||
|
{
|
||||||
|
WindDirection.CALM: -1,
|
||||||
|
WindDirection.N: 0,
|
||||||
|
WindDirection.NE: 45,
|
||||||
|
WindDirection.E: 90,
|
||||||
|
WindDirection.SE: 135,
|
||||||
|
WindDirection.S: 180,
|
||||||
|
WindDirection.SW: 225,
|
||||||
|
WindDirection.W: 270,
|
||||||
|
WindDirection.NW: 315,
|
||||||
|
}[direction]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WeatherValue(Model):
|
class WeatherValue(Model):
|
||||||
@@ -53,7 +114,7 @@ class WeatherValue(Model):
|
|||||||
temperature: list[int]
|
temperature: list[int]
|
||||||
wind_speed: int
|
wind_speed: int
|
||||||
wind_gust: int
|
wind_gust: int
|
||||||
wind_direction: WindDirection
|
wind_direction: float
|
||||||
precipitation: float
|
precipitation: float
|
||||||
pressure: list[int]
|
pressure: list[int]
|
||||||
humidity: int
|
humidity: int
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import statistics
|
||||||
|
|
||||||
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirection
|
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg
|
||||||
|
|
||||||
|
|
||||||
def build_weather_value(date: datetime.datetime) -> WeatherValue:
|
def build_weather_value(date: datetime.datetime) -> WeatherValue:
|
||||||
@@ -15,8 +16,47 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
|
|||||||
temperature=[],
|
temperature=[],
|
||||||
wind_speed=0,
|
wind_speed=0,
|
||||||
wind_gust=0,
|
wind_gust=0,
|
||||||
wind_direction=WindDirection.CALM,
|
wind_direction=WindDirectionDeg(-1),
|
||||||
precipitation=0,
|
precipitation=0,
|
||||||
pressure=[],
|
pressure=[],
|
||||||
humidity=0,
|
humidity=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) -> WeatherValue:
|
||||||
|
result = build_weather_value(date)
|
||||||
|
temperatures = []
|
||||||
|
pressures = []
|
||||||
|
humidities = []
|
||||||
|
wind_speeds = []
|
||||||
|
wind_gusts = []
|
||||||
|
wind_directions = []
|
||||||
|
cloudnesses = []
|
||||||
|
precipitations = []
|
||||||
|
precipitation = 0
|
||||||
|
for value in values:
|
||||||
|
temperatures += value.temperature
|
||||||
|
pressures += value.pressure
|
||||||
|
humidities.append(value.humidity)
|
||||||
|
wind_speeds.append(value.wind_speed)
|
||||||
|
wind_gusts.append(value.wind_gust)
|
||||||
|
wind_directions.append(value.wind_direction)
|
||||||
|
cloudnesses.append(value.sky.cloudness)
|
||||||
|
precipitations.append(value.sky.precipitation)
|
||||||
|
precipitation += value.precipitation
|
||||||
|
result.temperature = [max(temperatures), min(temperatures)]
|
||||||
|
result.pressure = [max(pressures), min(pressures)]
|
||||||
|
result.humidity = round(statistics.mean(humidities))
|
||||||
|
result.wind_speed = round(statistics.mean(wind_speeds))
|
||||||
|
result.wind_gust = round(statistics.mean(wind_gusts))
|
||||||
|
result.wind_direction = statistics.mean(wind_directions)
|
||||||
|
# TODO: merge cloudnesses
|
||||||
|
for item in cloudnesses:
|
||||||
|
if item != Cloudness.CLEAR:
|
||||||
|
result.sky.cloudness = item
|
||||||
|
# TODO: merge precipitations
|
||||||
|
for item in precipitations:
|
||||||
|
if item != Precipitation.NO:
|
||||||
|
result.sky.precipitation = item
|
||||||
|
result.precipitation = precipitation
|
||||||
|
return result
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
class TimeUnit:
|
class TimeUnit:
|
||||||
SECOND = 1
|
SECOND = 1
|
||||||
MINUTE = 60 * SECOND
|
MINUTE = 60 * SECOND
|
||||||
HOUR = 60 * MINUTE
|
HOUR = 60 * MINUTE
|
||||||
DAY = 24 * HOUR
|
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 "Прямые трансляции"
|
||||||
3560
poetry.lock
generated
@@ -1,38 +1,45 @@
|
|||||||
[tool.poetry]
|
[project]
|
||||||
name = "gallery"
|
name = "gallery"
|
||||||
version = "0.1.0"
|
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["shmyga <shmyga.z@gmail.com>"]
|
version = "0.5.2"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
|
||||||
|
requires-python = '>=3.14,<4.0'
|
||||||
|
dependencies = [
|
||||||
|
'aiohttp (>=3.14,<4.0)',
|
||||||
|
'beautifulsoup4 (>=4.15,<5.0)',
|
||||||
|
'dateparser (>=1.4,<2.0)',
|
||||||
|
'pydantic (>=2.13,<3.0)',
|
||||||
|
'aiocache[redis] (>=0.12,<0.13)',
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
app = [
|
||||||
|
"fastapi[standard] (>=0.139,<0.140)",
|
||||||
|
"jinja2 (>=3.1,<4.0)",
|
||||||
|
"babel (>=2.18,<3.0)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
test = ["pytest (>=9.1,<10.0)", "pytest-asyncio (>=1.4,<2.0)"]
|
||||||
|
dev = ["pylint (>=4.0.6,<5.0.0)", "black (>=26.5,<27.0)", "isort (>=8.0,<9.0)"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
gallery = "gallery.main:run"
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
packages = [{ include = "gallery" }]
|
packages = [{ include = "gallery" }]
|
||||||
|
requires-poetry = '>=2.0.0,<3.0.0'
|
||||||
[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"}
|
|
||||||
|
|
||||||
[tool.poetry.group.app.dependencies]
|
|
||||||
fastapi = "^0.111.1"
|
|
||||||
jinja2 = "^3.1.4"
|
|
||||||
|
|
||||||
[tool.poetry.group.test.dependencies]
|
|
||||||
pytest = "^8.3.1"
|
|
||||||
pytest-asyncio = "^0.23.8"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
|
||||||
pylint = "^3.2.6"
|
|
||||||
black = "^24.4.2"
|
|
||||||
isort = "^5.13.2"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ['poetry-core (>=2.0.0,<3.0.0)']
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.black]
|
||||||
gallery = "gallery.main:run"
|
line-length = 120
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-p no:warnings"
|
addopts = "-p no:warnings"
|
||||||
|
|||||||
5
scripts/develop
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname $(dirname "$0"))" || exit
|
||||||
|
|
||||||
|
docker compose -f docker-compose-develop.yaml up --build --watch
|
||||||
55
scripts/docker-action
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname $(dirname "$0"))" || exit
|
||||||
|
|
||||||
|
. .env
|
||||||
|
|
||||||
|
build () {
|
||||||
|
echo "build: $1"
|
||||||
|
. "$1/.env"
|
||||||
|
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
|
||||||
|
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
|
||||||
|
ARGS=("build")
|
||||||
|
for ARG in ${DOCKER_ARGS[@]}; do
|
||||||
|
ARGS+=("--build-arg" "$ARG")
|
||||||
|
done
|
||||||
|
if [ -n "$PROJECT_TARGET" ]; then
|
||||||
|
ARGS+=("--target" "$PROJECT_TARGET")
|
||||||
|
fi
|
||||||
|
ARGS+=("-t" "$DOCKER_GROUP/$PROJECT_NAME" ".")
|
||||||
|
ARGS+=("-f" "$1/Dockerfile")
|
||||||
|
echo "${ARGS[@]}"
|
||||||
|
docker "${ARGS[@]}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
publish () {
|
||||||
|
echo "publish: $1"
|
||||||
|
. "$1/.env"
|
||||||
|
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
|
||||||
|
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
|
||||||
|
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$VERSION
|
||||||
|
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$DOCKER_TAG
|
||||||
|
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
|
||||||
|
docker push $DOCKER_ROOT/$PROJECT_NAME:$DOCKER_TAG
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
save () {
|
||||||
|
echo "save: $1"
|
||||||
|
. "$1/.env"
|
||||||
|
mkdir -p "$1/dist"
|
||||||
|
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
|
||||||
|
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
|
||||||
|
docker save --output "$1/dist/$PROJECT_NAME-$VERSION.tar" "$DOCKER_GROUP/$PROJECT_NAME"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_TARGETS="."
|
||||||
|
TARGETS="${@-$DEFAULT_TARGETS}"
|
||||||
|
|
||||||
|
DOCKER_ACTION="${DOCKER_ACTION-build}"
|
||||||
|
|
||||||
|
for TARGET in $TARGETS; do
|
||||||
|
$DOCKER_ACTION "$TARGET"
|
||||||
|
done
|
||||||
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
|
set -e
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
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
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
set -e
|
set -e
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
cd "$(dirname $(dirname "$0"))" || exit
|
||||||
|
|
||||||
docker build -t shmyga/gallery .
|
cd locales/ru/LC_MESSAGES || exit
|
||||||
|
msgfmt messages.po
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
|
||||||
|
|
||||||
IMAGE_NAME=shmyga/gallery
|
|
||||||
|
|
||||||
docker tag $IMAGE_NAME instreamatic.com:8083/$IMAGE_NAME
|
|
||||||
docker push instreamatic.com:8083/$IMAGE_NAME
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
|
||||||
|
|
||||||
# docker run --rm -p 8000:80 shmyga/gallery
|
|
||||||
docker compose up --build
|
|
||||||
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
|
set -e
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
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
|
||||||