7 Commits

Author SHA1 Message Date
c233b020fc ci(version): 0.2.1 2026-04-23 21:24:28 +03:00
869a8ae79f fix(gismeteo): fix optional subdistrict in gismeteo reponse 2026-04-23 21:24:13 +03:00
4c3b3aeafc ci(version): 0.2.0 2026-04-23 21:06:15 +03:00
d1592150fd build(scipts): add version script 2026-04-23 21:06:06 +03:00
9351b9f53a feat(easel): add localization 2026-04-23 15:47:16 +03:00
ecb574e286 feat(easel): add bootstrap 2026-04-22 21:42:39 +03:00
94870a5c86 feat(weather): add weather location search 2026-04-22 12:58:56 +03:00
76 changed files with 2603 additions and 642 deletions

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
*.pyc *.pyc
*.mo
.pytest_cache .pytest_cache
.venv .venv
#.vscode #.vscode
static/node_modules
static/dist

View File

@@ -7,17 +7,27 @@ COPY pyproject.toml poetry.lock README.md ./
RUN poetry config virtualenvs.in-project true RUN poetry config virtualenvs.in-project true
RUN poetry install --with app --no-root RUN poetry install --with app --no-root
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 FROM python:3.12-slim
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app WORKDIR /app
RUN apt update && \ RUN apt update && \
apt install -y locales && \ apt install -y locales gettext && \
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \ sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales dpkg-reconfigure --frontend=noninteractive locales
ENV LANG=ru_RU.UTF-8 ENV LANG=ru_RU.UTF-8
ENV LC_ALL=ru_RU.UTF-8 ENV LC_ALL=ru_RU.UTF-8
ENV TZ="Europe/Moscow" ENV TZ="Europe/Moscow"
COPY --from=builder /app ./ COPY --from=builder /app ./
COPY --from=node-builder /app/dist ./static/dist
COPY gallery gallery/ COPY gallery gallery/
RUN cd gallery/easel/route/view/locales/ru/LC_MESSAGES && msgfmt messages.po
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"] CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]

View File

@@ -1,9 +1,9 @@
# Gallery # API Gallery
## View ## View
https://api.shmyga.ru https://api.shmyga.ru
## API Docs ## Swagger
https://api.shmyga.ru/docs https://api.shmyga.ru/docs

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env bash
docker compose -f docker-compose-develop.yaml up --build --watch

View File

@@ -33,12 +33,14 @@
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "uvicorn", "module": "uvicorn",
"args": [ "args": ["gallery.main:app", "--reload", "--log-config", "gallery/logging.yaml"],
"gallery.main:app", },
"--reload", {
"--log-config", "name": "gallery:static",
"gallery/logging.yaml", "cwd": "${workspaceFolder}/static",
], "request": "launch",
"type": "node-terminal",
"command": "npm run dev",
}, },
], ],
}, },

View File

@@ -1,10 +1,13 @@
import locale as _locale import locale as _locale
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from gallery.sketch.bundle import ApiBundle from gallery.sketch.bundle import ApiBundle
from gallery.util import root_path
from .route import api, doc, view from .route import api, doc
from .route.view import router as view_router
DEFAULT_LOCALE = "ru_RU.UTF-8" DEFAULT_LOCALE = "ru_RU.UTF-8"
@@ -17,7 +20,8 @@ def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI
redoc_url=None, redoc_url=None,
) )
app.state.api = api_bundle app.state.api = api_bundle
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
doc.mount(app) doc.mount(app)
api.mount(app) api.mount(app)
view.mount(app) app.include_router(view_router)
return app return app

View File

@@ -7,12 +7,12 @@ from gallery.sketch.schedule.model import ChannelId, Schedule
def mount(app: FastAPI): def mount(app: FastAPI):
@app.get("/api/schedule/channels") @app.get("/api/schedule/channels", tags=["API"])
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]: async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
schedule_api = request.app.state.api.schedule schedule_api = request.app.state.api.schedule
return await schedule_api.get_channels() return await schedule_api.get_channels()
@app.get("/api/schedule/{channel}/{date}") @app.get("/api/schedule/{channel}/{date}", tags=["API"])
async def get_api_schedule_channel_schedule( async def get_api_schedule_channel_schedule(
request: AppRequest, channel: str, date: datetime.date request: AppRequest, channel: str, date: datetime.date
) -> Schedule: ) -> Schedule:

View File

@@ -3,23 +3,25 @@ import datetime
from fastapi import FastAPI from fastapi import FastAPI
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.weather.model import WeatherResponse from gallery.sketch.weather.model import Location, WeatherResponse
def mount(app: FastAPI): def mount(app: FastAPI):
@app.get("/api/weather/locations") @app.get("/api/weather/locations", tags=["API"])
async def get_api_weather_locations(request: AppRequest) -> list[str]: async def get_api_weather_locations(
request: AppRequest, query: str
) -> list[Location]:
weather_api = request.app.state.api.weather weather_api = request.app.state.api.weather
return await weather_api.get_locations() return await weather_api.find_locations(query)
@app.get("/api/weather/{location}/day/{date}") @app.get("/api/weather/{location}/day/{date}", tags=["API"])
async def get_api_weather_day( async def get_api_weather_day(
request: AppRequest, location: str, date: datetime.date request: AppRequest, location: str, date: datetime.date
) -> WeatherResponse: ) -> WeatherResponse:
weather_api = request.app.state.api.weather weather_api = request.app.state.api.weather
return await weather_api.get_day(location, date) return await weather_api.get_day(location, date)
@app.get("/api/weather/{location}/days/{days}") @app.get("/api/weather/{location}/days/{days}", tags=["API"])
async def get_api_weather_days( async def get_api_weather_days(
request: AppRequest, location: str, days: int request: AppRequest, location: str, days: int
) -> WeatherResponse: ) -> WeatherResponse:

View File

@@ -1,9 +1,11 @@
from fastapi import FastAPI from fastapi import APIRouter, Depends
from . import common, schedule, weather 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)])
def mount(app: FastAPI): router.include_router(common_router)
common.mount(app) router.include_router(weather_router)
weather.mount(app) router.include_router(schedule_router)
schedule.mount(app)

View File

@@ -1,37 +1,41 @@
from pathlib import Path from pathlib import Path
from typing import NamedTuple from typing import NamedTuple
from fastapi import FastAPI, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from gallery.version import __version__ from gallery.version import __version__
from ..translation import _
class Section(NamedTuple): class Section(NamedTuple):
link: str link: str
title: str title: str
icon: str
SECTIONS = [ SECTIONS = [
Section("weather", "Погода"), Section("weather", "Weather", "brightness-high"),
Section("schedule", "Телепрограмма"), Section("schedule", "TV program", "tv"),
] ]
base_dir = Path(__file__).parent
def mount(app: FastAPI): router = APIRouter()
base_dir = Path(__file__).parent
app.mount("/static/common", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(directory=base_dir / "templates")
@app.get("/", response_class=HTMLResponse) templates = Jinja2Templates(directory=base_dir / "templates")
async def get_section_list(request: Request): templates.env.globals.update({"_": _})
return templates.TemplateResponse(
request=request,
name="root_index.html", @router.get("/", response_class=HTMLResponse)
context={ async def get_section_list(request: Request):
"version": __version__, return templates.TemplateResponse(
"sections": SECTIONS, request=request,
}, name="root_index.html",
) context={
"version": __version__,
"sections": SECTIONS,
},
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,114 +0,0 @@
/*
base
*/
body {
font-size: 1.5rem;
}
h3 {
margin: 0.5rem 0;
}
/*
table
*/
table {
table-layout: fixed;
border-collapse: collapse;
}
table,
th,
td {
text-align: center;
}
td {
padding: 0.1rem 0.4rem;
}
/*
a.button
*/
a.button {
text-decoration: none;
color: inherit;
}
.button.disabled {
pointer-events: none;
cursor: default;
color: gray;
filter: grayscale(100%);
}
/*
app
*/
.app-container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
}
.app-menu {
display: flex;
flex-direction: column;
margin: 0.5rem;
}
.app-content {
display: flex;
flex: 1;
flex-direction: column;
}
.app-header {
width: 100%;
display: flex;
flex-direction: row;
gap: 0.5rem;
justify-content: center;
}
.app-link-home > * {
width: 2rem;
height: 2rem;
background-image: url("/static/common/gallery.png");
background-size: contain;
}
.icon {
display: inline-block;
width: 2rem;
height: 2rem;
background-size: contain;
}
ul.app-list {
list-style: none;
padding-left: 0;
width: 30rem;
margin: auto;
}
ul.app-list > li {
border: 1px solid lightgrey;
}
ul.app-list > li > a {
display: flex;
gap: 0.25rem;
padding: 0.5rem 2rem;
text-decoration: none;
color: inherit;
}
ul.app-list > li:hover {
border-color: rgb(125, 125, 255);
}
ul.app-list > li:hover > a {
color: rgb(125, 125, 255);
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{request.state.language}}">
<head> <head>
{% block head %} {% block head %}
@@ -10,29 +10,115 @@
content="ie=edge"> content="ie=edge">
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" <link rel="stylesheet"
href="/static/common/style.css?v={{version}}"> href="/static/gallery.css?v={{version}}">
<script type="module"
src="/static/gallery.es.js?v={{version}}"></script>
<link rel="icon" <link rel="icon"
href="/static/common/favicon.ico?v={{version}}" href="/favicon.ico?v={{version}}"
type="image/x-icon"> type="image/x-icon">
{% endblock %} {% endblock %}
</head> </head>
<body class="app-container"> <body>
<div class="app-menu"> <div class="app col-lg-8 mx-auto p-3 py-md-5">
<a class="app-link-home" <header class="d-flex align-items-center pb-3 mb-5 border-bottom">
href="/"> <app-link href="/"
<div></div> icon="gear">API Gallery</app-link>
</a> {% block header %}{% endblock %}
</div> <ul class="navbar-nav flex-row flex-wrap ms-md-auto">
<div class="app-content"> <li class="nav-item dropdown">
<h3 class="app-header"> <button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
{% block header %}{% endblock %}</span> id="bd-language"
</h3> type="button"
{% block content %}{% endblock %} aria-expanded="false"
<div class="app-footer"> data-bs-toggle="dropdown"
{% block footer %}{% endblock %} aria-label="Select language (default)">
</div> <span class="me-2 language-icon-active">🇬🇧</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="me-2 language-icon">🇬🇧</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="me-2 language-icon">🇷🇺</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)">
<i class="bi me-2 opacity-50 theme-icon-active"></i>
<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">
<i class="bi bi-sun-fill me-2 opacity-50 theme-icon"></i>
Light
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="dark"
aria-pressed="false">
<i class="bi bi-moon-stars-fill me-2 opacity-50 theme-icon"></i>
Dark
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center active"
data-bs-theme-value="auto"
aria-pressed="true">
<i class="bi bi-circle-half me-2 opacity-50 theme-icon"></i>
Auto
</button>
</li>
</ul>
</li>
</ul>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="pt-5 my-5 text-muted border-top">
Created by shmyga &middot; &copy; 2026
</footer>
</div> </div>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const widget = params.get('widget') || window.location.hostname.startsWith('weather');
if (widget) {
document.body.classList.add('widget');
}
}());
</script>
</body> </body>
</html> </html>

View File

@@ -1,21 +1,26 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Информация{% endblock %} {% block title %}Index{% endblock %}
{% block head %} {% block head %}
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block header %}Информация{% endblock %}
{% block content %} {% block content %}
<ul class="app-list"> <h1>View</h1>
<div class="list-group mb-5">
{% for section in sections %} {% for section in sections %}
<li> <a href="{{section.link}}"
<a href="{{section.link}}"> class="list-group-item list-group-item-action px-4">
<span class="icon" <app-link href="{{section.link}}"
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span> icon="{{section.icon}}">
<span>{{section.title}}</span> {{_(section.title)}}
</a> </app-link>
</li> </a>
{% endfor %} {% endfor %}
</ul> </div>
<hr class="col-3 col-md-2 mb-5">
<h1>Docs</h1>
<a href="/docs"
target="_blank">
<h4>Swagger</h4>
</a>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,15 @@
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 "Weather"
msgstr "Погода"
msgid "TV program"
msgstr "Телепрограмма"

View File

@@ -1,10 +1,8 @@
import asyncio
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
@@ -12,71 +10,76 @@ from gallery.sketch.schedule.catalog import BUNDLE
from gallery.version import __version__ from gallery.version import __version__
from ..common.util import TagType, TagUtil from ..common.util import TagType, TagUtil
from ..translation import _
from .filters import timedelta_format from .filters import timedelta_format
base_dir = Path(__file__).parent
templates = Jinja2Templates(
directory=[
base_dir.parent / "common/templates",
base_dir / "templates",
]
)
templates.env.globals.update({"_": _})
templates.env.filters["timedelta_format"] = timedelta_format
def mount(app: FastAPI): router = APIRouter()
base_dir = Path(__file__).parent
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates( @router.get("/schedule", response_class=HTMLResponse)
directory=[ async def get_schedule_list(request: AppRequest):
base_dir.parent / "common/templates", schedule_api = request.app.state.api.schedule
base_dir / "templates", 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,
},
) )
templates.env.filters["timedelta_format"] = timedelta_format
@app.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: AppRequest):
schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels()
channels_data = BUNDLE.select_items(channels)
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"version": __version__,
"channels": channels_data,
},
)
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse) @router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False): async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
tag_value = TagUtil.parse_tag(tag) tag_value = TagUtil.parse_tag(tag)
schedule_api = request.app.state.api.schedule schedule_api = request.app.state.api.schedule
results = await schedule_api.get_all_schedules(tag_value.date) results = await schedule_api.get_all_schedules(tag_value.date)
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="schedule.html", name="schedule.html",
context={ context={
"version": __version__, "version": __version__,
"tag_util": TagUtil, "tag_util": TagUtil,
"datetime": datetime, "datetime": datetime,
"response": results[0], "response": results[0],
"responses": results, "responses": results,
"live": live, "live": live,
}, },
) )
@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) @router.get("/schedule/{channel}", response_class=RedirectResponse)
async def get_channel_tag(request: AppRequest, channel: str, tag: str): async def get_channel_default(channel: str):
tag_value = TagUtil.parse_tag(tag) return RedirectResponse(f"{channel}/tag/today")
schedule_api = request.app.state.api.schedule
if tag_value.type == TagType.DAY:
response = await schedule_api.get_channel_schedule(channel, tag_value.date) @router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
else: async def get_channel_tag(request: AppRequest, channel: str, tag: str):
raise ValueError(tag) tag_value = TagUtil.parse_tag(tag)
return templates.TemplateResponse( schedule_api = request.app.state.api.schedule
request=request, if tag_value.type == TagType.DAY:
name="channel.html", response = await schedule_api.get_channel_schedule(channel, tag_value.date)
context={ else:
"version": __version__, raise ValueError(tag)
"tag_util": TagUtil, return templates.TemplateResponse(
"datetime": datetime, request=request,
"response": response, name="channel.html",
}, context={
) "version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,25 +0,0 @@
table.schedule-table {
width: 60rem;
margin: auto;
table-layout: auto
}
table.schedule-table tr {
border-bottom: 1px solid lightgray;
}
table.schedule-table td {
text-align: left;
}
table.schedule-table tr.live {
font-weight: bold;
}
table.schedule-table .title {
margin-top: 0.5rem;
font-style: italic;
font-weight: bold;
font-size: 120%;
background-color: lightgray;
}

View File

@@ -2,27 +2,24 @@
{% block title %} {% block title %}
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}} Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
{% endblock %} {% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %} {% block header %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <span class="fs-4 text-body ms-2 me-2">/</span>
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> <app-link href="/schedule"
<a class="button" icon="tv">{{_("TV program")}}</app-link>
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>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<table class="schedule-table"> <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}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
</h4>
<table class="table">
<thead> <thead>
<tr> <tr>
<td></td> <td></td>
@@ -32,7 +29,7 @@
</thead> </thead>
<tbody> <tbody>
{% for value in response.values %} {% for value in response.values %}
<tr class="{{'live' if value.live else ''}}"> <tr class="{{'table-success' if value.live else ''}}">
<td>{{value.start.strftime('%H:%M')}}</td> <td>{{value.start.strftime('%H:%M')}}</td>
<td>{{(value.end - value.start) | timedelta_format}}</td> <td>{{(value.end - value.start) | timedelta_format}}</td>
<td>{{value.label}}</td> <td>{{value.label}}</td>

View File

@@ -1,21 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}ТВ{% endblock %} {% block title %}{{_("TV program")}}{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %}Телепрограмма{% endblock %}
{% block content %} {% block content %}
<ul class="app-list"> <h1>{{_("TV program")}}</h1>
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li> <div class="list-group mb-5">
{% for channel in channels %} <a href="schedule/tag/today"
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li> class="list-group-item list-group-item-action px-4">
{% endfor %} <span class="fw-bold">Все</span>
</ul> </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 %} {% endblock %}

View File

@@ -1,29 +1,26 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}} {{'Прямые трансляции' if live else _("TV program")}} | {{response.date.strftime('%a, %d %B %Y')}}
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %} {% endblock %}
{% block header %} {% block header %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <span class="fs-4 text-body ms-2 me-2">/</span>
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> <app-link href="/schedule"
<a class="button" icon="tv">{{_("TV program")}}</app-link>
href="..">⬆️</a>
<span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endblock %} {% endblock %}
{% block content %} {% 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>{{'Прямые трансляции' 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>
</h4>
<div> <div>
<table class="schedule-table {{'live' if live else ''}}"> <table class="table">
<thead> <thead>
<tr> <tr>
<td></td> <td></td>
@@ -35,7 +32,7 @@
{% for response in responses %} {% for response in responses %}
{% set values = (response.values|selectattr('live') if live else response.values)|list %} {% set values = (response.values|selectattr('live') if live else response.values)|list %}
{% if values|length > 0 %} {% if values|length > 0 %}
<tr class="title"> <tr class="table-primary fs-4">
<td colspan="3"> <td colspan="3">
<div>{{response.channel.name}}</div> <div>{{response.channel.name}}</div>
</td> </td>
@@ -43,7 +40,7 @@
<td></td> <td></td>
</tr> </tr>
{% for value in values %} {% for value in values %}
<tr class="{{'live' if not live and value.live else ''}}"> <tr class="{{'table-success' if not live and value.live else ''}}">
<td>{{value.start.strftime('%H:%M')}}</td> <td>{{value.start.strftime('%H:%M')}}</td>
<td>{{(value.end - value.start) | timedelta_format}}</td> <td>{{(value.end - value.start) | timedelta_format}}</td>
<td>{{value.label}}</td> <td>{{value.label}}</td>

View File

@@ -0,0 +1,34 @@
import gettext
from contextvars import ContextVar
from pathlib import Path
from fastapi import Cookie, Header, Request
_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=Path(__file__).parent / "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)

View File

@@ -1,93 +1,87 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import APIRouter, FastAPI
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.weather.catalog import BUNDLE
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA
from gallery.sketch.weather.model import WeatherResponse from gallery.sketch.weather.model import WeatherResponse
from gallery.version import __version__ from gallery.version import __version__
from ..common.util import TagType, TagUtil from ..common.util import TagType, TagUtil
from ..translation import _
from .filters import cloudness_icon, wind_direction_icon from .filters import cloudness_icon, wind_direction_icon
def mount(app: FastAPI): base_dir = Path(__file__).parent
base_dir = Path(__file__).parent templates = Jinja2Templates(
app.mount("/static/weather", StaticFiles(directory=base_dir / "static")) directory=[
templates = Jinja2Templates( base_dir.parent / "common/templates",
directory=[ base_dir / "templates",
base_dir.parent / "common/templates", ]
base_dir / "templates", )
] templates.env.globals.update({"_": _})
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse(
request=request,
name="weather.html",
context={
"version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
) )
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
def build_weather_response(request: AppRequest, response: WeatherResponse): router = APIRouter()
return templates.TemplateResponse(
request=request,
name="weather.html",
context={
"version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"response": response,
},
)
@app.get("/weather", response_class=HTMLResponse) @router.get("/weather", response_class=HTMLResponse)
async def get_weather_list(request: AppRequest): async def get_weather_index(request: AppRequest, query: str | None = None):
weather_api = request.app.state.api.weather weather_api = request.app.state.api.weather
locations = await weather_api.get_locations() locations = (await weather_api.find_locations(query)) if query else []
locations_data = BUNDLE.select_items(locations) return templates.TemplateResponse(
return templates.TemplateResponse( request=request,
request=request, name="index.html",
name="index.html", context={
context={ "version": __version__,
"version": __version__, "locations": locations,
"locations": locations_data, },
}, )
)
@app.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str):
return RedirectResponse(f"{location}/tag/today")
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse) @router.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_day_mock(request: AppRequest): async def get_weather_default(location: str):
response = WEATHER_MOCK_DATA.get_response("day") return RedirectResponse(f"{location}/tag/today")
return build_weather_response(request, response)
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse)
async def get_weather_days_mock(request: AppRequest):
response = WEATHER_MOCK_DATA.get_response("days")
return build_weather_response(request, response)
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse) @router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: AppRequest, location: str, date: datetime.date): async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
weather_api = request.app.state.api.weather weather_api = request.app.state.api.weather
response = await weather_api.get_day(location, date) response = await weather_api.get_day(location, date)
return build_weather_response(request, response) return build_weather_response(request, response)
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, location: str, days: int):
weather_api = request.app.state.api.weather
response = await weather_api.get_days(location, days)
return build_weather_response(request, response)
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse) @router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, location: str, tag: str): async def get_weather_days(request: AppRequest, location: str, days: int):
tag_value = TagUtil.parse_tag(tag) weather_api = request.app.state.api.weather
weather_api = request.app.state.api.weather response = await weather_api.get_days(location, days)
if tag_value.type == TagType.DAY: return build_weather_response(request, response)
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) @router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
else: async def get_weather_tag(request: AppRequest, location: str, tag: str):
raise ValueError(tag) tag_value = TagUtil.parse_tag(tag)
return build_weather_response(request, response) 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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -1,20 +1,73 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Погода{% endblock %} {% block title %}Weather{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %}Погода{% endblock %}
{% block content %} {% block content %}
<ul class="app-list"> <h1>Weather</h1>
<form action=""
method="get"
class="mb-4">
<div class="input-group mb-3">
<input type="text"
class="form-control"
id="query"
name="query"
placeholder="Enter the city name">
<button class="btn btn-primary"
type="submit">Search</button>
</div>
</form>
<ul id="locations"
class="list-group mb-5">
{% for location in locations %} {% for location in locations %}
<li><a href="weather/{{location.id}}">{{location.name}}</a></li> <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="text-primary">{{location.name}}</span>
<span class="small ms-1 text-secondary">
{{location.country}}, {{location.district}}, {{location.subdistrict}}
</span>
<span></span>
</a>
{% endfor %} {% endfor %}
</ul> </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();">&#x2715;</span>
`;
container.appendChild(element);
}
}
document.saveLocation = (location) => {
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
locations[location.id] = location.name;
window.localStorage.setItem('locations', JSON.stringify(locations));
}
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 %} {% endblock %}

View File

@@ -1,32 +1,29 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %} {% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %} {% block header %}
{% if response.period == 'day' %} <span class="fs-4 text-body ms-2 me-2">/</span>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <app-link href="/weather" icon="brightness-high">{{_("Weather")}}</app-link>
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 %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div> <h4>
<table style="margin: auto;"> {% 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 %}
</h4>
<div class="table-responsive">
<table class="table table-weather table-borderless table-compact text-center w-auto"
style="font-size: 130%;">
<tbody> <tbody>
<!-- date --> <!-- date -->
<tr> <tr>

View File

@@ -1,13 +1,13 @@
import datetime import datetime
import json
import logging import logging
from typing import Any, Dict, List from typing import Any
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from gallery.sketch.source import ApiSource from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import LocationId from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from . import datehelp from . import datehelp
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
@@ -34,7 +34,7 @@ class GismeteoApi(WeatherApi):
) )
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse: def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = [] result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data) location = LOCATION_PARSER.parse_location(data)
widget = ONE_DAY_PARSER.parse_widget(soup) widget = ONE_DAY_PARSER.parse_widget(soup)
@@ -52,7 +52,7 @@ class GismeteoApi(WeatherApi):
) )
def _parse_manydays(self, data: str) -> WeatherResponse: def _parse_manydays(self, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = [] result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data) location = LOCATION_PARSER.parse_location(data)
widget = DAYS_PARSER.parse_widget(soup) widget = DAYS_PARSER.parse_widget(soup)
@@ -69,11 +69,33 @@ class GismeteoApi(WeatherApi):
values=values, values=values,
) )
async def get_locations(self) -> list[str]: async def find_locations(self, query: str) -> list[Location]:
return [ geo = "ru"
LocationId.OREL, latitude = 52.968498
LocationId.ZMIYEVKA, longitude = 36.0695
] data = json.loads(
await self.SOURCE.request(
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
)
)
result = []
for item in data["data"]:
result.append(
Location(
id=f"{item['slug']}-{item['id']}",
name=item["translations"]["kk"]["city"]["name"],
lat=item["coordinates"]["latitude"],
lon=item["coordinates"]["longitude"],
country=item["translations"]["kk"]["country"]["name"],
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: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}") data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
GISMETEO_MOCK_DATA = MockData(Path(__file__).parent / "data")

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
MATCHTV_MOCK_DATA = MockData(Path(__file__).parent / "data")

View File

@@ -5,8 +5,7 @@ from collections import defaultdict
from aiocache import cached from aiocache import cached
from gallery.sketch.weather.api import WeatherApi from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import BUNDLE, LocationId from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit from gallery.util import TimeUnit
@@ -20,11 +19,9 @@ class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather" PROVIDER = "openweather"
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf") SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
async def get_locations(self) -> list[str]: @classmethod
return [ def _parse_location(cls, location_id: str) -> tuple[float, float]:
LocationId.OREL, return tuple(map(float, location_id.split(":", maxsplit=2)))
LocationId.ZMIYEVKA,
]
@cached( @cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast", key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
@@ -32,8 +29,10 @@ class OpenWeatherApi(WeatherApi):
ttl=TimeUnit.DAY, ttl=TimeUnit.DAY,
) )
async def _get_location_forecast(self, location_id: str) -> Forecast: async def _get_location_forecast(self, location_id: str) -> Forecast:
location = BUNDLE.get_item(location_id) return await self.SOURCE.get_forecast(*self._parse_location(location_id))
return await self.SOURCE.get_forecast(location.lat, location.lon)
async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
data: Forecast = await self._get_location_forecast(location_id) data: Forecast = await self._get_location_forecast(location_id)
@@ -42,9 +41,8 @@ class OpenWeatherApi(WeatherApi):
value = FORECAST_ITEM_PARSER.parse(item) value = FORECAST_ITEM_PARSER.parse(item)
if value.date.date() == date: if value.date.date() == date:
values.append(value) values.append(value)
location = BUNDLE.get_item(location_id)
return WeatherResponse( return WeatherResponse(
location=location.name, location=location_id,
date=date, date=date,
period="day", period="day",
values=values, values=values,
@@ -61,9 +59,8 @@ class OpenWeatherApi(WeatherApi):
merge_weather_values(date, values) merge_weather_values(date, values)
for date, values in values_by_date.items() for date, values in values_by_date.items()
] ]
location = BUNDLE.get_item(location_id)
return WeatherResponse( return WeatherResponse(
location=location.name, location=location_id,
date=datetime.date.today(), date=datetime.date.today(),
period="days", period="days",
values=list(sorted(values, key=lambda item: item.date)), values=list(sorted(values, key=lambda item: item.date)),

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
YANDEXTV_MOCK_DATA = MockData(Path(__file__).parent / "data")

View File

@@ -1,17 +0,0 @@
import json
class MockData:
def __init__(self, data_dir) -> None:
self._data_dir = data_dir
def get_text(self, key: str) -> str:
return (self._data_dir / f"{key}").read_text()
def get_html(self, key: str) -> str:
return self.get_text(f"{key}.html")
def get_json(self, key: str) -> dict:
data = json.loads(self.get_text(f"{key}.json"))
return data

View File

@@ -1,12 +1,12 @@
import datetime import datetime
from ..api import Api from ..api import Api
from .model import WeatherResponse from .model import Location, WeatherResponse
class WeatherApi(Api): class WeatherApi(Api):
async def get_locations(self) -> list[str]: async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:

View File

@@ -5,7 +5,7 @@ from aiocache import cached
from gallery.sketch.cached import DEFAULT_CACHE_PRESET, CachedApi from gallery.sketch.cached import DEFAULT_CACHE_PRESET, CachedApi
from .api import WeatherApi from .api import WeatherApi
from .model import WeatherResponse from .model import Location, WeatherResponse
CACHE_PRESET = DEFAULT_CACHE_PRESET CACHE_PRESET = DEFAULT_CACHE_PRESET
@@ -14,11 +14,11 @@ class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
CACHE_KEY = "weather" CACHE_KEY = "weather"
@cached( @cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.locations", key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
**CACHE_PRESET._asdict(), **CACHE_PRESET._asdict(),
) )
async def get_locations(self) -> list[str]: async def find_locations(self, query: str) -> list[Location]:
return await self._api.get_locations() return await self._api.find_locations(query)
@cached( @cached(
key_builder=lambda fun, self, location_id, date: ( key_builder=lambda fun, self, location_id, date: (

View File

@@ -1,31 +0,0 @@
from enum import Enum
from gallery.sketch.catalog import CatalogBundle
from .model import Location
class LocationId(str, Enum):
OREL = "orel-4432"
ZMIYEVKA = "zmiyevka-184640"
def __str__(self) -> str:
return self.value
BUNDLE = CatalogBundle(
[
Location(
id=LocationId.OREL,
name="Орёл",
lat=52.9687747,
lon=36.0694937,
),
Location(
id=LocationId.ZMIYEVKA,
name="Змиёвка",
lat=52.672192,
lon=36.380112,
),
]
)

View File

@@ -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")

View File

@@ -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}]}

View File

@@ -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}]}

View File

@@ -14,6 +14,9 @@ class Location(Model):
name: str name: str
lat: float lat: float
lon: float lon: float
country: str
district: str
subdistrict: str
class Cloudness(str, Enum): class Cloudness(str, Enum):

View File

@@ -1,5 +1,11 @@
from pathlib import Path
class TimeUnit: class TimeUnit:
SECOND = 1 SECOND = 1
MINUTE = 60 * SECOND MINUTE = 60 * SECOND
HOUR = 60 * MINUTE HOUR = 60 * MINUTE
DAY = 24 * HOUR DAY = 24 * HOUR
root_path = Path(__file__).parent.parent

View File

@@ -1 +1 @@
__version__ = "0.1.0" __version__ = "0.2.1"

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "gallery" name = "gallery"
version = "0.1.0" version = "0.2.1"
description = "" description = ""
authors = ["shmyga <shmyga.z@gmail.com>"] authors = ["shmyga <shmyga.z@gmail.com>"]
readme = "README.md" readme = "README.md"

6
scripts/locales Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
cd gallery/easel/route/view/locales/ru/LC_MESSAGES || exit
msgfmt messages.po

19
scripts/version Executable file
View 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
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

1
static/.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

1234
static/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
static/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "gallery",
"version": "0.2.1",
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"author": "shmyga <shmyga.z@gmail.com>",
"license": "ISC",
"description": "",
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"bootstrap-icons": "^1.13.1",
"sass": "^1.99.0"
},
"devDependencies": {
"vite": "^8.0.9"
}
}

17
static/src/components.ts Normal file
View 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">
<i class="bi bi-${this.getAttribute("icon")}"></i>
<span>${this.textContent}</span>
</span>
</a>`;
}
}
customElements.define("app-link", AppLinkElement);

5
static/src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import "./main.scss";
import "bootstrap";
import "./theme";
import "./language";
import "./components";

62
static/src/language.ts Normal file
View File

@@ -0,0 +1,62 @@
(() => {
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 activeLanguageIconContent = btnToActive?.querySelector("span")?.textContent;
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");
activeLanguageIcon.textContent = activeLanguageIconContent;
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();
});
});
});
})();

19
static/src/main.scss Normal file
View File

@@ -0,0 +1,19 @@
@import "bootstrap/scss/bootstrap";
$bootstrap-icons-font-dir: "bootstrap-icons/font/fonts";
@import "bootstrap-icons/font/bootstrap-icons";
@import "./widget.scss";
@import "./weather.scss";
.table.table-compact {
td {
padding: 0.1rem 0.4rem;
}
}
.icon {
display: inline-block;
width: 2rem;
height: 2rem;
background-size: contain;
}

76
static/src/theme.ts Normal file
View File

@@ -0,0 +1,76 @@
(() => {
const getStoredTheme = () => localStorage.getItem("theme");
const setStoredTheme = (theme: string) => localStorage.setItem("theme", theme);
const getPreferredTheme = () => {
const storedTheme = getStoredTheme();
if (storedTheme) {
return storedTheme;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};
const setTheme = (theme: string) => {
if (theme === "auto") {
document.documentElement.setAttribute(
"data-bs-theme",
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
);
} else {
document.documentElement.setAttribute("data-bs-theme", theme);
}
};
setTheme(getPreferredTheme());
const showActiveTheme = (theme: string, focus = false) => {
const themeSwitcher = document.querySelector("#bd-theme");
if (!themeSwitcher) {
return;
}
const themeSwitcherText = document.querySelector("#bd-theme-text");
const activeThemeIcon = document.querySelector(".theme-icon-active");
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`);
const activeThemeIconClass = btnToActive.querySelector("i.bi").className.match(/bi-[\w-]+/)[0];
document.querySelectorAll("[data-bs-theme-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(activeThemeIcon.classList).filter((className) => className.startsWith("bi-"));
activeThemeIcon.classList.remove(...classesToRemove);
activeThemeIcon.classList.add(activeThemeIconClass);
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`;
themeSwitcher.setAttribute("aria-label", themeSwitcherLabel);
if (focus) {
themeSwitcher.focus();
}
};
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
const storedTheme = getStoredTheme();
if (storedTheme !== "light" && storedTheme !== "dark") {
setTheme(getPreferredTheme());
}
});
window.addEventListener("DOMContentLoaded", () => {
showActiveTheme(getPreferredTheme());
document.querySelectorAll("[data-bs-theme-value]").forEach((toggle) => {
toggle.addEventListener("click", () => {
const theme = toggle.getAttribute("data-bs-theme-value") || '';
setStoredTheme(theme);
setTheme(theme);
showActiveTheme(theme, true);
});
});
});
})();

73
static/src/weather.scss Normal file
View File

@@ -0,0 +1,73 @@
.table-weather {
.header {
font-size: 0.9rem;
text-align: left;
padding-top: 0.25rem;
}
.date {
background: rgba(1, 0, 0, 0.1) !important;
}
.date.now {
background: rgba(0, 128, 255, 0.2) !important;
}
.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 !important;
}
.pressure .value {
padding: 0.1rem 0.4rem;
color: blueviolet;
}
.humidity .value {
color: blue;
}
}

17
static/src/widget.scss Normal file
View File

@@ -0,0 +1,17 @@
.widget .app {
padding: 0.5rem !important;
}
.widget header {
display: none !important;
}
.widget main {
display: flex;
flex-direction: column;
align-items: center;
}
.widget footer {
display: none !important;
}

14
static/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import packageJson from "./package.json";
export default defineConfig({
base: "./",
build: {
outDir: "./dist",
lib: {
entry: "./src/index.ts",
name: packageJson.name,
fileName: (format) => `${packageJson.name}.${format}.js`,
},
},
});

0
tests/common/__init__.py Normal file
View File

17
tests/common/mock.py Normal file
View File

@@ -0,0 +1,17 @@
from pathlib import Path
from gallery.sketch.source import ApiSource
class MockSource(ApiSource):
def __init__(self, path: Path, mapping: dict[str, str]):
super().__init__("")
self._path = path
self._mapping = mapping
async def request(self, endpoint: str) -> str:
for pattern, filename in self._mapping.items():
if pattern in endpoint:
return (self._path / filename).read_text()
raise ValueError(endpoint)

View File

@@ -0,0 +1,12 @@
from pathlib import Path
from tests.common.mock import MockSource
GISMETEO_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"today": "today.html",
"10-days": "10-days.html",
"mq/city/q": "mq_city_q.json",
},
)

View File

@@ -0,0 +1,400 @@
{
"meta": { "status": true },
"data": [
{
"id": 4432,
"kind": "M",
"slug": "orel",
"coordinates": { "latitude": 52.968498, "longitude": 36.0695 },
"obsStationId": 11948,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 253, "slug": "oryol-oblast" },
"subdistrict": { "id": 4728, "slug": "urban-district-city-oryol" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 13074,
"kind": "A",
"slug": "orel-yuzhnyy-im-i-s-turgeneva",
"coordinates": { "latitude": 52.935001, "longitude": 36.001671 },
"obsStationId": 11948,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 253, "slug": "oryol-oblast" },
"subdistrict": { "id": 4728, "slug": "urban-district-city-oryol" },
"translations": {
"ru": {
"city": { "name": "Орел / Южный им. И. С. Тургенева", "nameP": "Орел / Южный им. И. С. Тургенева" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
},
"kk": {
"city": { "name": "Орел / Южный им. И. С. Тургенева", "nameP": "Орел / Южный им. И. С. Тургенева" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": { "name": "Орловская область", "nameP": "в Орловской области", "nameR": "Орловской области" },
"subdistrict": {
"name": "городской округ город Орёл",
"nameP": "в городском округе города Орёл",
"nameR": "городского округа города Орёл"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 112316,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 52.0172, "longitude": 30.849199 },
"obsStationId": 12921,
"timeZone": 180,
"country": { "id": 19, "slug": "belarus", "code": "BY" },
"district": { "id": 346, "slug": "gomel-region" },
"subdistrict": { "id": 1828, "slug": "loyev-district" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Лоевский район", "nameP": "в Лоевском районе", "nameR": "Лоевского района" }
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Лоевский район", "nameP": "в Лоевском районе", "nameR": "Лоевского района" }
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 178290,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 58.799999, "longitude": 34.453701 },
"obsStationId": 11657,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 248, "slug": "novgorod-oblast" },
"subdistrict": { "id": 2857, "slug": "municipal-district-khvoyninsky" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Новгородская область",
"nameP": "в Новгородской области",
"nameR": "Новгородской области"
},
"subdistrict": {
"name": "муниципальный округ Хвойнинский",
"nameP": "в муниципальном округе Хвойнинском",
"nameR": "муниципального округа Хвойнинского"
}
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Новгородская область",
"nameP": "в Новгородской области",
"nameR": "Новгородской области"
},
"subdistrict": {
"name": "муниципальный округ Хвойнинский",
"nameP": "в муниципальном округе Хвойнинском",
"nameR": "муниципального округа Хвойнинского"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 112830,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 52.182499, "longitude": 30.4349 },
"obsStationId": 12920,
"timeZone": 180,
"country": { "id": 19, "slug": "belarus", "code": "BY" },
"district": { "id": 346, "slug": "gomel-region" },
"subdistrict": { "id": 1833, "slug": "rechytsa-district" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Речицкий район", "nameP": "в Речицком районе", "nameR": "Речицкого района" }
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Беларусь", "nameP": "в Беларуси", "nameR": "Беларуси" },
"district": { "name": "Гомельская область", "nameP": "в Гомельской области", "nameR": "Гомельской области" },
"subdistrict": { "name": "Речицкий район", "nameP": "в Речицком районе", "nameR": "Речицкого района" }
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 97816,
"kind": "T",
"slug": "orilske",
"coordinates": { "latitude": 49.088799, "longitude": 36.228401 },
"obsStationId": 13147,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 335, "slug": "kharkiv-oblast" },
"subdistrict": { "id": 1646, "slug": "berestyn-district" },
"translations": {
"ru": {
"city": { "name": "Орельское", "nameP": "в Орельском" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": {
"name": "Берестинский район",
"nameP": "в Берестинском районе",
"nameR": "Берестинского района"
}
},
"kk": {
"city": { "name": "Орельское", "nameP": "в Орельском" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": {
"name": "Берестинский район",
"nameP": "в Берестинском районе",
"nameR": "Берестинского района"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 97619,
"kind": "T",
"slug": "orilka",
"coordinates": { "latitude": 48.980499, "longitude": 36.0075 },
"obsStationId": 13147,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 335, "slug": "kharkiv-oblast" },
"subdistrict": { "id": 1649, "slug": "lozivskyi-district" },
"translations": {
"ru": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": { "name": "Лозовский район", "nameP": "в Лозовском районе", "nameR": "Лозовского района" }
},
"kk": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Харьковская область",
"nameP": "в Харьковской области",
"nameR": "Харьковской области"
},
"subdistrict": { "name": "Лозовский район", "nameP": "в Лозовском районе", "nameR": "Лозовского района" }
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 78141,
"kind": "T",
"slug": "orilka",
"coordinates": { "latitude": 48.945999, "longitude": 35.689098 },
"obsStationId": 13158,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 319, "slug": "dnipropetrovsk-oblast" },
"subdistrict": { "id": 1184, "slug": "samarivskyi-district" },
"translations": {
"ru": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Самаровский район",
"nameP": "в Самаровском районе",
"nameR": "Самаровского района"
}
},
"kk": {
"city": { "name": "Орелька", "nameP": "в Орельке" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Самаровский район",
"nameP": "в Самаровском районе",
"nameR": "Самаровского района"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 77735,
"kind": "T",
"slug": "orilske",
"coordinates": { "latitude": 48.587799, "longitude": 34.8111 },
"obsStationId": 13158,
"timeZone": 180,
"country": { "id": 198, "slug": "ukraine", "code": "UA" },
"district": { "id": 319, "slug": "dnipropetrovsk-oblast" },
"subdistrict": { "id": 1178, "slug": "dniprovskyi-district" },
"translations": {
"ru": {
"city": { "name": "Орельское (Партизанское)", "nameP": "в Орельском (Партизанском)" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Днепровский район",
"nameP": "в Днепровском районе",
"nameR": "Днепровского района"
}
},
"kk": {
"city": { "name": "Орельское (Партизанское)", "nameP": "в Орельском (Партизанском)" },
"country": { "name": "Украина", "nameP": "на Украине", "nameR": "Украины" },
"district": {
"name": "Днепропетровская область",
"nameP": "в Днепропетровской области",
"nameR": "Днепропетровской области"
},
"subdistrict": {
"name": "Днепровский район",
"nameP": "в Днепровском районе",
"nameR": "Днепровского района"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
},
{
"id": 171956,
"kind": "T",
"slug": "orel",
"coordinates": { "latitude": 55.516499, "longitude": 44.0658 },
"obsStationId": 11899,
"timeZone": 180,
"country": { "id": 156, "slug": "russia", "code": "RU" },
"district": { "id": 266, "slug": "nizhny-novgorod-oblast" },
"subdistrict": { "id": 2796, "slug": "municipal-district-vadsky" },
"translations": {
"ru": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Нижегородская область",
"nameP": "в Нижегородской области",
"nameR": "Нижегородской области"
},
"subdistrict": {
"name": "муниципальный округ Вадский",
"nameP": "в муниципальном округе Вадском",
"nameR": "муниципального округа Вадского"
}
},
"kk": {
"city": { "name": "Орел", "nameP": "в Орле" },
"country": { "name": "Россия", "nameP": "в России", "nameR": "России" },
"district": {
"name": "Нижегородская область",
"nameP": "в Нижегородской области",
"nameR": "Нижегородской области"
},
"subdistrict": {
"name": "муниципальный округ Вадский",
"nameP": "в муниципальном округе Вадском",
"nameR": "муниципального округа Вадского"
}
}
},
"visitCount": 0,
"options": { "altitude": 0, "mrlExists": false, "significantHeightDiff": false, "landSeaMask": 0 },
"meta": { "nowcast": true, "allergy": { "birch": true, "grass": true, "ragweed": true } },
"redirectUrl": {}
}
],
"error": null
}

View File

@@ -0,0 +1,10 @@
from pathlib import Path
from tests.common.mock import MockSource
MATCHTV_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"test": "test.html",
},
)

View File

@@ -0,0 +1,10 @@
from pathlib import Path
from tests.common.mock import MockSource
OPENWEATHER_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"forecast": "forecast.json",
},
)

View File

@@ -0,0 +1,10 @@
from pathlib import Path
from tests.common.mock import MockSource
YANDEXTV_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"test": "test.html",
},
)

View File

@@ -3,20 +3,21 @@ import datetime
import pytest import pytest
from gallery.painting.gismeteo.api import GismeteoApi from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.gismeteo.mock import GISMETEO_MOCK_DATA from tests.data.gismeteo import GISMETEO_MOCK_SOURCE
@pytest.fixture(name="gismeteo_api", scope="module") @pytest.fixture(name="gismeteo_api", scope="module")
def gismeteo_api_fixture() -> GismeteoApi: def gismeteo_api_fixture() -> GismeteoApi:
class MockSource:
async def request(self, endpoint: str):
return GISMETEO_MOCK_DATA.get_html(endpoint.split("/")[-1])
api = GismeteoApi() api = GismeteoApi()
api.SOURCE = MockSource() api.SOURCE = GISMETEO_MOCK_SOURCE
return api return api
async def test_search(gismeteo_api: GismeteoApi):
result = await gismeteo_api.find_locations("test")
assert len(result) == 10
async def test_day(gismeteo_api: GismeteoApi): async def test_day(gismeteo_api: GismeteoApi):
result = await gismeteo_api.get_day("test", datetime.date.today()) result = await gismeteo_api.get_day("test", datetime.date.today())
assert len(result.values) == 8 assert len(result.values) == 8

View File

@@ -3,18 +3,14 @@ import datetime
import pytest import pytest
from gallery.painting.matchtv.api import MatchTvApi from gallery.painting.matchtv.api import MatchTvApi
from gallery.painting.matchtv.mock import MATCHTV_MOCK_DATA
from gallery.sketch.schedule.model import ChannelId from gallery.sketch.schedule.model import ChannelId
from tests.data.matchtv import MATCHTV_MOCK_SOURCE
@pytest.fixture(name="matchtv_api", scope="module") @pytest.fixture(name="matchtv_api", scope="module")
def matchtv_api_fixture() -> MatchTvApi: def matchtv_api_fixture() -> MatchTvApi:
class MockSource:
async def request(self, endpoint: str):
return MATCHTV_MOCK_DATA.get_html(endpoint.split("/")[1].split("?")[0])
api = MatchTvApi() api = MatchTvApi()
api.SOURCE = MockSource() api.SOURCE = MATCHTV_MOCK_SOURCE
return api return api

View File

@@ -3,25 +3,27 @@ import datetime
import pytest import pytest
from gallery.painting.openweather.api import OpenWeatherApi from gallery.painting.openweather.api import OpenWeatherApi
from gallery.painting.openweather.mock import OPENWEATHER_MOCK_DATA from gallery.painting.openweather.openweather import OpenWeather
from gallery.painting.openweather.openweather import Forecast from tests.data.openweather import OPENWEATHER_MOCK_SOURCE
@pytest.fixture(name="openweather_api", scope="module") @pytest.fixture(name="openweather_api", scope="module")
def openweather_api_fixture() -> OpenWeatherApi: def openweather_api_fixture() -> OpenWeatherApi:
async def _get_location_forecast(location_id: str) -> Forecast: class MockOpenWeather(OpenWeather):
return Forecast(**OPENWEATHER_MOCK_DATA.get_json("forecast")) def __init__(self):
super().__init__("")
self._source = OPENWEATHER_MOCK_SOURCE
api = OpenWeatherApi() api = OpenWeatherApi()
api._get_location_forecast = _get_location_forecast api.SOURCE = MockOpenWeather()
return api return api
async def test_day(openweather_api: OpenWeatherApi): async def test_day(openweather_api: OpenWeatherApi):
result = await openweather_api.get_day("orel-4432", datetime.date(2024, 8, 23)) result = await openweather_api.get_day("52.968498:36.0695", datetime.date(2024, 8, 23))
assert len(result.values) == 8 assert len(result.values) == 8
async def test_days(openweather_api: OpenWeatherApi): async def test_days(openweather_api: OpenWeatherApi):
result = await openweather_api.get_days("orel-4432", 10) result = await openweather_api.get_days("52.968498:36.0695", 10)
assert len(result.values) == 6 assert len(result.values) == 6

View File

@@ -3,19 +3,14 @@ import datetime
import pytest import pytest
from gallery.painting.yandextv.api import CHANNELS_MAP, YandexTvApi from gallery.painting.yandextv.api import CHANNELS_MAP, YandexTvApi
from gallery.painting.yandextv.mock import YANDEXTV_MOCK_DATA
from gallery.sketch.schedule.model import ChannelId from gallery.sketch.schedule.model import ChannelId
from tests.data.yandextv import YANDEXTV_MOCK_SOURCE
@pytest.fixture(name="yandextv_api", scope="module") @pytest.fixture(name="yandextv_api", scope="module")
def yandextv_api_fixture() -> YandexTvApi: def yandextv_api_fixture() -> YandexTvApi:
class MockSource:
async def request(self, endpoint: str):
return YANDEXTV_MOCK_DATA.get_html(endpoint.split("/")[1].split("?")[0])
api = YandexTvApi() api = YandexTvApi()
api.SOURCE = MockSource() api.SOURCE = YANDEXTV_MOCK_SOURCE
CHANNELS_MAP[ChannelId("test")] = "test" CHANNELS_MAP[ChannelId("test")] = "test"
return api return api