14 Commits

43 changed files with 5929 additions and 5444 deletions

View File

@@ -3,3 +3,5 @@ DOCKER_GROUP=infernalgames
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP" DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+') VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
DOCKER_PROJECTS=("gallery") DOCKER_PROJECTS=("gallery")
OPENWEATHER_KEY="<EMPTY>"

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
#.vscode #.vscode
static/node_modules static/node_modules
static/dist static/dist
.env

View File

@@ -7,7 +7,9 @@ WORKDIR /app
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -
COPY pyproject.toml poetry.lock README.md ./ 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 --mount=type=cache,target=/root/.cache/pypoetry/cache \
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
poetry install --with app --no-root
COPY locales ./locales COPY locales ./locales
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
@@ -15,7 +17,8 @@ FROM node:24 AS node-builder
ENV PATH=/app/node_modules/.bin:$PATH ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app WORKDIR /app
COPY static/package.json static/package-lock.json ./ COPY static/package.json static/package-lock.json ./
RUN npm ci RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY static ./ COPY static ./
RUN npm run build RUN npm run build
@@ -27,7 +30,6 @@ ENV TZ="Europe/Moscow"
COPY --from=builder /app ./ COPY --from=builder /app ./
COPY --from=node-builder /app/dist ./static/dist COPY --from=node-builder /app/dist ./static/dist
COPY gallery gallery/ COPY gallery gallery/
#COPY --from=builder /app/gallery/easel/route/view/locales /app/gallery/easel/route/view/locales
COPY --from=builder --parents locales/**/*.mo ./ COPY --from=builder --parents locales/**/*.mo ./
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"] CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 182 KiB

View File

@@ -8,6 +8,7 @@
"python.testing.pytestArgs": ["tests", "-s"], "python.testing.pytestArgs": ["tests", "-s"],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.terminal.useEnvFile": true,
"python-envs.pythonProjects": [ "python-envs.pythonProjects": [
{ {
"path": ".", "path": ".",

View File

@@ -4,8 +4,7 @@ from fastapi.staticfiles import StaticFiles
from gallery.sketch.bundle import ApiBundle from gallery.sketch.bundle import ApiBundle
from gallery.util import root_path from gallery.util import root_path
from .route import api, doc from .route import api, doc, view
from .route.view import router as view_router
def build_app(api_bundle: ApiBundle) -> FastAPI: def build_app(api_bundle: ApiBundle) -> FastAPI:
@@ -17,6 +16,6 @@ def build_app(api_bundle: ApiBundle) -> FastAPI:
app.state.api = api_bundle app.state.api = api_bundle
app.mount("/static", StaticFiles(directory=root_path / "static/dist")) app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
doc.mount(app) doc.mount(app)
api.mount(app) app.include_router(api.router)
app.include_router(view_router) app.include_router(view.router)
return app return app

View File

@@ -1,8 +1,7 @@
from fastapi import FastAPI from fastapi import APIRouter
from . import schedule, weather from . import schedule, weather
router = APIRouter(prefix="/api", tags=["API"])
def mount(app: FastAPI): router.include_router(weather.router)
weather.mount(app) router.include_router(schedule.router)
schedule.mount(app)

View File

@@ -1,18 +1,20 @@
import datetime import datetime
from fastapi import FastAPI from fastapi import APIRouter
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.schedule.model import ChannelId, Schedule from gallery.sketch.schedule.model import ChannelId, Schedule
router = APIRouter(prefix="/schedule")
def mount(app: FastAPI):
@app.get("/api/schedule/channels", tags=["API"]) @router.get("/channels")
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}", tags=["API"])
@router.get("/{channel}/{date}")
async def get_api_schedule_channel_schedule(request: AppRequest, channel: str, date: datetime.date) -> Schedule: async def get_api_schedule_channel_schedule(request: AppRequest, channel: str, date: datetime.date) -> Schedule:
schedule_api = request.app.state.api.schedule schedule_api = request.app.state.api.schedule
return await schedule_api.get_channel_schedule(ChannelId(channel), date) return await schedule_api.get_channel_schedule(ChannelId(channel), date)

View File

@@ -1,23 +1,31 @@
import datetime import datetime
from fastapi import FastAPI from fastapi import APIRouter
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.weather.model import Location, WeatherResponse from gallery.sketch.weather.model import Location, WeatherResponse
router = APIRouter(prefix="/weather")
def mount(app: FastAPI):
@app.get("/api/weather/locations", tags=["API"]) @router.get("/providers")
async def get_api_weather_providers(request: AppRequest) -> list[str]:
return request.app.state.api.get_api_providers("weather")
@router.get("/locations")
async def get_api_weather_locations(request: AppRequest, query: str) -> list[Location]: 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.find_locations(query) return await weather_api.find_locations(query)
@app.get("/api/weather/{location}/day/{date}", tags=["API"])
@router.get("/{location}/day/{date}")
async def get_api_weather_day(request: AppRequest, location: str, date: datetime.date) -> WeatherResponse: async def get_api_weather_day(request: AppRequest, location: str, date: datetime.date) -> 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}", tags=["API"])
@router.get("/{location}/days/{days}")
async def get_api_weather_days(request: AppRequest, location: str, days: int) -> WeatherResponse: async def get_api_weather_days(request: AppRequest, location: str, days: int) -> WeatherResponse:
weather_api = request.app.state.api.weather weather_api = request.app.state.api.weather
return await weather_api.get_days(location, days) return await weather_api.get_days(location, days)

View File

@@ -1,11 +1,11 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from .common import router as common_router from .root import router as root_router
from .schedule import router as schedule_router from .schedule import router as schedule_router
from .translation import set_language from .translation import set_language
from .weather import router as weather_router from .weather import router as weather_router
router = APIRouter(dependencies=[Depends(set_language)]) router = APIRouter(tags=["view"], dependencies=[Depends(set_language)])
router.include_router(common_router) router.include_router(root_router)
router.include_router(weather_router) router.include_router(weather_router)
router.include_router(schedule_router) router.include_router(schedule_router)

View File

@@ -1,36 +0,0 @@
from pathlib import Path
from typing import NamedTuple
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from ..common.utils.template import build_templates
class Section(NamedTuple):
link: str
title: str
icon: str
SECTIONS = [
Section("weather", "Weather", "brightness-high"),
Section("schedule", "TV program", "tv"),
]
base_dir = Path(__file__).parent
router = APIRouter()
templates = build_templates()
@router.get("/", response_class=HTMLResponse)
async def get_section_list(request: Request):
return templates.TemplateResponse(
request=request,
name="root_index.html",
context={
"sections": SECTIONS,
},
)

View File

@@ -30,6 +30,9 @@
{% block header %}{% endblock %} {% block header %}{% endblock %}
</div> </div>
<ul class="navbar-nav flex-row flex-wrap ms-md-auto"> <ul class="navbar-nav flex-row flex-wrap ms-md-auto">
<li class="nav-item me-2">
<span class="nav-link">{{ version }}</span>
</li>
<li class="nav-item dropdown"> <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" <button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="bd-language" id="bd-language"

View File

@@ -1,15 +1,19 @@
import datetime import datetime
import typing
from pathlib import Path from pathlib import Path
from babel.dates import format_date from babel.dates import format_date
from fastapi import Request from fastapi import Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from gallery.easel.core import AppRequest
from gallery.version import __version__ from gallery.version import __version__
from ...translation import _ from ...translation import _
from .tag import TagUtil from .tag import TagUtil
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
def is_widget(request: Request) -> bool: def is_widget(request: Request) -> bool:
return (request.url.hostname and request.url.hostname.startswith("weather")) or ( return (request.url.hostname and request.url.hostname.startswith("weather")) or (
@@ -17,17 +21,23 @@ def is_widget(request: Request) -> bool:
) )
def context_processor(request: Request) -> dict: def base_context_processor(request: Request) -> dict:
return { return {
"is_widget": is_widget(request), "is_widget": is_widget(request),
"provider": request.query_params.get("provider"),
} }
def build_templates(templates_dir: Path | None = None, filters: dict | None = None) -> Jinja2Templates: def build_templates(
templates_dir: Path | None = None, filters: dict | None = None, context_processor: ContextProcessor | None = None
) -> Jinja2Templates:
directory = [Path(__file__).parent.parent / "templates"] directory = [Path(__file__).parent.parent / "templates"]
if templates_dir: if templates_dir:
directory.append(templates_dir) directory.append(templates_dir)
templates = Jinja2Templates(directory=directory, context_processors=[context_processor]) context_processors: list[ContextProcessor] = [base_context_processor]
if context_processor:
context_processors.append(context_processor)
templates = Jinja2Templates(directory=directory, context_processors=context_processors)
templates.env.globals.update( templates.env.globals.update(
{ {
"_": _, "_": _,

View File

@@ -0,0 +1,36 @@
from pathlib import Path
from typing import NamedTuple
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from ..common.utils.template import build_templates
class Section(NamedTuple):
link: str
title: str
icon: str
SECTIONS = [
Section("weather", "Weather", "brightness-high"),
Section("schedule", "TV program", "tv"),
]
base_dir = Path(__file__).parent
router = APIRouter()
templates = build_templates(Path(__file__).parent / "templates")
@router.get("/", response_class=HTMLResponse)
async def get_section_list(request: Request):
return templates.TemplateResponse(
request=request,
name="root_index.html",
context={
"sections": SECTIONS,
},
)

View File

@@ -1,16 +1,32 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated
from fastapi import APIRouter from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.catalog import BUNDLE from gallery.sketch.schedule.catalog import BUNDLE
from ..common.utils.tag import TagType, TagUtil from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates from ..common.utils.template import build_templates
from .filters import timedelta_format from .filters import timedelta_format
async def get_schedule_api(request: AppRequest, provider: str | None = None) -> ScheduleApi:
return request.app.state.api.get_schedule(provider)
ScheduleApiDepends = Annotated[ScheduleApi, Depends(get_schedule_api)]
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(ScheduleApi.TYPE),
}
templates = build_templates( templates = build_templates(
Path(__file__).parent / "templates", Path(__file__).parent / "templates",
{ {
@@ -18,12 +34,11 @@ templates = build_templates(
}, },
) )
router = APIRouter() router = APIRouter(prefix="/schedule")
@router.get("/schedule", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def get_schedule_list(request: AppRequest): async def get_schedule_list(request: AppRequest, schedule_api: ScheduleApiDepends):
schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels() channels = await schedule_api.get_channels()
channels_data = BUNDLE.select_items(channels) channels_data = BUNDLE.select_items(channels)
return templates.TemplateResponse( return templates.TemplateResponse(
@@ -35,10 +50,9 @@ async def get_schedule_list(request: AppRequest):
) )
@router.get("/schedule/tag/{tag}", response_class=HTMLResponse) @router.get("/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False): async def get_schedule_tag(request: AppRequest, schedule_api: ScheduleApiDepends, tag: str, live: bool = False):
tag_value = TagUtil.parse_tag(tag) tag_value = TagUtil.parse_tag(tag)
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,
@@ -53,15 +67,14 @@ async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
) )
@router.get("/schedule/{channel}", response_class=RedirectResponse) @router.get("/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str): async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/tag/today") return RedirectResponse(f"{channel}/tag/today")
@router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse) @router.get("/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, channel: str, tag: str): async def get_channel_tag(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, tag: str):
tag_value = TagUtil.parse_tag(tag) tag_value = TagUtil.parse_tag(tag)
schedule_api = request.app.state.api.schedule
if tag_value.type == TagType.DAY: if tag_value.type == TagType.DAY:
response = await schedule_api.get_channel_schedule(channel, tag_value.date) response = await schedule_api.get_channel_schedule(channel, tag_value.date)
else: else:

View File

@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}} {{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
locale=request.state.language)}}
{% endblock %} {% endblock %}
{% block header %} {% block header %}
@@ -10,13 +11,19 @@
{% block content %} {% block content %}
<h4> <h4>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
<a class="button" <i class="bi bi-arrow-left-square"></i>
href="../..">⬆️</a> </a>
<span>{{response.channel.name}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span> <a class="icon-link"
<a class="button" href="../..">
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a> <i class="bi bi-arrow-up-square"></i>
</a>
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<a class="icon-link"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
<i class="bi bi-arrow-right-square"></i>
</a>
</h4> </h4>
<table class="table"> <table class="table">
<thead> <thead>

View File

@@ -4,12 +4,12 @@
{% block content %} {% block content %}
<h1>{{_("TV program")}}</h1> <h1>{{_("TV program")}}</h1>
<div class="list-group mb-5"> <div class="list-group mb-5">
<a href="schedule/tag/today" <a href="tag/today"
class="list-group-item list-group-item-action px-4"> class="list-group-item list-group-item-action px-4">
<span class="fw-bold">Все</span> <span class="fw-bold">Все</span>
</a> </a>
{% for channel in channels %} {% for channel in channels %}
<a href="schedule/{{channel.id}}" <a href="{{channel.id}}"
class="list-group-item list-group-item-action px-4"> class="list-group-item list-group-item-action px-4">
<span class="text-primary">{{channel.name}}</span> <span class="text-primary">{{channel.name}}</span>
</a> </a>

View File

@@ -1,6 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}} {{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, DATE_FORMAT,
locale=request.state.language)}}
{% endblock %} {% endblock %}
{% block header %} {% block header %}
@@ -10,13 +11,20 @@
{% block content %} {% block content %}
<h4> <h4>
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
<a class="button" <i class="bi bi-arrow-left-square"></i>
href="..">⬆️</a> </a>
<span>{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, 'E, d MMMM Y', locale=request.state.language)}}</span> <a class="icon-link"
<a class="button" href="..">
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a> <i class="bi bi-arrow-up-square"></i>
</a>
<span>{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, DATE_FORMAT,
locale=request.state.language)}}</span>
<a class="icon-link"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
<i class="bi bi-arrow-right-square"></i>
</a>
</h4> </h4>
<div> <div>
<table class="table"> <table class="table">

View File

@@ -1,22 +1,39 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated
from fastapi import APIRouter from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse from gallery.sketch.weather.model import WeatherResponse
from ..common.utils.tag import TagType, TagUtil from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates from ..common.utils.template import build_templates
from .filters import cloudness_icon, wind_direction_icon from .filters import cloudness_icon, wind_direction_icon
async def get_weather_api(request: AppRequest, provider: str | None = None) -> WeatherApi:
return request.app.state.api.get_weather(provider)
WeatherApiDepends = Annotated[WeatherApi, Depends(get_weather_api)]
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(WeatherApi.TYPE),
}
templates = build_templates( templates = build_templates(
Path(__file__).parent / "templates", Path(__file__).parent / "templates",
{ {
"wind_direction_icon": wind_direction_icon, "wind_direction_icon": wind_direction_icon,
"cloudness_icon": cloudness_icon, "cloudness_icon": cloudness_icon,
}, },
context_procesor,
) )
@@ -30,12 +47,17 @@ def build_weather_response(request: AppRequest, response: WeatherResponse):
) )
router = APIRouter() router = APIRouter(prefix="/weather")
@router.get("/weather", response_class=HTMLResponse) @router.get("/", response_class=RedirectResponse)
async def get_weather_index(request: AppRequest, query: str | None = None): async def get_weather(request: AppRequest):
weather_api = request.app.state.api.weather default_provider = request.app.state.api.get_api_providers(WeatherApi.TYPE)[0]
return RedirectResponse(default_provider)
@router.get("/{provider}", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends, query: str | None = None):
locations = (await weather_api.find_locations(query)) if query else [] locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
@@ -46,29 +68,31 @@ async def get_weather_index(request: AppRequest, query: str | None = None):
) )
@router.get("/weather/{location}", response_class=RedirectResponse) @router.get("/{provider}/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str): async def get_weather_default(location: str):
return RedirectResponse(f"{location}/tag/today") return RedirectResponse(f"{location}/tag/today")
@router.get("/weather/{location}/day/{date}", response_class=HTMLResponse) @router.get("/{provider}/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: AppRequest, location: str, date: datetime.date): async def get_weather_day(
weather_api = request.app.state.api.weather request: AppRequest,
weather_api: WeatherApiDepends,
location: str,
date: datetime.date,
):
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)
@router.get("/weather/{location}/days/{days}", response_class=HTMLResponse) @router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, location: str, days: int): async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
weather_api = request.app.state.api.weather
response = await weather_api.get_days(location, days) response = await weather_api.get_days(location, days)
return build_weather_response(request, response) return build_weather_response(request, response)
@router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse) @router.get("/{provider}/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, location: str, tag: str): async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, location: str, tag: str):
tag_value = TagUtil.parse_tag(tag) tag_value = TagUtil.parse_tag(tag)
weather_api = request.app.state.api.weather
if tag_value.type == TagType.DAY: if tag_value.type == TagType.DAY:
response = await weather_api.get_day(location, tag_value.date) response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS: elif tag_value.type == TagType.DAYS:

View File

@@ -1,6 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{_("Weather")}}{% endblock %} {% block title %}{{_("Weather")}}{% endblock %}
{# {% block header %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{{provider or providers[0]}}
</button>
<ul class="dropdown-menu">
{% for provider in providers %}
<li><a class="dropdown-item"
href="{{ url_for('get_weather_index').include_query_params(provider=provider) }}">{{provider}}</a></li>
{% endfor %}
</ul>
</div>
{% endblock %} #}
{% block content %} {% block content %}
<h1>{{_("Weather")}}</h1> <h1>{{_("Weather")}}</h1>
<form action="" <form action=""
@@ -16,59 +33,29 @@
type="submit">{{_("Search")}}</button> type="submit">{{_("Search")}}</button>
</div> </div>
</form> </form>
{% if locations %}
<ul id="locations" <ul id="locations"
class="list-group mb-5"> class="list-group mb-5">
{% for location in locations %} {% for location in locations %}
<a href="weather/{{location.id}}" <weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location>
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 %} {% endfor %}
</ul> </ul>
<hr>
{% endif %}
<ul id="storedLocations"
class="list-group mb-5">
</ul>
<script> <script>
(function () { (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 params = new URLSearchParams(window.location.search);
const searchQuery = params.get('query'); const searchQuery = params.get('query');
if (searchQuery) { if (searchQuery) {
document.querySelector('#query').value = searchQuery; document.querySelector('#query').value = searchQuery;
} else {
document.loadLocations();
} }
document.addEventListener("DOMContentLoaded", (event) => {
weatherLocationManager.loadLocations('#storedLocations');
});
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -89,7 +89,9 @@ class GismeteoApi(WeatherApi):
lon=item["coordinates"]["longitude"], lon=item["coordinates"]["longitude"],
country=item["translations"]["kk"]["country"]["name"], country=item["translations"]["kk"]["country"]["name"],
country_code=item["country"]["code"].lower(), country_code=item["country"]["code"].lower(),
district=item["translations"]["kk"]["district"]["name"], district=(
item["translations"]["kk"]["district"]["name"] if item["translations"]["kk"]["district"] else ""
),
subdistrict=( subdistrict=(
item["translations"]["kk"]["subdistrict"]["name"] item["translations"]["kk"]["subdistrict"]["name"]
if "subdistrict" in item["translations"]["kk"] if "subdistrict" in item["translations"]["kk"]

View File

@@ -39,14 +39,11 @@ class DateParser(RowParser[datetime.datetime]):
KEY = "date" KEY = "date"
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]: def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
datetime_date_tag = tag.select_one(".widget-row.widget-row-datetime-date > .row-item") datetime_time_row = tag.select_one(".widget-row.widget-row-datetime-time")
if datetime_date_tag: if datetime_time_row:
date_str = datetime_date_tag.find(text=True, recursive=False).text for item in datetime_time_row.select(".row-item > time-value"):
date = dateparser.parse(date_str, languages=["ru"]) timestamp = int(item.attrs["timestamp"])
for item in tag.select(".widget-row.widget-row-datetime-time > .row-item"): time = datetime.datetime.fromtimestamp(timestamp)
time_str = item.text
time = dateparser.parse(time_str, languages=["ru"])
time = time.replace(year=date.year, month=date.month, day=date.day)
yield time yield time
else: else:
for item in tag.select(".widget-row.widget-row-date > .row-item"): for item in tag.select(".widget-row.widget-row-date > .row-item"):
@@ -174,7 +171,7 @@ class PrecipitationParser(RowParser[float]):
def parse_row(self, tag: Tag) -> Iterable[float]: 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(",", ".")) yield float(item.text.replace(",", ".").replace("< ", ""))
class PressureParser(RowParser[list[int]]): class PressureParser(RowParser[list[int]]):

View File

@@ -1,6 +1,7 @@
import datetime import datetime
import logging import logging
from collections import defaultdict from collections import defaultdict
from os import environ
from aiocache import cached from aiocache import cached
@@ -17,7 +18,7 @@ logger = logging.getLogger("openweather")
class OpenWeatherApi(WeatherApi): class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather" PROVIDER = "openweather"
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf") SOURCE = OpenWeather(environ["OPENWEATHER_KEY"])
@classmethod @classmethod
def _parse_location(cls, location_id: str) -> tuple[float, float]: def _parse_location(cls, location_id: str) -> tuple[float, float]:
@@ -32,7 +33,20 @@ class OpenWeatherApi(WeatherApi):
return await self.SOURCE.get_forecast(*self._parse_location(location_id)) return await self.SOURCE.get_forecast(*self._parse_location(location_id))
async def find_locations(self, query: str) -> list[Location]: async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError result = await self.SOURCE.get_locations(query)
return [
Location(
id=f"{item.lat}:{item.lon}",
name=item.name,
lat=item.lat,
lon=item.lon,
country=item.country,
country_code=item.country.lower(),
district=item.state,
subdistrict="",
)
for item in 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: Forecast = await self._get_location_forecast(location_id) data: Forecast = await self._get_location_forecast(location_id)

View File

@@ -67,6 +67,15 @@ class Forecast(Model):
list: list[ForecastItem] list: list[ForecastItem]
class Location(Model):
name: str
local_names: dict[str, str]
lat: float
lon: float
country: str
state: str
class OpenWeather: class OpenWeather:
BASE_URL = "https://api.openweathermap.org" BASE_URL = "https://api.openweathermap.org"
@@ -78,4 +87,10 @@ class OpenWeather:
endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric" endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
response = await self._source.request(endpoint) response = await self._source.request(endpoint)
response_data = json.loads(response) response_data = json.loads(response)
return Forecast(**response_data) return Forecast.model_validate(response_data)
async def get_locations(self, query: str, limit: int = 5) -> list[Location]:
endpoint = f"geo/1.0/direct?q={query}&limit={limit}&appid={self._api_key}"
response = await self._source.request(endpoint)
response_data = json.loads(response)
return [Location.model_validate(item) for item in response_data]

View File

@@ -2,8 +2,13 @@ from typing import TypeVar
class Api: class Api:
TYPE: str
PROVIDER: str PROVIDER: str
@property
def type(self) -> str:
return self.TYPE
@property @property
def provider(self) -> str: def provider(self) -> str:
return self.PROVIDER return self.PROVIDER

View File

@@ -3,26 +3,27 @@ from .schedule.api import ScheduleApi
from .weather.api import WeatherApi from .weather.api import WeatherApi
class ApiBundle(list[Api]): class ApiBundle:
def __init__(self, values: list[Api]) -> None: def __init__(self, values: list[Api]):
super().__init__(values) self._values = values
self._by_provider = {value.provider: value for value in values}
def get_api_by_provider(self, provider: str) -> Api: def get_api_providers(self, api_type: str) -> list[str]:
for value in self: result = []
if value.PROVIDER == provider: for value in self._values:
return value if value.type == api_type:
raise ValueError(provider) result.append(value.provider)
return result
def get_api_by_type(self, api_type: type[API]) -> API: def get_api(self, api_type: type[API], provider: str | None = None) -> API:
for value in self: for value in self._values:
if isinstance(value, api_type): if isinstance(value, api_type):
if provider is None or provider == value.provider:
return value return value
raise ValueError(api_type) raise ValueError(api_type, provider)
@property def get_weather(self, provider: str | None = None) -> WeatherApi:
def weather(self) -> WeatherApi: return self.get_api(WeatherApi, provider)
return self.get_api_by_type(WeatherApi)
@property def get_schedule(self, provider: str | None = None) -> ScheduleApi:
def schedule(self) -> ScheduleApi: return self.get_api(ScheduleApi, provider)
return self.get_api_by_type(ScheduleApi)

View File

@@ -6,6 +6,7 @@ from .model import ChannelId, Schedule
class ScheduleApi(Api): class ScheduleApi(Api):
TYPE = "schedule"
INTERVAL: float = 0.5 INTERVAL: float = 0.5
async def get_channels(self) -> list[ChannelId]: async def get_channels(self) -> list[ChannelId]:

View File

@@ -12,7 +12,7 @@ CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]): class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
CACHE_KEY = "schedule" CACHE_KEY = ScheduleApi.TYPE
@cached( @cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels", key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",

View File

@@ -5,6 +5,7 @@ from .model import Location, WeatherResponse
class WeatherApi(Api): class WeatherApi(Api):
TYPE = "weather"
async def find_locations(self, query: str) -> list[Location]: async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError raise NotImplementedError

View File

@@ -11,7 +11,7 @@ CACHE_PRESET = DEFAULT_CACHE_PRESET
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]): class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
CACHE_KEY = "weather" CACHE_KEY = WeatherApi.TYPE
@cached( @cached(
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}", key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",

View File

@@ -1,5 +1,3 @@
__version__ = "0.2.2"
import tomllib import tomllib
from pathlib import Path from pathlib import Path

View File

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

View File

@@ -2,6 +2,10 @@
set -e set -e
cd "$(dirname $(dirname "$0"))" || exit cd "$(dirname $(dirname "$0"))" || exit
if [[ ! -f .env ]]; then
cp .env-base .env
fi
PYTHON_VERSION=3.12 PYTHON_VERSION=3.12
poetry env use ${PYTHON_VERSION} poetry env use ${PYTHON_VERSION}
poetry install poetry install

View File

@@ -1,12 +1,12 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.3.0", "version": "0.3.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gallery", "name": "gallery",
"version": "0.3.0", "version": "0.3.3",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.3.0", "version": "0.3.3",
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"dev": "vite build --watch" "dev": "vite build --watch"

View File

@@ -3,6 +3,7 @@ import "./components";
import "./language"; import "./language";
import "./main.scss"; import "./main.scss";
import "./theme"; import "./theme";
import "./weather/weather";
document.addEventListener("DOMContentLoaded", (event) => { document.addEventListener("DOMContentLoaded", (event) => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');

View File

@@ -4,7 +4,7 @@
@import "./lib/weather-icons/weather-icons"; @import "./lib/weather-icons/weather-icons";
@import "./widget.scss"; @import "./widget.scss";
@import "./weather.scss"; @import "./weather/weather.scss";
.table.table-compact { .table.table-compact {
td { td {

View File

@@ -0,0 +1,113 @@
export interface Location {
id: string;
name: string;
provider: string;
country_code: string;
country?: string;
district?: string;
subdistrict?: string;
}
export class WeatherLocationStorage {
private storageKey: string = "weather:locations";
getAll(): Location[] {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
return Object.values(locations);
}
add(location: Location) {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
locations[location.id] = location;
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
}
remove(id: string) {
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
delete locations[id];
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
}
}
export class WeatherLocationManager {
loadLocations(selector: string) {
const locations = window.weatherLocationStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
for (const location of locations) {
const element = new WeatherLocationElement();
element.setAttribute("location", JSON.stringify(location));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
class WeatherLocationElement extends HTMLElement {
static observedAttributes = ["location", "removable"];
location: Location | null = null;
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
}
connectedCallback() {
this.location = JSON.parse(this.getAttribute("location") || "{}");
this.innerHTML = `
<a href="${this.location?.provider}/${this.location?.id}"
class="list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start">
<span>
<span class="fi fi-${this.location?.country_code} me-1"></span>
<span class="text-primary">${this.location?.name}</span>
<span class="small ms-1 text-secondary">
${this.location?.country}, ${this.location?.district}, ${this.location?.subdistrict}
</span>
</span>
<span class="text-danger" data-remove>
&#x2715;
</span>
</a>`;
this.addEventListener("click", this.handleClick);
if (this.hasAttribute("removable")) {
this.querySelector("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
}
handleClick(event: MouseEvent) {
if (this.location) {
window.weatherLocationStorage.add(this.location);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.location) {
window.weatherLocationStorage.remove(this.location.id);
}
this.remove();
}
}
customElements.define("weather-location", WeatherLocationElement);
declare global {
interface Window {
weatherLocationStorage: WeatherLocationStorage;
weatherLocationManager: WeatherLocationManager;
}
}
window.weatherLocationStorage = new WeatherLocationStorage();
window.weatherLocationManager = new WeatherLocationManager();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,254 @@
[
{
"name": "London",
"local_names": {
"fo": "London",
"nn": "London",
"tg": "Лондон",
"uk": "Лондон",
"lt": "Londonas",
"zu": "ILondon",
"eo": "Londono",
"os": "Лондон",
"pa": "ਲੰਡਨ",
"jv": "London",
"hu": "London",
"gd": "Lunnainn",
"hy": "Լոնդոն",
"to": "Lonitoni",
"sr": "Лондон",
"sv": "London",
"ku": "London",
"te": "లండన్",
"tw": "London",
"co": "Londra",
"eu": "Londres",
"et": "London",
"ca": "Londres",
"nl": "Londen",
"kl": "London",
"fi": "Lontoo",
"az": "London",
"mr": "लंडन",
"km": "ឡុងដ៍",
"af": "Londen",
"hi": "लंदन",
"gl": "Londres",
"ka": "ლონდონი",
"ff": "London",
"de": "London",
"sl": "London",
"th": "ลอนดอน",
"bn": "লন্ডন",
"pl": "Londyn",
"an": "Londres",
"bg": "Лондон",
"ht": "Lonn",
"so": "London",
"mn": "Лондон",
"id": "London",
"ko": "런던",
"yo": "Lọndọnu",
"sa": "लन्डन्",
"nv": "Tooh Dineʼé Bikin Haalʼá",
"br": "Londrez",
"rm": "Londra",
"ascii": "London",
"en": "London",
"ba": "Лондон",
"ga": "Londain",
"tr": "Londra",
"om": "Landan",
"bh": "लंदन",
"gu": "લંડન",
"ee": "London",
"se": "London",
"vi": "Luân Đôn",
"sk": "Londýn",
"lb": "London",
"cs": "Londýn",
"io": "London",
"ab": "Лондон",
"ug": "لوندۇن",
"ha": "Landan",
"wo": "Londar",
"bi": "London",
"st": "London",
"fj": "Lodoni",
"tt": "Лондон",
"mt": "Londra",
"vo": "London",
"bs": "London",
"or": "ଲଣ୍ଡନ",
"is": "London",
"tk": "London",
"he": "לונדון",
"ia": "London",
"kn": "ಲಂಡನ್",
"ie": "London",
"es": "Londres",
"my": "လန်ဒန်မြို့",
"sn": "London",
"zh": "伦敦",
"wa": "Londe",
"fy": "Londen",
"sq": "Londra",
"be": "Лондан",
"oc": "Londres",
"hr": "London",
"da": "London",
"bm": "London",
"ru": "Лондон",
"ne": "लन्डन",
"pt": "Londres",
"lo": "ລອນດອນ",
"ro": "Londra",
"cu": "Лондонъ",
"ce": "Лондон",
"ny": "London",
"am": "ለንደን",
"gv": "Lunnin",
"no": "London",
"it": "Londra",
"cv": "Лондон",
"ta": "இலண்டன்",
"ar": "لندن",
"av": "Лондон",
"sm": "Lonetona",
"ja": "ロンドン",
"ky": "Лондон",
"si": "ලන්ඩන්",
"tl": "Londres",
"na": "London",
"sw": "London",
"mi": "Rānana",
"lv": "Londona",
"gn": "Lóndyre",
"su": "London",
"mg": "Lôndôna",
"ml": "ലണ്ടൻ",
"fr": "Londres",
"ur": "علاقہ لندن",
"kw": "Loundres",
"yi": "לאנדאן",
"ig": "London",
"sc": "Londra",
"ln": "Lóndɛlɛ",
"kk": "Лондон",
"el": "Λονδίνο",
"sd": "لنڊن",
"li": "Londe",
"cy": "Llundain",
"ms": "London",
"fa": "لندن",
"sh": "London",
"feature_name": "London",
"uz": "London",
"bo": "ལོན་ཊོན།",
"mk": "Лондон",
"qu": "London",
"ay": "London",
"kv": "Лондон",
"ps": "لندن"
},
"lat": 51.5073219,
"lon": -0.1276474,
"country": "GB",
"state": "England"
},
{
"name": "City of London",
"local_names": {
"lt": "Londono Sitis",
"zh": "倫敦市",
"hi": "सिटी ऑफ़ लंदन",
"ko": "시티 오브 런던",
"es": "City de Londres",
"ur": "لندن شہر",
"uk": "Лондонське Сіті",
"he": "הסיטי של לונדון",
"en": "City of London",
"pt": "Cidade de Londres",
"fr": "Cité de Londres",
"ru": "Сити"
},
"lat": 51.5156177,
"lon": -0.0919983,
"country": "GB",
"state": "England"
},
{
"name": "London",
"local_names": {
"ga": "Londain",
"ka": "ლონდონი",
"yi": "לאנדאן",
"cr": "ᓬᐊᐣᑕᐣ",
"th": "ลอนดอน",
"he": "לונדון",
"el": "Λόντον",
"lt": "Londonas",
"ja": "ロンドン",
"fa": "لندن",
"bn": "লন্ডন",
"iu": "ᓚᓐᑕᓐ",
"ru": "Лондон",
"oj": "Baketigweyaang",
"ug": "لوندۇن",
"en": "London",
"be": "Лондан",
"ar": "لندن",
"fr": "London",
"lv": "Landona",
"ko": "런던",
"hy": "Լոնտոն"
},
"lat": 42.9832406,
"lon": -81.243372,
"country": "CA",
"state": "Ontario"
},
{
"name": "Chelsea",
"local_names": {
"id": "Chelsea, London",
"ru": "Челси",
"ga": "Chelsea",
"ur": "چیلسی، لندن",
"et": "Chelsea",
"pl": "Chelsea",
"da": "Chelsea",
"ar": "تشيلسي",
"fa": "چلسی",
"ko": "첼시",
"sv": "Chelsea, London",
"es": "Chelsea",
"eu": "Chelsea",
"nl": "Chelsea",
"sk": "Chelsea",
"no": "Chelsea",
"af": "Chelsea, Londen",
"uk": "Челсі",
"he": "צ'לסי",
"it": "Chelsea",
"hu": "Chelsea",
"ja": "チェルシー",
"zh": "車路士",
"hi": "चेल्सी, लंदन",
"pt": "Chelsea",
"az": "Çelsi",
"el": "Τσέλσι",
"vi": "Chelsea, Luân Đôn",
"en": "Chelsea",
"de": "Chelsea",
"tr": "Chelsea, Londra",
"fr": "Chelsea",
"sh": "Chelsea, London"
},
"lat": 51.4875167,
"lon": -0.1687007,
"country": "GB",
"state": "England"
},
{ "name": "London", "lat": 37.1289771, "lon": -84.0832646, "country": "US", "state": "Kentucky" }
]