Compare commits
30 Commits
d3ef03a6a0
...
0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
5
.env
Normal file
@@ -0,0 +1,5 @@
|
||||
DOCKER_REPO=git.shmyga.ru
|
||||
DOCKER_GROUP=infernalgames
|
||||
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
|
||||
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
|
||||
DOCKER_PROJECTS=("gallery")
|
||||
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
*.pyc
|
||||
*.mo
|
||||
.pytest_cache
|
||||
.venv
|
||||
#.vscode
|
||||
static/node_modules
|
||||
static/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"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
Dockerfile
@@ -1,23 +1,33 @@
|
||||
FROM python:3.12 AS builder
|
||||
ENV POETRY_HOME="/opt/poetry"
|
||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
||||
RUN apt update && \
|
||||
apt install -y gettext
|
||||
WORKDIR /app
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
COPY pyproject.toml poetry.lock README.md ./
|
||||
RUN poetry config virtualenvs.in-project true
|
||||
RUN poetry install --with app
|
||||
RUN poetry install --with app --no-root
|
||||
COPY locales ./locales
|
||||
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
|
||||
|
||||
FROM node:24 AS node-builder
|
||||
ENV PATH=/app/node_modules/.bin:$PATH
|
||||
WORKDIR /app
|
||||
COPY static/package.json static/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY static ./
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12-slim
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
WORKDIR /app
|
||||
RUN apt update && \
|
||||
apt install -y locales && \
|
||||
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
|
||||
dpkg-reconfigure --frontend=noninteractive locales
|
||||
ENV LANG=ru_RU.UTF-8
|
||||
ENV LC_ALL=ru_RU.UTF-8
|
||||
|
||||
ENV TZ="Europe/Moscow"
|
||||
COPY --from=builder /app ./
|
||||
COPY --from=node-builder /app/dist ./static/dist
|
||||
COPY gallery gallery/
|
||||
#COPY --from=builder /app/gallery/easel/route/view/locales /app/gallery/easel/route/view/locales
|
||||
COPY --from=builder --parents locales/**/*.mo ./
|
||||
|
||||
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
|
||||
|
||||
25
docker-compose-develop.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
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
|
||||
- DEBUG=1
|
||||
ports:
|
||||
- 8000:80
|
||||
develop:
|
||||
watch:
|
||||
- action: sync
|
||||
path: ./gallery
|
||||
target: /app/gallery
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
@@ -1,3 +1,5 @@
|
||||
name: gallery
|
||||
|
||||
services:
|
||||
redis:
|
||||
container_name: gallery-redis
|
||||
@@ -5,15 +7,15 @@ services:
|
||||
stop_grace_period: 3s
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: [ "redis-server", "--bind", "0.0.0.0", "--port", "6379" ]
|
||||
app:
|
||||
container_name: gallery-app
|
||||
build: .
|
||||
# image: shmyga/gallery
|
||||
image: ${DOCKER_ROOT}/gallery
|
||||
environment:
|
||||
- REDIS_HOST=redis
|
||||
depends_on:
|
||||
- redis
|
||||
ports:
|
||||
- 8000:80
|
||||
- 127.0.0.1:8000:80
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
|
||||
BIN
docs/screenshot.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
@@ -1,19 +1,29 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
"path": ".",
|
||||
},
|
||||
],
|
||||
"settings": {
|
||||
"python.testing.pytestArgs": ["tests", "-s"],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python-envs.pythonProjects": [
|
||||
{
|
||||
"path": ".",
|
||||
"envManager": "ms-python.python:poetry",
|
||||
"packageManager": "ms-python.python:poetry",
|
||||
},
|
||||
],
|
||||
"files.associations": {
|
||||
"*.html": "jinja-html",
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/__pycache__": true
|
||||
"**/__pycache__": true,
|
||||
},
|
||||
"terminal.integrated.env.linux": {
|
||||
"PYTHONPATH": "${workspaceFolder}"
|
||||
}
|
||||
"PYTHONPATH": "${workspaceFolder}",
|
||||
},
|
||||
},
|
||||
"launch": {
|
||||
"version": "0.2.1",
|
||||
@@ -23,13 +33,18 @@
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
"args": [
|
||||
"gallery.main:app",
|
||||
"--reload",
|
||||
"--log-config",
|
||||
"gallery/logging.yaml"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"args": ["gallery.main:app", "--reload", "--log-config", "gallery/logging.yaml"],
|
||||
"justMyCode": true,
|
||||
"consoleTitle": "gallery:app",
|
||||
},
|
||||
{
|
||||
"name": "gallery:static",
|
||||
"cwd": "${workspaceFolder}/static",
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
"command": "npm run dev",
|
||||
"consoleTitle": "gallery:static",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
import locale as _locale
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.bundle import ApiBundle
|
||||
from gallery.util import root_path
|
||||
|
||||
from .route import doc
|
||||
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
|
||||
from .route import api, doc
|
||||
from .route.view import router as view_router
|
||||
|
||||
|
||||
def build_app(
|
||||
weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8"
|
||||
) -> FastAPI:
|
||||
_locale.setlocale(_locale.LC_TIME, locale)
|
||||
def build_app(api_bundle: ApiBundle) -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Gallery",
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
)
|
||||
app.state.weather_api = weather_api
|
||||
app.state.schedule_api = schedule_api
|
||||
app.state.api = api_bundle
|
||||
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
||||
doc.mount(app)
|
||||
weather_api_route.mount(app)
|
||||
schedule_api_route.mount(app)
|
||||
common_view_route.mount(app)
|
||||
weather_view_route.mount(app)
|
||||
schedule_view_route.mount(app)
|
||||
api.mount(app)
|
||||
app.include_router(view_router)
|
||||
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,0 +1,8 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from . import schedule, weather
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
weather.mount(app)
|
||||
schedule.mount(app)
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import datetime
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.sketch.schedule.model import ChannelId, Schedule
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
pass
|
||||
@app.get("/api/schedule/channels", tags=["API"])
|
||||
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
|
||||
schedule_api = request.app.state.api.schedule
|
||||
return await schedule_api.get_channels()
|
||||
|
||||
@app.get("/api/schedule/{channel}/{date}", tags=["API"])
|
||||
async def get_api_schedule_channel_schedule(request: AppRequest, channel: str, date: datetime.date) -> Schedule:
|
||||
schedule_api = request.app.state.api.schedule
|
||||
return await schedule_api.get_channel_schedule(ChannelId(channel), date)
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import datetime
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI
|
||||
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.model import WeatherResponse
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.sketch.weather.model import Location, WeatherResponse
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
@app.get("/api/weather/locations")
|
||||
async def get_api_weather_locations(request: Request) -> list[str]:
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
return await weather_api.get_locations()
|
||||
@app.get("/api/weather/locations", tags=["API"])
|
||||
async def get_api_weather_locations(request: AppRequest, query: str) -> list[Location]:
|
||||
weather_api = request.app.state.api.weather
|
||||
return await weather_api.find_locations(query)
|
||||
|
||||
@app.get("/api/weather/{location}/day/{date}")
|
||||
async def get_api_weather_day(
|
||||
request: Request, location: str, date: datetime.date
|
||||
) -> WeatherResponse:
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
@app.get("/api/weather/{location}/day/{date}", tags=["API"])
|
||||
async def get_api_weather_day(request: AppRequest, location: str, date: datetime.date) -> WeatherResponse:
|
||||
weather_api = request.app.state.api.weather
|
||||
return await weather_api.get_day(location, date)
|
||||
|
||||
@app.get("/api/weather/{location}/days/{days}")
|
||||
async def get_api_weather_days(
|
||||
request: Request, location: str, days: int
|
||||
) -> WeatherResponse:
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
@app.get("/api/weather/{location}/days/{days}", tags=["API"])
|
||||
async def get_api_weather_days(request: AppRequest, location: str, days: int) -> WeatherResponse:
|
||||
weather_api = request.app.state.api.weather
|
||||
return await weather_api.get_days(location, days)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from .common import router as common_router
|
||||
from .schedule import router as schedule_router
|
||||
from .translation import set_language
|
||||
from .weather import router as weather_router
|
||||
|
||||
router = APIRouter(dependencies=[Depends(set_language)])
|
||||
router.include_router(common_router)
|
||||
router.include_router(weather_router)
|
||||
router.include_router(schedule_router)
|
||||
|
||||
@@ -1,37 +1,36 @@
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.version import __version__
|
||||
from ..common.utils.template import build_templates
|
||||
|
||||
|
||||
class Section(NamedTuple):
|
||||
link: str
|
||||
title: str
|
||||
icon: str
|
||||
|
||||
|
||||
SECTIONS = [
|
||||
Section("weather", "Погода"),
|
||||
Section("schedule", "Телепрограмма"),
|
||||
Section("weather", "Weather", "brightness-high"),
|
||||
Section("schedule", "TV program", "tv"),
|
||||
]
|
||||
|
||||
base_dir = Path(__file__).parent
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/common", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||
router = APIRouter()
|
||||
|
||||
@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,
|
||||
},
|
||||
)
|
||||
templates = build_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,
|
||||
},
|
||||
)
|
||||
|
||||
|
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;
|
||||
}
|
||||
122
gallery/easel/route/view/common/templates/base.html
Normal file
@@ -0,0 +1,122 @@
|
||||
<!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.es.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="pt-5 my-5 text-muted border-top">
|
||||
Created by shmyga · © 2026
|
||||
</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>
|
||||
26
gallery/easel/route/view/common/templates/root_index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{_("Index")}}{% endblock %}
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{_("View")}}</h1>
|
||||
<div class="list-group mb-5">
|
||||
{% for section in sections %}
|
||||
<a href="{{section.link}}"
|
||||
class="list-group-item list-group-item-action px-4">
|
||||
<app-link href="{{section.link}}"
|
||||
icon="{{section.icon}}">
|
||||
{{_(section.title)}}
|
||||
</app-link>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr class="col-3 col-md-2 mb-5">
|
||||
<h1>{{_("Docs")}}</h1>
|
||||
<a href="/docs"
|
||||
target="_blank">
|
||||
<h4>Swagger</h4>
|
||||
</a>
|
||||
{% endblock %}
|
||||
0
gallery/easel/route/view/common/utils/__init__.py
Normal file
43
gallery/easel/route/view/common/utils/template.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from babel.dates import format_date
|
||||
from fastapi import Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.version import __version__
|
||||
|
||||
from ...translation import _
|
||||
from .tag import TagUtil
|
||||
|
||||
|
||||
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 context_processor(request: Request) -> dict:
|
||||
return {
|
||||
"is_widget": is_widget(request),
|
||||
}
|
||||
|
||||
|
||||
def build_templates(templates_dir: Path | None = None, filters: dict | None = None) -> Jinja2Templates:
|
||||
directory = [Path(__file__).parent.parent / "templates"]
|
||||
if templates_dir:
|
||||
directory.append(templates_dir)
|
||||
templates = Jinja2Templates(directory=directory, context_processors=[context_processor])
|
||||
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
|
||||
@@ -1,81 +1,77 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.sketch.schedule.catalog import BUNDLE
|
||||
from gallery.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
|
||||
|
||||
templates = build_templates(
|
||||
Path(__file__).parent / "templates",
|
||||
{
|
||||
"timedelta_format": timedelta_format,
|
||||
},
|
||||
)
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||
templates.env.filters["timedelta_format"] = timedelta_format
|
||||
router = APIRouter()
|
||||
|
||||
@app.get("/schedule", response_class=HTMLResponse)
|
||||
async def get_schedule_list(request: Request):
|
||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
||||
channels = await schedule_api.get_channels()
|
||||
channels_data = BUNDLE.select_items(channels)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"channels": channels_data,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_schedule_tag(request: 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,
|
||||
"response": responses[0],
|
||||
"responses": responses,
|
||||
"live": live,
|
||||
},
|
||||
)
|
||||
@router.get("/schedule", response_class=HTMLResponse)
|
||||
async def get_schedule_list(request: AppRequest):
|
||||
schedule_api = request.app.state.api.schedule
|
||||
channels = await schedule_api.get_channels()
|
||||
channels_data = BUNDLE.select_items(channels)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"channels": channels_data,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/schedule/{channel}", response_class=RedirectResponse)
|
||||
async def get_channel_default(channel: str):
|
||||
return RedirectResponse(f"{channel}/tag/today")
|
||||
|
||||
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_channel_tag(request: Request, channel: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api: ScheduleApi = request.app.state.schedule_api
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="channel.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
@router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api = request.app.state.api.schedule
|
||||
results = await schedule_api.get_all_schedules(tag_value.date)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="schedule.html",
|
||||
context={
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": results[0],
|
||||
"responses": results,
|
||||
"live": live,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/schedule/{channel}", response_class=RedirectResponse)
|
||||
async def get_channel_default(channel: str):
|
||||
return RedirectResponse(f"{channel}/tag/today")
|
||||
|
||||
|
||||
@router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
schedule_api = request.app.state.api.schedule
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="channel.html",
|
||||
context={
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"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,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
|
||||
{% endblock %}
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible"
|
||||
content="ie=edge">
|
||||
<title>Программа | {{response.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>
|
||||
{% block header %}
|
||||
<app-link href="/schedule"
|
||||
icon="tv">{{_("TV program")}}</app-link>
|
||||
{% endblock %}
|
||||
|
||||
<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>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
</div>
|
||||
</h3>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for value in response.values %}
|
||||
<tr class="{{'live' if value.live else ''}}">
|
||||
<td>{{value.start.strftime('%H:%M')}}</td>
|
||||
<td>{{(value.end - value.start) | timedelta_format}}</td>
|
||||
<td>{{value.label}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{% block content %}
|
||||
<h4>
|
||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
||||
<a class="button"
|
||||
href="../..">⬆️</a>
|
||||
<span>{{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for value in response.values %}
|
||||
<tr class="{{'table-success' if value.live else ''}}">
|
||||
<td>{{value.start.strftime('%H:%M')}}</td>
|
||||
<td>{{(value.end - value.start) | timedelta_format}}</td>
|
||||
<td>{{value.label}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -1,38 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{_("TV program")}}{% endblock %}
|
||||
|
||||
<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">
|
||||
<span>Телепрограмма</span>
|
||||
</div>
|
||||
</h3>
|
||||
<ul class="app-list">
|
||||
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li>
|
||||
{% for channel in channels %}
|
||||
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{% block content %}
|
||||
<h1>{{_("TV program")}}</h1>
|
||||
<div class="list-group mb-5">
|
||||
<a href="schedule/tag/today"
|
||||
class="list-group-item list-group-item-action px-4">
|
||||
<span class="fw-bold">Все</span>
|
||||
</a>
|
||||
{% for channel in channels %}
|
||||
<a href="schedule/{{channel.id}}"
|
||||
class="list-group-item list-group-item-action px-4">
|
||||
<span class="text-primary">{{channel.name}}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,69 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}
|
||||
{% endblock %}
|
||||
|
||||
<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>
|
||||
{% block header %}
|
||||
<app-link href="/schedule"
|
||||
icon="tv">{{_("TV program")}}</app-link>
|
||||
{% endblock %}
|
||||
|
||||
<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>
|
||||
{% block content %}
|
||||
<h4>
|
||||
<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>{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
</h4>
|
||||
<div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for response in responses %}
|
||||
{% set values = (response.values|selectattr('live') if live else response.values)|list %}
|
||||
{% if values|length > 0 %}
|
||||
<tr class="table-primary fs-4">
|
||||
<td colspan="3">
|
||||
<div>{{response.channel.name}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% for value in values %}
|
||||
<tr class="{{'table-success' if not live and value.live else ''}}">
|
||||
<td>{{value.start.strftime('%H:%M')}}</td>
|
||||
<td>{{(value.end - value.start) | timedelta_format}}</td>
|
||||
<td>{{value.label}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
31
gallery/easel/route/view/translation.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import gettext
|
||||
from contextvars import ContextVar
|
||||
|
||||
from fastapi import Cookie, Header, Request
|
||||
|
||||
from gallery.util import root_path
|
||||
|
||||
_translation: ContextVar[gettext.GNUTranslations | gettext.NullTranslations] = ContextVar("translation")
|
||||
|
||||
|
||||
async def set_language(
|
||||
request: Request,
|
||||
accept_language: str = Header("en"),
|
||||
language: str | None = Cookie(None),
|
||||
):
|
||||
# Simplify the header (e.g., "en-US,en;q=0.9" -> "en")
|
||||
lang = language or accept_language.split(",")[0].split("-")[0]
|
||||
|
||||
try:
|
||||
t = gettext.translation("messages", localedir=root_path / "locales", languages=[lang])
|
||||
except FileNotFoundError:
|
||||
t = gettext.NullTranslations()
|
||||
|
||||
token = _translation.set(t)
|
||||
request.state.language = lang
|
||||
yield lang
|
||||
_translation.reset(token)
|
||||
|
||||
|
||||
def _(message: str) -> str:
|
||||
return _translation.get().gettext(message)
|
||||
@@ -1,88 +1,78 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.catalog import BUNDLE
|
||||
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.sketch.weather.model import WeatherResponse
|
||||
from gallery.version import __version__
|
||||
|
||||
from ..common.util import TagType, TagUtil
|
||||
from ..common.utils.tag import TagType, TagUtil
|
||||
from ..common.utils.template import build_templates
|
||||
from .filters import cloudness_icon, wind_direction_icon
|
||||
|
||||
templates = build_templates(
|
||||
Path(__file__).parent / "templates",
|
||||
{
|
||||
"wind_direction_icon": wind_direction_icon,
|
||||
"cloudness_icon": cloudness_icon,
|
||||
},
|
||||
)
|
||||
|
||||
def mount(app: FastAPI):
|
||||
base_dir = Path(__file__).parent
|
||||
app.mount("/static/weather", StaticFiles(directory=base_dir / "static"))
|
||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
||||
|
||||
def build_weather_response(request: Request, response: WeatherResponse):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="weather.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"tag_util": TagUtil,
|
||||
"datetime": datetime,
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="weather.html",
|
||||
context={
|
||||
"response": response,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/weather", response_class=HTMLResponse)
|
||||
async def get_weather_list(request: Request):
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
locations = await weather_api.get_locations()
|
||||
locations_data = BUNDLE.select_items(locations)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"locations": locations_data,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/weather/{location}", response_class=RedirectResponse)
|
||||
async def get_weather_default(location: str):
|
||||
return RedirectResponse(f"{location}/tag/today")
|
||||
router = APIRouter()
|
||||
|
||||
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
|
||||
async def get_weather_day_mock(request: Request):
|
||||
response = WEATHER_MOCK_DATA.get_response("day")
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
|
||||
async def get_weather_days_mock(request: Request):
|
||||
response = WEATHER_MOCK_DATA.get_response("days")
|
||||
return build_weather_response(request, response)
|
||||
@router.get("/weather", response_class=HTMLResponse)
|
||||
async def get_weather_index(request: AppRequest, query: str | None = None):
|
||||
weather_api = request.app.state.api.weather
|
||||
locations = (await weather_api.find_locations(query)) if query else []
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={
|
||||
"locations": locations,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
||||
async def get_weather_day(request: Request, location: str, date: datetime.date):
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
response = await weather_api.get_day(location, date)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
||||
async def get_weather_days(request: Request, location: str, days: int):
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
response = await weather_api.get_days(location, days)
|
||||
return build_weather_response(request, response)
|
||||
@router.get("/weather/{location}", response_class=RedirectResponse)
|
||||
async def get_weather_default(location: str):
|
||||
return RedirectResponse(f"{location}/tag/today")
|
||||
|
||||
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_weather_tag(request: Request, location: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
weather_api: WeatherApi = request.app.state.weather_api
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await weather_api.get_day(location, tag_value.date)
|
||||
elif tag_value.type == TagType.DAYS:
|
||||
response = await weather_api.get_days(location, tag_value.days)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
||||
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
|
||||
weather_api = request.app.state.api.weather
|
||||
response = await weather_api.get_day(location, date)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
|
||||
@router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
||||
async def get_weather_days(request: AppRequest, location: str, days: int):
|
||||
weather_api = request.app.state.api.weather
|
||||
response = await weather_api.get_days(location, days)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
|
||||
@router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_weather_tag(request: AppRequest, location: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
weather_api = request.app.state.api.weather
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await weather_api.get_day(location, tag_value.date)
|
||||
elif tag_value.type == TagType.DAYS:
|
||||
response = await weather_api.get_days(location, tag_value.days)
|
||||
else:
|
||||
raise ValueError(tag)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
@@ -1,39 +1,55 @@
|
||||
from gallery.sketch.weather.model import Cloudness, Precipitation, Sky, WindDirection
|
||||
import datetime
|
||||
|
||||
from gallery.sketch.weather.model import (
|
||||
Cloudness,
|
||||
Precipitation,
|
||||
Sky,
|
||||
WindDirection,
|
||||
WindDirectionDeg,
|
||||
)
|
||||
|
||||
|
||||
def wind_direction_icon(wind_direction: WindDirection) -> str:
|
||||
return {
|
||||
WindDirection.N: "⬇️",
|
||||
WindDirection.NO: "↙️",
|
||||
WindDirection.O: "⬅️",
|
||||
WindDirection.SO: "↖️",
|
||||
WindDirection.S: "⬆️",
|
||||
WindDirection.SW: "↗️",
|
||||
WindDirection.W: "➡️",
|
||||
WindDirection.NW: "↘️",
|
||||
WindDirection.CALM: "",
|
||||
}.get(wind_direction, wind_direction)
|
||||
def wind_direction_icon(wind_direction_deg: float) -> str:
|
||||
wind_direction = WindDirectionDeg(wind_direction_deg).direction
|
||||
if wind_direction == WindDirection.CALM:
|
||||
return "wind-calm"
|
||||
else:
|
||||
return f"wind-from-{wind_direction.name.lower()}"
|
||||
|
||||
|
||||
def cloudness_icon(sky: Sky) -> list[str]:
|
||||
def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
|
||||
day = (3 < date.hour < 22) if period == "day" else True
|
||||
day_prefix = "day" if day else "night-alt"
|
||||
main_icon = ""
|
||||
if sky.thunder:
|
||||
if sky.cloudness == Cloudness.CLEAR:
|
||||
main_icon = "🌩️"
|
||||
if sky.precipitation == Precipitation.NO:
|
||||
main_icon = "⚡"
|
||||
else:
|
||||
main_icon = "⛈️"
|
||||
main_icon = {
|
||||
Precipitation.NO: "lightning",
|
||||
Precipitation.SMALL_RAIN: "storm-showers",
|
||||
Precipitation.RAIN: "thunderstorm",
|
||||
Precipitation.HEAVY_RAIN: "thunderstorm",
|
||||
Precipitation.SHOWER: "thunderstorm",
|
||||
Precipitation.SNOW: "storm-showers",
|
||||
Precipitation.HEAVY_SNOW: "storm-showers",
|
||||
}[sky.precipitation]
|
||||
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
|
||||
main_icon = f"{day_prefix}-{main_icon}"
|
||||
elif sky.precipitation == Precipitation.NO:
|
||||
main_icon = {
|
||||
Cloudness.CLEAR: "☀️",
|
||||
Cloudness.PARTLY_CLOUDY: "🌤️",
|
||||
Cloudness.CLOUDY: "⛅",
|
||||
Cloudness.MAINLY_CLOUDY: "☁️",
|
||||
Cloudness.CLEAR: "day-sunny" if day else "night-clear",
|
||||
Cloudness.PARTLY_CLOUDY: f"{day_prefix}-cloudy",
|
||||
Cloudness.CLOUDY: "cloud",
|
||||
Cloudness.MAINLY_CLOUDY: "cloudy",
|
||||
}[sky.cloudness]
|
||||
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",
|
||||
}[sky.precipitation]
|
||||
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
|
||||
main_icon = f"{day_prefix}-{main_icon}"
|
||||
icons = [main_icon]
|
||||
if sky.fog:
|
||||
icons.append("🌫️")
|
||||
return icons
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,72 +0,0 @@
|
||||
.header {
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.date.now {
|
||||
background: rgba(0, 128, 255, 0.2);
|
||||
}
|
||||
|
||||
.date .value a {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cloudness {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.cloudness .icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cloudness .icon:first-child {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.temperature .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.temperature .value.positive {
|
||||
color: orangered;
|
||||
}
|
||||
|
||||
.temperature .value.negative {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.wind .direction {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.wind .gust {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.precipitation .value {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.pressure {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pressure .value {
|
||||
padding: 0.1rem 0.4rem;
|
||||
color: blueviolet;
|
||||
}
|
||||
|
||||
.humidity .value {
|
||||
color: blue;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.5 KiB |
@@ -1,37 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{_("Weather")}}{% endblock %}
|
||||
|
||||
<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/weather/style.css?v={{version}}">
|
||||
<link rel="icon"
|
||||
href="/static/weather/favicon.ico?v={{version}}"
|
||||
type="image/x-icon">
|
||||
</head>
|
||||
{% block content %}
|
||||
<h1>{{_("Weather")}}</h1>
|
||||
<form action=""
|
||||
method="get"
|
||||
class="mb-4">
|
||||
<div class="input-group mb-3">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="query"
|
||||
name="query"
|
||||
placeholder="{{_('Enter the city name')}}">
|
||||
<button class="btn btn-primary"
|
||||
type="submit">{{_("Search")}}</button>
|
||||
</div>
|
||||
</form>
|
||||
<ul id="locations"
|
||||
class="list-group mb-5">
|
||||
{% for location in locations %}
|
||||
<a href="weather/{{location.id}}"
|
||||
class="list-group-item list-group-item-action px-4"
|
||||
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
|
||||
<span class="fi fi-{{location.country_code}} me-1"></span>
|
||||
<span class="text-primary">{{location.name}}</span>
|
||||
<span class="small ms-1 text-secondary">
|
||||
{{location.country}}, {{location.district}}, {{location.subdistrict}}
|
||||
</span>
|
||||
<span></span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<script>
|
||||
(function () {
|
||||
document.loadLocations = () => {
|
||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
||||
const container = document.querySelector('#locations');
|
||||
container.innerHTML = '';
|
||||
for (const [id, name] of Object.entries(locations)) {
|
||||
const element = document.createElement('a');
|
||||
element.href = `weather/${id}`;
|
||||
element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start';
|
||||
element.innerHTML = `
|
||||
<span class="text-primary me-auto">${name}</span>
|
||||
<span class="text-danger" onclick="removeLocation('${id}'); event.preventDefault();">✕</span>
|
||||
`;
|
||||
container.appendChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
<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 location in locations %}
|
||||
<li><a href="weather/{{location.id}}">{{location.name}}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</body>
|
||||
document.saveLocation = (location) => {
|
||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
||||
locations[location.id] = location.name;
|
||||
window.localStorage.setItem('locations', JSON.stringify(locations));
|
||||
}
|
||||
|
||||
</html>
|
||||
document.removeLocation = (id) => {
|
||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
||||
delete locations[id];
|
||||
window.localStorage.setItem('locations', JSON.stringify(locations));
|
||||
document.loadLocations();
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const searchQuery = params.get('query');
|
||||
if (searchQuery) {
|
||||
document.querySelector('#query').value = searchQuery;
|
||||
} else {
|
||||
document.loadLocations();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,184 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible"
|
||||
content="ie=edge">
|
||||
<title>Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
|
||||
<link rel="stylesheet"
|
||||
href="/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>
|
||||
{% block header %}
|
||||
<app-link href="/weather"
|
||||
icon="brightness-high">{{_("Weather")}}</app-link>
|
||||
{% endblock %}
|
||||
|
||||
<body class="app-container">
|
||||
<h3 class="app-header">
|
||||
<a class="app-link-home"
|
||||
href="/">
|
||||
<div></div>
|
||||
</a>
|
||||
<div class="app-title">
|
||||
{% if response.period == 'day' %}
|
||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
||||
<a class="button"
|
||||
href="../tag/days-10">⬆️</a>
|
||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||
<a class="button"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<!-- date -->
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
{% if response.period == 'day' %}
|
||||
<td
|
||||
class="date {{'now' if value.date < datetime.datetime.now() and value.date + datetime.timedelta(hours=3) > datetime.datetime.now() else ''}}">
|
||||
<span class="value">{{value.date.strftime('%H:%M')}}</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
|
||||
<span class="value">
|
||||
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
|
||||
{{value.date.strftime('%a %d')}}
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- cloudness -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Облачность
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="cloudness">
|
||||
{% for icon in value.sky | cloudness_icon %}
|
||||
<div class="icon">{{icon}}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- temperature -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Температура, °C
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="temperature">
|
||||
{% for temperature in value.temperature %}
|
||||
<div class="value {{'positive' if temperature > 0 else 'negative'}}"
|
||||
style="background-color: rgba(255, 128, 128, {{(temperature - 10) * 0.015}});">
|
||||
{{temperature}}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_direction -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Направление ветра
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind">
|
||||
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_speed -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Скорость ветра, м/с
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind"
|
||||
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
|
||||
<span class="speed">{{value.wind_speed}}</span>
|
||||
{% if value.wind_gust != value.wind_speed %}
|
||||
<span class="gust">
|
||||
({{value.wind_gust}})
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- precipitation -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Осадки, мм
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="precipitation"
|
||||
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
|
||||
<span class="value">{{value.precipitation or ' '}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- pressure -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Давление, мм рт. ст.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="pressure">
|
||||
{% for pressure in value.pressure %}
|
||||
<div class="value"
|
||||
style="background-color: rgba(128, 0, 255, {{(pressure - 720) * 0.008}});">
|
||||
{{pressure}}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- humidity -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
Влажность, %
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="humidity"
|
||||
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
|
||||
<span class="value">{{value.humidity}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{% block content %}
|
||||
<h4>
|
||||
{% if response.period == 'day' %}
|
||||
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
|
||||
<i class="bi bi-arrow-left-square"></i>
|
||||
</a>
|
||||
<a class="icon-link"
|
||||
href="../tag/days-10">
|
||||
<i class="bi bi-arrow-up-square"></i>
|
||||
</a>
|
||||
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||
<a class="icon-link"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
|
||||
<i class="bi bi-arrow-right-square"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||
<span>- {{format_date(response.date + datetime.timedelta(days=(response.values | length - 1)), DATE_FORMAT,
|
||||
locale=request.state.language)}}</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-weather table-borderless table-compact text-center w-auto"
|
||||
style="font-size: 130%;">
|
||||
<tbody>
|
||||
<!-- date -->
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
{% if response.period == 'day' %}
|
||||
<td
|
||||
class="date {{'now' if value.date < datetime.datetime.now() and value.date + datetime.timedelta(hours=3) > datetime.datetime.now() else ''}}">
|
||||
<span class="value">{{value.date.strftime('%H:%M')}}</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if response.period == 'days' %}
|
||||
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
|
||||
<span class="value {{'text-danger' if value.date.weekday() in [5,6] else ''}}">
|
||||
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
|
||||
{{format_date(value.date, 'E d', locale=request.state.language)}}
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- cloudness -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
{{_("Cloudiness")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="cloudness"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ value.sky }}">
|
||||
{% for icon in value.sky | cloudness_icon(value.date, response.period) %}
|
||||
<div class="wi wi-{{icon}} wi-xl text-primary"></div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- temperature -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
{{_("Temperature, °C")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="temperature">
|
||||
{% for temperature in value.temperature %}
|
||||
<div class="value {{'positive' if temperature > 0 else 'negative'}}"
|
||||
style="background-color: rgba(255, 128, 128, {{(temperature - 10) * 0.015}});">
|
||||
{{temperature}}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_direction -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
{{_("Wind direction")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ value.wind_direction }}">
|
||||
<div class="wi wi-wind-deg wi-{{value.wind_direction | wind_direction_icon}} wi-l text-primary"></div>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- wind_speed -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
{{_("Wind speed, m/s")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind"
|
||||
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
|
||||
<span class="speed">{{value.wind_speed}}</span>
|
||||
{% if value.wind_gust != value.wind_speed %}
|
||||
<span class="gust">
|
||||
({{value.wind_gust}})
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- precipitation -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
{{_("Precipitation, mm")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="precipitation"
|
||||
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
|
||||
<span class="value">{{value.precipitation or ' '}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- pressure -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
{{_("Pressure, mmHg")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="pressure">
|
||||
{% for pressure in value.pressure %}
|
||||
<div class="value"
|
||||
style="background-color: rgba(128, 0, 255, {{(pressure - 720) * 0.008}});">
|
||||
{{pressure}}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<!-- humidity -->
|
||||
<tr>
|
||||
<td colspan="{{response.values | length}}"
|
||||
class="header">
|
||||
{{_("Humidity, %")}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="humidity"
|
||||
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
|
||||
<span class="value">{{value.humidity}}</span>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -6,19 +6,28 @@ import uvicorn
|
||||
from gallery.easel import build_app
|
||||
from gallery.painting.gismeteo.api import GismeteoApi
|
||||
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.weather.cached import CachedWeatherApi
|
||||
|
||||
weather_api = CachedWeatherApi(GismeteoApi())
|
||||
schedule_api = CachedScheduleApi(MatchTvApi())
|
||||
app = build_app(weather_api, schedule_api)
|
||||
api = ApiBundle(
|
||||
[
|
||||
CachedScheduleApi(YandexTvApi()),
|
||||
CachedScheduleApi(MatchTvApi()),
|
||||
CachedWeatherApi(GismeteoApi()),
|
||||
CachedWeatherApi(OpenWeatherApi()),
|
||||
]
|
||||
)
|
||||
app = build_app(api)
|
||||
|
||||
|
||||
def run():
|
||||
uvicorn.run(
|
||||
"gallery.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
host=environ.get("GALLERY_HOST", "0.0.0.0"),
|
||||
port=int(environ.get("GALLERY_PORT", 8000)),
|
||||
log_config=str(Path(__file__).parent / "logging.yaml"),
|
||||
reload="DEBUG" in environ,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from gallery.sketch.source import ApiSource
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.catalog import LocationId
|
||||
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
|
||||
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
|
||||
|
||||
from . import datehelp
|
||||
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
|
||||
@@ -34,7 +35,7 @@ class GismeteoApi(WeatherApi):
|
||||
)
|
||||
|
||||
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
|
||||
result: List[Dict[str, Any]] = []
|
||||
result: list[dict[str, Any]] = []
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
location = LOCATION_PARSER.parse_location(data)
|
||||
widget = ONE_DAY_PARSER.parse_widget(soup)
|
||||
@@ -52,7 +53,7 @@ class GismeteoApi(WeatherApi):
|
||||
)
|
||||
|
||||
def _parse_manydays(self, data: str) -> WeatherResponse:
|
||||
result: List[Dict[str, Any]] = []
|
||||
result: list[dict[str, Any]] = []
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
location = LOCATION_PARSER.parse_location(data)
|
||||
widget = DAYS_PARSER.parse_widget(soup)
|
||||
@@ -69,13 +70,41 @@ class GismeteoApi(WeatherApi):
|
||||
values=values,
|
||||
)
|
||||
|
||||
async def get_locations(self) -> list[str]:
|
||||
return [
|
||||
LocationId.OREL,
|
||||
LocationId.ZMIYEVKA,
|
||||
]
|
||||
async def find_locations(self, query: str) -> list[Location]:
|
||||
geo = "ru"
|
||||
latitude = 52.968498
|
||||
longitude = 36.0695
|
||||
data = json.loads(
|
||||
await self.SOURCE.request(
|
||||
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
|
||||
)
|
||||
)
|
||||
result = []
|
||||
for item in data["data"]:
|
||||
result.append(
|
||||
Location(
|
||||
id=f"{item['slug']}-{item['id']}",
|
||||
name=item["translations"]["kk"]["city"]["name"],
|
||||
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"],
|
||||
subdistrict=(
|
||||
item["translations"]["kk"]["subdistrict"]["name"]
|
||||
if "subdistrict" in item["translations"]["kk"]
|
||||
else ""
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
max_date = datetime.date.today() + datetime.timedelta(days=9)
|
||||
if date > max_date:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail={"max_date": max_date.strftime("%Y-%m-%d")}
|
||||
)
|
||||
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
|
||||
return self._parse_oneday(date, data)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from bs4 import Tag
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class WidgetParser:
|
||||
def parse_widget(self, tag: Tag) -> Tag:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from gallery.sketch.mock import MockData
|
||||
|
||||
GISMETEO_MOCK_DATA = MockData(Path(__file__).parent / "data")
|
||||
@@ -1,14 +1,23 @@
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
import dateparser
|
||||
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
|
||||
|
||||
logger = logging.getLogger("gismeteo")
|
||||
|
||||
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
|
||||
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
|
||||
|
||||
@@ -30,9 +39,7 @@ class DateParser(RowParser[datetime.datetime]):
|
||||
KEY = "date"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
|
||||
datetime_date_tag = tag.select_one(
|
||||
".widget-row.widget-row-datetime-date > .row-item"
|
||||
)
|
||||
datetime_date_tag = tag.select_one(".widget-row.widget-row-datetime-date > .row-item")
|
||||
if datetime_date_tag:
|
||||
date_str = datetime_date_tag.find(text=True, recursive=False).text
|
||||
date = dateparser.parse(date_str, languages=["ru"])
|
||||
@@ -53,6 +60,7 @@ class SkyParser(RowParser[Sky]):
|
||||
|
||||
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
||||
"ясно": Cloudness.CLEAR,
|
||||
"безоблачно": Cloudness.CLEAR,
|
||||
"малооблачно": Cloudness.PARTLY_CLOUDY,
|
||||
"облачно": Cloudness.CLOUDY,
|
||||
"пасмурно": Cloudness.MAINLY_CLOUDY,
|
||||
@@ -60,27 +68,52 @@ class SkyParser(RowParser[Sky]):
|
||||
|
||||
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
||||
"без осадков": Precipitation.NO,
|
||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||
"небольшой дождь": Precipitation.SMALL_RAIN, # TODO: remove it?
|
||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||
"сильный дождь": Precipitation.HEAVY_RAIN, # TODO: remove it?
|
||||
"сильный дождь": Precipitation.HEAVY_RAIN,
|
||||
"ливневый дождь": Precipitation.SHOWER,
|
||||
"дождь": Precipitation.RAIN,
|
||||
"ливень": Precipitation.SHOWER,
|
||||
"снег": Precipitation.SNOW,
|
||||
"небольшой снег": Precipitation.SNOW,
|
||||
"сильный снег": Precipitation.HEAVY_SNOW,
|
||||
"мокрый снег": Precipitation.SNOW,
|
||||
"снег с дождём": Precipitation.SNOW,
|
||||
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
|
||||
"небольшой снег с дождём": Precipitation.SNOW,
|
||||
"небольшой мокрый снег": Precipitation.SNOW,
|
||||
}
|
||||
|
||||
THUNDER = "гроза"
|
||||
FOG = "дымка"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[Sky]:
|
||||
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
|
||||
sky_str = item.attrs["data-tooltip"]
|
||||
values = {item.strip().lower() for item in sky_str.split(",")}
|
||||
cloudness = Cloudness.CLEAR
|
||||
precipitation = Precipitation.NO
|
||||
thunder = "гроза" in values
|
||||
fog = "дымка" in values
|
||||
thunder = False
|
||||
fog = False
|
||||
if self.THUNDER in values:
|
||||
thunder = True
|
||||
values.remove(self.THUNDER)
|
||||
if self.FOG in values:
|
||||
fog = True
|
||||
values.remove(self.FOG)
|
||||
for k, v in self.CLOUDNESS_MAP.items():
|
||||
if k in values:
|
||||
cloudness = v
|
||||
values.remove(k)
|
||||
break
|
||||
for k, v in self.PRECIPITATION_MAP.items():
|
||||
if k in values:
|
||||
precipitation = v
|
||||
values.remove(k)
|
||||
break
|
||||
if values:
|
||||
logger.warning("unknown sky values: %s:", values)
|
||||
yield Sky(
|
||||
cloudness=cloudness,
|
||||
precipitation=precipitation,
|
||||
@@ -93,21 +126,15 @@ class TemperatureParser(RowParser[list[int]]):
|
||||
KEY = "temperature"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
||||
for item in tag.select(
|
||||
".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"
|
||||
):
|
||||
yield [
|
||||
int(value.attrs["value"]) for value in item.select("temperature-value")
|
||||
]
|
||||
for item in tag.select(".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"):
|
||||
yield [int(value.attrs["value"]) for value in item.select("temperature-value")]
|
||||
|
||||
|
||||
class WindSpeedParser(RowParser[int]):
|
||||
KEY = "wind_speed"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(
|
||||
".widget-row[data-row=wind-speed] > .row-item > speed-value"
|
||||
):
|
||||
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > speed-value"):
|
||||
yield int(item.attrs["value"])
|
||||
|
||||
|
||||
@@ -115,7 +142,7 @@ class WindGustParser(RowParser[int]):
|
||||
KEY = "wind_gust"
|
||||
|
||||
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")
|
||||
yield int(value.attrs["value"]) if value else 0
|
||||
|
||||
@@ -124,32 +151,29 @@ class WindDirectionParser(RowParser[WindDirection]):
|
||||
KEY = "wind_direction"
|
||||
|
||||
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
|
||||
"—": WindDirection.CALM,
|
||||
"штиль": WindDirection.CALM,
|
||||
"с": WindDirection.N,
|
||||
"св": WindDirection.NO,
|
||||
"в": WindDirection.O,
|
||||
"юв": WindDirection.SO,
|
||||
"св": WindDirection.NE,
|
||||
"в": WindDirection.E,
|
||||
"юв": WindDirection.SE,
|
||||
"ю": WindDirection.S,
|
||||
"юз": WindDirection.SW,
|
||||
"з": WindDirection.W,
|
||||
"сз": WindDirection.NW,
|
||||
}
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[WindDirection]:
|
||||
for item in tag.select(
|
||||
".widget-row[data-row=wind-direction] > .row-item > .direction"
|
||||
):
|
||||
wind_direction_str = item.text.lower()
|
||||
yield self.WIND_DIRECTION_MAP[wind_direction_str]
|
||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > .wind-direction"):
|
||||
wind_direction_str = item.text.lower().strip()
|
||||
yield WindDirectionDeg.from_direction(self.WIND_DIRECTION_MAP[wind_direction_str]).value
|
||||
|
||||
|
||||
class WindPrecipitationParser(RowParser[float]):
|
||||
class PrecipitationParser(RowParser[float]):
|
||||
KEY = "precipitation"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||
for item in tag.select(
|
||||
".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"
|
||||
):
|
||||
for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"):
|
||||
yield float(item.text.replace(",", "."))
|
||||
|
||||
|
||||
@@ -157,9 +181,7 @@ class PressureParser(RowParser[list[int]]):
|
||||
KEY = "pressure"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
||||
for item in tag.select(
|
||||
".widget-row-chart[data-row=pressure] > .chart > .values > .value"
|
||||
):
|
||||
for item in tag.select(".widget-row-chart[data-row=pressure] > .chart > .values > .value"):
|
||||
yield [int(value.attrs["value"]) for value in item.select("pressure-value")]
|
||||
|
||||
|
||||
@@ -167,7 +189,9 @@ class HumidityParser(RowParser[int]):
|
||||
KEY = "humidity"
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -178,7 +202,7 @@ ROW_PARSERS: list[RowParser] = [
|
||||
WindSpeedParser(),
|
||||
WindGustParser(),
|
||||
WindDirectionParser(),
|
||||
WindPrecipitationParser(),
|
||||
PrecipitationParser(),
|
||||
PressureParser(),
|
||||
HumidityParser(),
|
||||
]
|
||||
|
||||
@@ -4,8 +4,7 @@ import logging
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
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, ChannelId, Schedule, ScheduleValue
|
||||
from gallery.sketch.source import ApiSource
|
||||
|
||||
logger = logging.getLogger("matchtv")
|
||||
@@ -15,7 +14,7 @@ class MatchTvApi(ScheduleApi):
|
||||
PROVIDER = "matchtv"
|
||||
SOURCE = ApiSource("https://matchtv.ru")
|
||||
|
||||
async def get_channels(self) -> list[str]:
|
||||
async def get_channels(self) -> list[ChannelId]:
|
||||
return [
|
||||
ChannelId.MATCH_TV,
|
||||
ChannelId.MATCH_IGRA,
|
||||
@@ -26,22 +25,20 @@ class MatchTvApi(ScheduleApi):
|
||||
ChannelId.MATCH_STRANA,
|
||||
]
|
||||
|
||||
async def get_channel_schedule(
|
||||
self, channel_id: str, date: datetime.date
|
||||
) -> Schedule:
|
||||
endpoint = f"channel/{channel_id}/tvguide?date={date:%d-%m-%Y}"
|
||||
async def get_channel_schedule(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
|
||||
endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}"
|
||||
data = await self.SOURCE.request(endpoint)
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
values = []
|
||||
channel_name = soup.select_one(".caption__heading").text.split("|")[0].strip()
|
||||
current_day = datetime.datetime.combine(
|
||||
date.today(), datetime.datetime.min.time()
|
||||
)
|
||||
channel_name = soup.select_one(".p-tv-guide-header__title").text.replace("Телепрограмма ", "").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(".teleprogram-schedule .teleprogram-schedule__item"):
|
||||
title = item.select_one(".teleprogram-item__title").text.strip()
|
||||
time_str = item.select_one(".teleprogram-item__time").text.strip()
|
||||
for item in soup.select(
|
||||
".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission"
|
||||
):
|
||||
title = item.select_one(".p-tv-guide-schedule-channel-transmission__title").text.strip()
|
||||
time_str = item.select_one(".p-tv-guide-schedule-channel-transmission__time-block").text.strip()
|
||||
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:
|
||||
@@ -53,6 +50,4 @@ class MatchTvApi(ScheduleApi):
|
||||
if prev_value is not None:
|
||||
prev_value.end = item_date
|
||||
prev_value = value
|
||||
return Schedule(
|
||||
channel=Channel(id=channel_id, name=channel_name), date=date, values=values
|
||||
)
|
||||
return Schedule(channel=Channel(id=channel_id, name=channel_name), 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
64
gallery/painting/openweather/api.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import datetime
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
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, OpenWeather
|
||||
from .parser import FORECAST_ITEM_PARSER
|
||||
|
||||
logger = logging.getLogger("openweather")
|
||||
|
||||
|
||||
class OpenWeatherApi(WeatherApi):
|
||||
PROVIDER = "openweather"
|
||||
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
|
||||
|
||||
@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}.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]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
data: Forecast = await self._get_location_forecast(location_id)
|
||||
values = []
|
||||
for item in data.list:
|
||||
value = FORECAST_ITEM_PARSER.parse(item)
|
||||
if value.date.date() == date:
|
||||
values.append(value)
|
||||
return WeatherResponse(
|
||||
location=location_id,
|
||||
date=date,
|
||||
period="day",
|
||||
values=values,
|
||||
)
|
||||
|
||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||
data: Forecast = await self._get_location_forecast(location_id)
|
||||
values_by_date: dict[datetime.datetime, list[WeatherValue]] = defaultdict(list)
|
||||
for item in data.list:
|
||||
value = FORECAST_ITEM_PARSER.parse(item)
|
||||
item_date = value.date.replace(hour=0, minute=0)
|
||||
values_by_date[item_date].append(value)
|
||||
values = [merge_weather_values(date, values) for date, values in values_by_date.items()]
|
||||
return WeatherResponse(
|
||||
location=location_id,
|
||||
date=datetime.date.today(),
|
||||
period="days",
|
||||
values=list(sorted(values, key=lambda item: item.date)),
|
||||
)
|
||||
81
gallery/painting/openweather/openweather.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from gallery.sketch.source import ApiSource
|
||||
|
||||
|
||||
class Model(BaseModel):
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class Main(Model):
|
||||
temp: float
|
||||
feels_like: float
|
||||
temp_min: float
|
||||
temp_max: float
|
||||
pressure: int
|
||||
sea_level: int
|
||||
grnd_level: int
|
||||
humidity: int
|
||||
temp_kf: float
|
||||
|
||||
|
||||
class Weather(Model):
|
||||
id: int
|
||||
main: str
|
||||
description: str
|
||||
icon: str
|
||||
|
||||
|
||||
class Clouds(Model):
|
||||
all: int
|
||||
|
||||
|
||||
class Wind(Model):
|
||||
speed: float
|
||||
deg: int
|
||||
gust: float
|
||||
|
||||
|
||||
class Rain(Model):
|
||||
interval_3h: float = Field(..., alias="3h")
|
||||
|
||||
|
||||
class Sys(Model):
|
||||
pod: str
|
||||
|
||||
|
||||
class ForecastItem(Model):
|
||||
dt: int
|
||||
main: Main
|
||||
weather: list[Weather]
|
||||
clouds: Clouds
|
||||
wind: Wind
|
||||
visibility: int
|
||||
pop: float
|
||||
rain: Rain | None = None
|
||||
sys: Sys
|
||||
dt_txt: str
|
||||
|
||||
|
||||
class Forecast(Model):
|
||||
cod: str
|
||||
message: int
|
||||
cnt: int
|
||||
list: list[ForecastItem]
|
||||
|
||||
|
||||
class OpenWeather:
|
||||
BASE_URL = "https://api.openweathermap.org"
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
self._api_key = api_key
|
||||
self._source = ApiSource(self.BASE_URL)
|
||||
|
||||
async def get_forecast(self, lat: float, lon: float) -> Forecast:
|
||||
endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
|
||||
response = await self._source.request(endpoint)
|
||||
response_data = json.loads(response)
|
||||
return Forecast(**response_data)
|
||||
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
88
gallery/painting/yandextv/api.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
|
||||
from gallery.sketch.source import ApiSource
|
||||
|
||||
logger = logging.getLogger("matchtv")
|
||||
|
||||
CHANNELS_MAP: dict[ChannelId, str] = {
|
||||
ChannelId.MATCH_TV: "match-tv-49",
|
||||
ChannelId.MATCH_IGRA: "match-igra-1174",
|
||||
ChannelId.MATCH_ARENA: "match-arena-1173",
|
||||
ChannelId.MATCH_FUTBOL_1: "match-futbol-1-646",
|
||||
ChannelId.MATCH_FUTBOL_2: "match-futbol-2-593",
|
||||
ChannelId.MATCH_FUTBOL_3: "match-futbol-3-797",
|
||||
ChannelId.MATCH_STRANA: "match-strana-1356",
|
||||
ChannelId.MATCH_PLANETA: "match-planeta-1177",
|
||||
# ChannelId.EUROSPORT: "eurosport-677",
|
||||
# ChannelId.EUROSPORT_2: "eurosport-2-720",
|
||||
ChannelId.START: "start-103",
|
||||
}
|
||||
|
||||
HEADERS: dict[str, str] = {
|
||||
"Accept": (
|
||||
"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 get_channels(self) -> list[ChannelId]:
|
||||
return list(CHANNELS_MAP.keys())
|
||||
|
||||
async def get_channel_schedule(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
|
||||
endpoint = f"channel/{CHANNELS_MAP[channel_id]}?date={date:%Y-%m-%d}"
|
||||
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), date=date, values=values)
|
||||
@@ -1,6 +1,12 @@
|
||||
from typing import TypeVar
|
||||
|
||||
|
||||
class Api:
|
||||
PROVIDER: str
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return self.PROVIDER
|
||||
|
||||
|
||||
API = TypeVar("API", bound=Api)
|
||||
|
||||
28
gallery/sketch/bundle.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from .api import API, Api
|
||||
from .schedule.api import ScheduleApi
|
||||
from .weather.api import WeatherApi
|
||||
|
||||
|
||||
class ApiBundle(list[Api]):
|
||||
def __init__(self, values: list[Api]) -> None:
|
||||
super().__init__(values)
|
||||
|
||||
def get_api_by_provider(self, provider: str) -> Api:
|
||||
for value in self:
|
||||
if value.PROVIDER == provider:
|
||||
return value
|
||||
raise ValueError(provider)
|
||||
|
||||
def get_api_by_type(self, api_type: type[API]) -> API:
|
||||
for value in self:
|
||||
if isinstance(value, api_type):
|
||||
return value
|
||||
raise ValueError(api_type)
|
||||
|
||||
@property
|
||||
def weather(self) -> WeatherApi:
|
||||
return self.get_api_by_type(WeatherApi)
|
||||
|
||||
@property
|
||||
def schedule(self) -> ScheduleApi:
|
||||
return self.get_api_by_type(ScheduleApi)
|
||||
@@ -1,15 +1,19 @@
|
||||
from typing import Generic, TypeVar
|
||||
from typing import Generic, NamedTuple
|
||||
|
||||
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]):
|
||||
CACHE_TTL: int = TimeUnit.HOUR
|
||||
CACHE_ALIAS: str = "redis"
|
||||
CACHE_KEY: str
|
||||
|
||||
def __init__(self, api: API):
|
||||
|
||||
@@ -7,5 +7,8 @@ class CatalogBundle(Generic[T]):
|
||||
def __init__(self, items: list[T]) -> None:
|
||||
self._items_by_id = {item.id: item for item in items}
|
||||
|
||||
def get_item(self, item_id: str) -> T:
|
||||
return self._items_by_id[item_id]
|
||||
|
||||
def select_items(self, ids: list[str]) -> list[T]:
|
||||
return [self._items_by_id[id_] for id_ in ids]
|
||||
|
||||
@@ -1,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,24 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
from ..api import Api
|
||||
from .model import Schedule
|
||||
from .model import ChannelId, Schedule
|
||||
|
||||
|
||||
class ScheduleApi(Api):
|
||||
async def get_channels(self) -> list[str]:
|
||||
INTERVAL: float = 0.5
|
||||
|
||||
async def get_channels(self) -> list[ChannelId]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_channel_schedule(
|
||||
self, channel_id: str, date: datetime.date
|
||||
) -> Schedule:
|
||||
async def get_channel_schedule(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
|
||||
channels = await self.get_channels()
|
||||
results = []
|
||||
for channel in channels:
|
||||
results.append(await self.get_channel_schedule(channel_id=channel, date=date))
|
||||
if self.INTERVAL > 0:
|
||||
await asyncio.sleep(self.INTERVAL)
|
||||
return results
|
||||
|
||||
@@ -2,10 +2,13 @@ import datetime
|
||||
|
||||
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 .model import Schedule
|
||||
from .model import ChannelId, Schedule
|
||||
|
||||
CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
|
||||
|
||||
|
||||
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
||||
@@ -13,20 +16,23 @@ class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",
|
||||
alias=CachedApi.CACHE_ALIAS,
|
||||
ttl=CachedApi.CACHE_TTL,
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_channels(self) -> list[str]:
|
||||
async def get_channels(self) -> list[ChannelId]:
|
||||
return await self._api.get_channels()
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self, channel_id, date: (
|
||||
f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}"
|
||||
),
|
||||
alias=CachedApi.CACHE_ALIAS,
|
||||
ttl=CachedApi.CACHE_TTL,
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_channel_schedule(
|
||||
self, channel_id: str, date: datetime.date
|
||||
) -> Schedule:
|
||||
async def get_channel_schedule(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
|
||||
return await self._api.get_channel_schedule(channel_id, date)
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self, date: (f"api.{self.CACHE_KEY}.{self.provider}.all.{date}"),
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
|
||||
return await self._api.get_all_schedules(date)
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
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
|
||||
|
||||
from .model import Channel, ChannelId
|
||||
|
||||
BUNDLE = CatalogBundle(
|
||||
[
|
||||
@@ -27,5 +11,11 @@ BUNDLE = CatalogBundle(
|
||||
Channel(id=ChannelId.MATCH_FUTBOL_2, name="Футбол 2"),
|
||||
Channel(id=ChannelId.MATCH_FUTBOL_3, name="Футбол 3"),
|
||||
Channel(id=ChannelId.MATCH_STRANA, name="Матч! Страна"),
|
||||
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
|
||||
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
|
||||
Channel(id=ChannelId.EUROSPORT, name="Europsort"),
|
||||
Channel(id=ChannelId.EUROSPORT_2, name="Europsort 2"),
|
||||
Channel(id=ChannelId.START, name="Старт!"),
|
||||
Channel(id=ChannelId.TEST, name="Тест"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -8,8 +9,26 @@ class Model(BaseModel):
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class ChannelId(StrEnum):
|
||||
MATCH_TV = "matchtv"
|
||||
MATCH_IGRA = "igra"
|
||||
MATCH_ARENA = "arena"
|
||||
MATCH_FUTBOL_1 = "futbol-1"
|
||||
MATCH_FUTBOL_2 = "futbol-2"
|
||||
MATCH_FUTBOL_3 = "futbol-3"
|
||||
MATCH_STRANA = "strana"
|
||||
MATCH_PLANETA = "planeta"
|
||||
EUROSPORT = "eurosport"
|
||||
EUROSPORT_2 = "eurosport-2"
|
||||
START = "start"
|
||||
TEST = "test"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class Channel(Model):
|
||||
id: str
|
||||
id: ChannelId
|
||||
name: str
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ logger = logging.getLogger("source")
|
||||
|
||||
class ApiSource:
|
||||
DEFAULT_USER_AGENT = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/126.0.0.0 Safari/537.36"
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
)
|
||||
DEFAULT_TIMEOUT = 30.0
|
||||
|
||||
@@ -19,18 +17,18 @@ class ApiSource:
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
cookies: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
):
|
||||
self._base_url = base_url
|
||||
self._user_agent = user_agent
|
||||
self._timeout = timeout
|
||||
self._cookies = cookies
|
||||
self._headers = headers
|
||||
|
||||
async def request(self, endpoint: str) -> str:
|
||||
url = f"{self._base_url}/{endpoint}"
|
||||
logger.info(url)
|
||||
headers = {
|
||||
"User-Agent": self._user_agent,
|
||||
}
|
||||
headers = {"User-Agent": self._user_agent, **(self._headers or {})}
|
||||
async with aiohttp.ClientSession(
|
||||
headers=headers,
|
||||
cookies=self._cookies,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import datetime
|
||||
|
||||
from ..api import Api
|
||||
from .model import WeatherResponse
|
||||
from .model import Location, WeatherResponse
|
||||
|
||||
|
||||
class WeatherApi(Api):
|
||||
|
||||
async def get_locations(self) -> list[str]:
|
||||
async def find_locations(self, query: str) -> list[Location]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
|
||||
@@ -2,29 +2,29 @@ import datetime
|
||||
|
||||
from aiocache import cached
|
||||
|
||||
from gallery.sketch.cached import CachedApi
|
||||
from gallery.sketch.cached import DEFAULT_CACHE_PRESET, CachedApi
|
||||
|
||||
from .api import WeatherApi
|
||||
from .model import WeatherResponse
|
||||
from .model import Location, WeatherResponse
|
||||
|
||||
CACHE_PRESET = DEFAULT_CACHE_PRESET
|
||||
|
||||
|
||||
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
||||
CACHE_KEY = "weather"
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.locations",
|
||||
alias=CachedApi.CACHE_ALIAS,
|
||||
ttl=CachedApi.CACHE_TTL,
|
||||
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_locations(self) -> list[str]:
|
||||
return await self._api.get_locations()
|
||||
async def find_locations(self, query: str) -> list[Location]:
|
||||
return await self._api.find_locations(query)
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self, location_id, date: (
|
||||
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
|
||||
),
|
||||
alias=CachedApi.CACHE_ALIAS,
|
||||
ttl=CachedApi.CACHE_TTL,
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
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: (
|
||||
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
|
||||
),
|
||||
alias=CachedApi.CACHE_ALIAS,
|
||||
ttl=CachedApi.CACHE_TTL,
|
||||
**CACHE_PRESET._asdict(),
|
||||
)
|
||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||
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}]}
|
||||
@@ -12,6 +12,12 @@ class Model(BaseModel):
|
||||
class Location(Model):
|
||||
id: str
|
||||
name: str
|
||||
lat: float
|
||||
lon: float
|
||||
country: str
|
||||
country_code: str
|
||||
district: str
|
||||
subdistrict: str
|
||||
|
||||
|
||||
class Cloudness(str, Enum):
|
||||
@@ -25,7 +31,10 @@ class Precipitation(str, Enum):
|
||||
NO = "no"
|
||||
SMALL_RAIN = "small_rain"
|
||||
RAIN = "rain"
|
||||
HEAVY_RAIN = "heavy_rain"
|
||||
SHOWER = "shower"
|
||||
SNOW = "snow"
|
||||
HEAVY_SNOW = "heavy_snow"
|
||||
|
||||
|
||||
class Sky(Model):
|
||||
@@ -38,22 +47,71 @@ class Sky(Model):
|
||||
class WindDirection(str, Enum):
|
||||
CALM = "calm"
|
||||
N = "N"
|
||||
NO = "NO"
|
||||
O = "O"
|
||||
SO = "SO"
|
||||
NE = "NE"
|
||||
E = "E"
|
||||
SE = "SE"
|
||||
S = "S"
|
||||
SW = "SW"
|
||||
W = "W"
|
||||
NW = "NW"
|
||||
|
||||
|
||||
class WindDirectionDeg(float):
|
||||
@property
|
||||
def direction(self) -> WindDirection:
|
||||
return self.to_direction()
|
||||
|
||||
@property
|
||||
def value(self) -> float:
|
||||
return self
|
||||
|
||||
# pylint:disable=too-many-return-statements
|
||||
def to_direction(self) -> WindDirection:
|
||||
if self == -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) -> "WindDirectionDeg":
|
||||
return cls(
|
||||
{
|
||||
WindDirection.CALM: -1,
|
||||
WindDirection.N: 0,
|
||||
WindDirection.NE: 45,
|
||||
WindDirection.E: 90,
|
||||
WindDirection.SE: 135,
|
||||
WindDirection.S: 180,
|
||||
WindDirection.SW: 225,
|
||||
WindDirection.W: 270,
|
||||
WindDirection.NW: 315,
|
||||
}[direction]
|
||||
)
|
||||
|
||||
|
||||
class WeatherValue(Model):
|
||||
date: datetime.datetime
|
||||
sky: Sky
|
||||
temperature: list[int]
|
||||
wind_speed: int
|
||||
wind_gust: int
|
||||
wind_direction: WindDirection
|
||||
wind_direction: float
|
||||
precipitation: float
|
||||
pressure: list[int]
|
||||
humidity: int
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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:
|
||||
@@ -15,8 +16,47 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
|
||||
temperature=[],
|
||||
wind_speed=0,
|
||||
wind_gust=0,
|
||||
wind_direction=WindDirection.CALM,
|
||||
wind_direction=WindDirectionDeg(-1),
|
||||
precipitation=0,
|
||||
pressure=[],
|
||||
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:
|
||||
SECOND = 1
|
||||
MINUTE = 60 * SECOND
|
||||
HOUR = 60 * MINUTE
|
||||
DAY = 24 * HOUR
|
||||
|
||||
|
||||
root_path = Path(__file__).parent.parent
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.2"
|
||||
|
||||
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 "Прямые трансляции"
|
||||
673
poetry.lock
generated
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "gallery"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
description = ""
|
||||
authors = ["shmyga <shmyga.z@gmail.com>"]
|
||||
readme = "README.md"
|
||||
@@ -12,11 +12,12 @@ aiohttp = "^3.9.5"
|
||||
beautifulsoup4 = "^4.12.3"
|
||||
dateparser = "^1.2.0"
|
||||
pydantic = "^2.8.2"
|
||||
aiocache = {extras = ["redis"], version = "^0.12.2"}
|
||||
aiocache = { extras = ["redis"], version = "^0.12.2" }
|
||||
|
||||
[tool.poetry.group.app.dependencies]
|
||||
fastapi = "^0.111.1"
|
||||
jinja2 = "^3.1.4"
|
||||
babel = "^2.18.0"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
pytest = "^8.3.1"
|
||||
@@ -34,6 +35,12 @@ build-backend = "poetry.core.masonry.api"
|
||||
[tool.poetry.scripts]
|
||||
gallery = "gallery.main:run"
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-p no:warnings"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
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
|
||||
45
scripts/docker-action
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/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:latest
|
||||
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
|
||||
docker push $DOCKER_ROOT/$PROJECT_NAME:latest
|
||||
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
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
poetry run pylint gallery
|
||||
TARGET="gallery"
|
||||
|
||||
poetry run pylint $TARGET
|
||||
poetry run isort $TARGET --check-only
|
||||
poetry run black $TARGET -q --check --diff
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
set -e
|
||||
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
|
||||
15
scripts/setup
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
PYTHON_VERSION=3.12
|
||||
poetry env use ${PYTHON_VERSION}
|
||||
poetry install
|
||||
|
||||
cd static || exit
|
||||
|
||||
if [[ -f $HOME/.nvm/nvm.sh ]]; then
|
||||
source "$HOME/.nvm/nvm.sh"
|
||||
nvm use
|
||||
fi
|
||||
npm ci
|
||||
19
scripts/version
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 [version]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -z "$(git status -s)" ]]; then
|
||||
echo "Uncomitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
poetry version $1
|
||||
(cd static && npm version $1 --allow-same-version)
|
||||
git add .
|
||||
git commit -m "ci(version): $1"
|
||||
git tag $1
|
||||
2
static/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
1
static/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
24
|
||||
1241
static/package-lock.json
generated
Normal file
21
static/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "gallery",
|
||||
"version": "0.3.0",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"author": "shmyga <shmyga.z@gmail.com>",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"flag-icons": "^7.5.0",
|
||||
"sass": "^1.99.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
17
static/src/components.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
class AppLinkElement extends HTMLElement {
|
||||
static observedAttributes = ["icon", "href"];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = `
|
||||
<a href="${this.getAttribute("href")}"
|
||||
class="d-flex align-items-center text-body text-decoration-none">
|
||||
<span class="fs-4">
|
||||
<span class="bi bi-${this.getAttribute("icon")} me-1"></span>
|
||||
<span>${this.textContent}</span>
|
||||
</span>
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("app-link", AppLinkElement);
|
||||
10
static/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import * as bootstrap from "bootstrap";
|
||||
import "./components";
|
||||
import "./language";
|
||||
import "./main.scss";
|
||||
import "./theme";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
const tooltipList = [...tooltipTriggerList].map((tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||
});
|
||||
64
static/src/language.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
(() => {
|
||||
const getStoredLanguage = () => {
|
||||
const m = document.cookie.match(/language=(\w+)/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
const setStoredLanguage = (language: string) => (document.cookie = `language=${language}; max-age=34560000; path=/`);
|
||||
|
||||
const getPreferredLanguage = () => {
|
||||
const storedLanguage = getStoredLanguage();
|
||||
if (storedLanguage) {
|
||||
return storedLanguage;
|
||||
}
|
||||
const result = window.navigator.language.split("-")[0];
|
||||
return ["en", "ru"].includes(result) ? result : "en";
|
||||
};
|
||||
|
||||
const setLanguage = (language: string) => {};
|
||||
|
||||
setLanguage(getPreferredLanguage());
|
||||
|
||||
const showActiveLanguage = (language: string, focus = false) => {
|
||||
const languageSwitcher = document.querySelector("#bd-language");
|
||||
|
||||
if (!languageSwitcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
const languageSwitcherText = document.querySelector("#bd-language-text");
|
||||
const activeLanguageIcon = document.querySelector(".language-icon-active");
|
||||
const btnToActive = document.querySelector(`[data-bs-language-value="${language}"]`);
|
||||
const activeLanguageIconClass = btnToActive.querySelector(".fi").className.match(/fi-[\w-]+/)[0];
|
||||
|
||||
document.querySelectorAll("[data-bs-language-value]").forEach((element) => {
|
||||
element.classList.remove("active");
|
||||
element.setAttribute("aria-pressed", "false");
|
||||
});
|
||||
|
||||
btnToActive.classList.add("active");
|
||||
btnToActive.setAttribute("aria-pressed", "true");
|
||||
const classesToRemove = Array.from(activeLanguageIcon.classList).filter((className) => className.startsWith("fi-"));
|
||||
activeLanguageIcon.classList.remove(...classesToRemove);
|
||||
activeLanguageIcon.classList.add(activeLanguageIconClass);
|
||||
const languageSwitcherLabel = `${languageSwitcherText.textContent} (${btnToActive.dataset.bsLanguageValue})`;
|
||||
languageSwitcher.setAttribute("aria-label", languageSwitcherLabel);
|
||||
|
||||
if (focus) {
|
||||
languageSwitcher.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
showActiveLanguage(getPreferredLanguage());
|
||||
|
||||
document.querySelectorAll("[data-bs-language-value]").forEach((toggle) => {
|
||||
toggle.addEventListener("click", () => {
|
||||
const language = toggle.getAttribute("data-bs-language-value") || "";
|
||||
setStoredLanguage(language);
|
||||
setLanguage(language);
|
||||
showActiveLanguage(language, true);
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
45
static/src/lib/bootstrap-icons.scss
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
.icon-link {
|
||||
vertical-align: -0.25rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
vertical-align: -0.125em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
|
||||
mask-size: contain;
|
||||
mask-position: 50%;
|
||||
mask-repeat: no-repeat;
|
||||
background-color: currentColor;
|
||||
|
||||
&.bi-circle-half {
|
||||
mask-image: url(bootstrap-icons/icons/circle-half.svg);
|
||||
}
|
||||
&.bi-moon-stars-fill {
|
||||
mask-image: url(bootstrap-icons/icons/moon-stars-fill.svg);
|
||||
}
|
||||
&.bi-brightness-high {
|
||||
mask-image: url(bootstrap-icons/icons/brightness-high.svg);
|
||||
}
|
||||
&.bi-gear {
|
||||
mask-image: url(bootstrap-icons/icons/gear.svg);
|
||||
}
|
||||
&.bi-sun-fill {
|
||||
mask-image: url(bootstrap-icons/icons/sun-fill.svg);
|
||||
}
|
||||
&.bi-tv {
|
||||
mask-image: url(bootstrap-icons/icons/tv.svg);
|
||||
}
|
||||
&.bi-arrow-left-square {
|
||||
mask-image: url(bootstrap-icons/icons/arrow-left-square.svg);
|
||||
}
|
||||
&.bi-arrow-right-square {
|
||||
mask-image: url(bootstrap-icons/icons/arrow-right-square.svg);
|
||||
}
|
||||
&.bi-arrow-up-square {
|
||||
mask-image: url(bootstrap-icons/icons/arrow-up-square.svg);
|
||||
}
|
||||
}
|
||||
52
static/src/lib/bootstrap.scss
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
@import "bootstrap/scss/mixins/banner";
|
||||
@include bsBanner("");
|
||||
|
||||
|
||||
// scss-docs-start import-stack
|
||||
// Configuration
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/utilities";
|
||||
|
||||
// Layout & components
|
||||
@import "bootstrap/scss/root";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/type";
|
||||
//@import "bootstrap/scss/images";
|
||||
//@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
@import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
//@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
//@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/nav";
|
||||
@import "bootstrap/scss/navbar";
|
||||
//@import "bootstrap/scss/card";
|
||||
//@import "bootstrap/scss/accordion";
|
||||
//@import "bootstrap/scss/breadcrumb";
|
||||
//@import "bootstrap/scss/pagination";
|
||||
//@import "bootstrap/scss/badge";
|
||||
//@import "bootstrap/scss/alert";
|
||||
//@import "bootstrap/scss/progress";
|
||||
@import "bootstrap/scss/list-group";
|
||||
//@import "bootstrap/scss/close";
|
||||
//@import "bootstrap/scss/toasts";
|
||||
//@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/tooltip";
|
||||
//@import "bootstrap/scss/popover";
|
||||
//@import "bootstrap/scss/carousel";
|
||||
//@import "bootstrap/scss/spinners";
|
||||
//@import "bootstrap/scss/offcanvas";
|
||||
//@import "bootstrap/scss/placeholders";
|
||||
|
||||
// Helpers
|
||||
@import "bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
// scss-docs-end import-stack
|
||||
17
static/src/lib/flag-icons.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
@use "flag-icons/sass/flag-icons" with (
|
||||
$flag-icons-path: "flag-icons/flags",
|
||||
$flag-icons-included-countries: (
|
||||
"gb",
|
||||
"ru",
|
||||
"by",
|
||||
"ua",
|
||||
"kz",
|
||||
)
|
||||
);
|
||||
|
||||
.fir {
|
||||
@extend .fis;
|
||||
border-radius: 50%;
|
||||
height: 1em;
|
||||
border: 1px solid gray;
|
||||
}
|
||||
22
static/src/lib/weather-icons/build.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
LIST_ITEM = Template(
|
||||
""".$name {
|
||||
mask-image: url(./svg/$name.svg);
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def generate():
|
||||
work_dir = Path(__file__).parent
|
||||
data = ""
|
||||
for item in (work_dir / "svg").glob("*.svg"):
|
||||
data += LIST_ITEM.substitute({"name": item.stem})
|
||||
target = work_dir / "classes-list.scss"
|
||||
target.write_text(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate()
|
||||