user language support, other important fixes

This commit is contained in:
Evgeny Zinoviev 2024-02-19 03:44:40 +03:00
parent 838b01c548
commit 840cbe4729
10 changed files with 102 additions and 35 deletions

View File

@ -14,7 +14,8 @@ from io import StringIO
from aiohttp import web from aiohttp import web
from typing import Optional, Union from typing import Optional, Union
from urllib.parse import quote_plus from urllib.parse import quote_plus
from homekit.config import config, AppConfigUnit, is_development_mode, Translation from contextvars import ContextVar
from homekit.config import config, AppConfigUnit, is_development_mode, Translation, Language
from homekit.camera import IpcamConfig from homekit.camera import IpcamConfig
from homekit.util import homekit_path, filesize_fmt, seconds_to_human_readable_string, json_serial, validate_ipv4 from homekit.util import homekit_path, filesize_fmt, seconds_to_human_readable_string, json_serial, validate_ipv4
from homekit.modem import E3372, ModemsConfig, MacroNetWorkType from homekit.modem import E3372, ModemsConfig, MacroNetWorkType
@ -45,10 +46,10 @@ common_static_files = [
'app.js', 'app.js',
'app.css' 'app.css'
] ]
static_version = 3 static_version = 4
routes = web.RouteTableDef() routes = web.RouteTableDef()
webkbn_strings = Translation('web_kbn')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
lang_context_var = ContextVar('lang', default=Translation.DEFAULT_LANGUAGE)
def get_js_link(file, version=static_version) -> str: def get_js_link(file, version=static_version) -> str:
@ -201,8 +202,29 @@ def get_current_upstream() -> str:
return upstream return upstream
def lang(key: str): def get_preferred_lang(req: web.Request) -> Language:
return webkbn_strings.get()[key] lang_cookie = req.cookies.get('lang', None)
if lang_cookie is None:
return Translation.DEFAULT_LANGUAGE
try:
return Language(lang_cookie)
except ValueError:
logger.debug(f"unsupported lang_cookie value: {lang_cookie}")
return Translation.DEFAULT_LANGUAGE
@web.middleware
async def language_middleware(request, handler):
lang_context_var.set(get_preferred_lang(request))
return await handler(request)
def lang(key, unit='web_kbn'):
strings = Translation(unit)
if isinstance(key, str) and '.' in key:
return strings.get(lang_context_var.get()).get(key)
else:
return strings.get(lang_context_var.get())[key]
async def render(req: web.Request, async def render(req: web.Request,
@ -214,7 +236,8 @@ async def render(req: web.Request,
context = {} context = {}
context = { context = {
**context, **context,
'head_static': get_head_static(assets) 'head_static': get_head_static(assets),
'user_lang': lang_context_var.get().value
} }
if title is not None: if title is not None:
context['title'] = title context['title'] = title
@ -231,6 +254,8 @@ async def index(req: web.Request):
cc = IpcamConfig() cc = IpcamConfig()
ctx['camzones'] = cc['zones'].keys() ctx['camzones'] = cc['zones'].keys()
ctx['allcams'] = cc.get_all_cam_names() ctx['allcams'] = cc.get_all_cam_names()
ctx['lang_enum'] = Language
ctx['lang_selected'] = lang_context_var.get()
return await render(req, 'index', return await render(req, 'index',
title=lang('sitename'), title=lang('sitename'),
@ -240,7 +265,7 @@ async def index(req: web.Request):
@routes.get('/modems.cgi') @routes.get('/modems.cgi')
async def modems(req: web.Request): async def modems(req: web.Request):
return await render(req, 'modems', return await render(req, 'modems',
title='Состояние модемов', title=lang('modem_statuses'),
context=dict(modems=ModemsConfig())) context=dict(modems=ModemsConfig()))
@ -280,9 +305,9 @@ async def modems_verbose(req: web.Request):
['Dialup connection', dialup_conn] ['Dialup connection', dialup_conn]
] ]
modem_name = ModemsConfig().getfullname(modem) modem_name = Translation('modems').get(lang_context_var.get())[modem]['full']
return await render(req, 'modem_verbose', return await render(req, 'modem_verbose',
title=f'Подробная информация о модеме "{modem_name}"', title=lang('modem_verbose_info_about_modem') % (modem_name,),
context=dict(data=data, modem_name=modem_name)) context=dict(data=data, modem_name=modem_name))
@ -296,7 +321,7 @@ async def sms(req: web.Request):
cl = get_modem_client(ModemsConfig()[modem]) cl = get_modem_client(ModemsConfig()[modem])
messages = cl.sms_list(1, 20, is_outbox) messages = cl.sms_list(1, 20, is_outbox)
return await render(req, 'sms', return await render(req, 'sms',
title=f"SMS-сообщения ({'исходящие' if is_outbox else 'входящие'}, {modem})", title=lang('sms_page_title') % (lang('sms_outbox') if is_outbox else lang('sms_inbox'), modem),
context=dict( context=dict(
modems=ModemsConfig(), modems=ModemsConfig(),
selected_modem=modem, selected_modem=modem,
@ -512,6 +537,7 @@ async def routing_dhcp(req: web.Request):
def init_web_app(app: web.Application): def init_web_app(app: web.Application):
app.middlewares.append(language_middleware)
aiohttp_jinja2.setup( aiohttp_jinja2.setup(
app, app,
loader=jinja2.FileSystemLoader(homekit_path('web', 'kbn_templates')), loader=jinja2.FileSystemLoader(homekit_path('web', 'kbn_templates')),
@ -519,12 +545,11 @@ def init_web_app(app: web.Application):
) )
env = aiohttp_jinja2.get_env(app) env = aiohttp_jinja2.get_env(app)
def filter_lang(key, unit='web_kbn'): # @pass_context is used only to prevent jinja2 from caching the result of lang filter results of constant values.
strings = Translation(unit) # as of now i don't know a better way of doing it
if isinstance(key, str) and '.' in key: @jinja2.pass_context
return strings.get().get(key) def filter_lang(ctx, key, unit='web_kbn'):
else: return lang(key, unit)
return strings.get()[key]
env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'), default=json_serial) env.filters['tojson'] = lambda obj: json.dumps(obj, separators=(',', ':'), default=json_serial)
env.filters['lang'] = filter_lang env.filters['lang'] = filter_lang

View File

@ -3,6 +3,7 @@ from .config import (
ConfigUnit, ConfigUnit,
AppConfigUnit, AppConfigUnit,
Translation, Translation,
Language,
config, config,
is_development_mode, is_development_mode,
setup_logging, setup_logging,

View File

@ -11,7 +11,6 @@ from argparse import ArgumentParser
from enum import Enum, auto from enum import Enum, auto
from os.path import join, isdir, isfile from os.path import join, isdir, isfile
from ..util import Addr from ..util import Addr
from pprint import pprint
class MyValidator(cerberus.Validator): class MyValidator(cerberus.Validator):
@ -55,6 +54,7 @@ class BaseConfigUnit(ABC):
return key in self._data return key in self._data
def load_from(self, path: str): def load_from(self, path: str):
print(f'loading config from {path}')
with open(path, 'r') as fd: with open(path, 'r') as fd:
self._data = yaml.safe_load(fd) self._data = yaml.safe_load(fd)
if self._data is None: if self._data is None:
@ -93,6 +93,7 @@ class ConfigUnit(BaseConfigUnit):
NAME = 'dumb' NAME = 'dumb'
_instance = None _instance = None
__initialized: bool
def __init_subclass__(cls, **kwargs): def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs) super().__init_subclass__(**kwargs)
@ -101,9 +102,14 @@ class ConfigUnit(BaseConfigUnit):
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs):
if cls._instance is None: if cls._instance is None:
cls._instance = super(ConfigUnit, cls).__new__(cls, *args, **kwargs) cls._instance = super(ConfigUnit, cls).__new__(cls, *args, **kwargs)
if cls._instance is not None:
cls._instance.__initialized = False
return cls._instance return cls._instance
def __init__(self, name=None, load=True): def __init__(self, name=None, load=True):
if self.__initialized:
return
super().__init__() super().__init__()
self._data = {} self._data = {}
@ -116,6 +122,8 @@ class ConfigUnit(BaseConfigUnit):
elif name is not None: elif name is not None:
self.NAME = name self.NAME = name
self.__initialized = True
@classmethod @classmethod
def get_config_path(cls, name=None) -> str: def get_config_path(cls, name=None) -> str:
if name is None: if name is None:
@ -262,6 +270,17 @@ class AppConfigUnit(ConfigUnit):
return self._logging_verbose return self._logging_verbose
class Language(Enum):
RU = 'ru'
EN = 'en'
def name(self):
if self == Language.RU:
return 'Русский'
elif self == Language.EN:
return 'English'
class TranslationUnit(BaseConfigUnit): class TranslationUnit(BaseConfigUnit):
pass pass
@ -270,10 +289,10 @@ TranslationInstances = {}
class Translation: class Translation:
LANGUAGES = ('en', 'ru') DEFAULT_LANGUAGE = Language.RU
DEFAULT_LANGUAGE = 'ru'
_langs: dict[str, TranslationUnit] _langs: dict[Language, TranslationUnit]
__initialized: bool
# def __init_subclass__(cls, **kwargs): # def __init_subclass__(cls, **kwargs):
# super().__init_subclass__(**kwargs) # super().__init_subclass__(**kwargs)
@ -283,14 +302,18 @@ class Translation:
unit = args[0] unit = args[0]
if unit not in TranslationInstances: if unit not in TranslationInstances:
TranslationInstances[unit] = super(Translation, cls).__new__(cls) TranslationInstances[unit] = super(Translation, cls).__new__(cls)
TranslationInstances[unit].__initialized = False
return TranslationInstances[unit] return TranslationInstances[unit]
def __init__(self, name: str): def __init__(self, name: str):
if self.__initialized:
return
self._langs = {} self._langs = {}
for lang in self.LANGUAGES: for lang in Language:
for dirname in CONFIG_DIRECTORIES: for dirname in CONFIG_DIRECTORIES:
if isdir(dirname): if isdir(dirname):
filename = join(dirname, f'i18n-{lang}', f'{name}.yaml') filename = join(dirname, f'i18n-{lang.value}', f'{name}.yaml')
if lang in self._langs: if lang in self._langs:
raise RuntimeError(f'{name}: translation unit for lang \'{lang}\' already loaded') raise RuntimeError(f'{name}: translation unit for lang \'{lang}\' already loaded')
self._langs[lang] = TranslationUnit() self._langs[lang] = TranslationUnit()
@ -301,7 +324,9 @@ class Translation:
if len(diff) > 0: if len(diff) > 0:
raise RuntimeError(f'{name}: translation units have difference in keys: ' + ', '.join(diff)) raise RuntimeError(f'{name}: translation units have difference in keys: ' + ', '.join(diff))
def get(self, lang: str = DEFAULT_LANGUAGE) -> TranslationUnit: self.__initialized = True
def get(self, lang: Language = DEFAULT_LANGUAGE) -> TranslationUnit:
return self._langs[lang] return self._langs[lang]

View File

@ -102,6 +102,15 @@ function removeClass(el, name) {
} }
function indexInit() { function indexInit() {
// language selector
var langSelect = document.getElementById('lang');
langSelect.addEventListener('change', function() {
var selectedLang = this.value;
document.cookie = "lang=" + selectedLang + ";path=/";
window.location.reload();
});
// camera blocks
var blocks = ['zones', 'list']; var blocks = ['zones', 'list'];
for (var i = 0; i < blocks.length; i++) { for (var i = 0; i < blocks.length; i++) {
var button = ge('cam_'+blocks[i]+'_btn'); var button = ge('cam_'+blocks[i]+'_btn');

View File

@ -18,7 +18,7 @@
{% endmacro %} {% endmacro %}
<!doctype html> <!doctype html>
<html data-bs-theme="auto"> <html lang="{{ user_lang }}" data-bs-theme="auto">
<head> <head>
<title>{{ title }}</title> <title>{{ title }}</title>
<meta http-equiv="content-type" content="text/html; charset=utf-8"> <meta http-equiv="content-type" content="text/html; charset=utf-8">

View File

@ -2,6 +2,13 @@
{% block content %} {% block content %}
<div class="container py-4"> <div class="container py-4">
<div style="float: right">
<select name="lang" id="lang">
{% for lang in lang_enum %}
<option value="{{ lang.value }}"{% if lang_selected == lang %} selected="selected"{% endif %}>{{ lang.name() }}</option>
{% endfor %}
</select>
</div>
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item active" aria-current="page">{{ "main"|lang }}</li> <li class="breadcrumb-item active" aria-current="page">{{ "main"|lang }}</li>

View File

@ -1,13 +1,13 @@
{% with level=modem_data.level %} {% with level=modem_data.level %}
<span class="text-secondary">Сигнал:</span> {% include 'signal_level.j2' %}<br> <span class="text-secondary">{{ "modem_signal"|lang }}:</span> {% include 'signal_level.j2' %}<br>
{% endwith %} {% endwith %}
<span class="text-secondary">Тип сети:</span> <b>{{ modem_data.type }}</b><br> <span class="text-secondary">{{ "modem_network_type"|lang }}:</span> <b>{{ modem_data.type }}</b><br>
<span class="text-secondary">RSSI:</span> {{ modem_data.rssi }}<br/> <span class="text-secondary">RSSI:</span> {{ modem_data.rssi }}<br/>
{% if modem_data.sinr %} {% if modem_data.sinr %}
<span class="text-secondary">SINR:</span> {{ modem_data.sinr }}<br/> <span class="text-secondary">SINR:</span> {{ modem_data.sinr }}<br/>
{% endif %} {% endif %}
<span class="text-secondary">Время соединения:</span> {{ modem_data.connected_time }}<br> <span class="text-secondary">{{ "modem_connection_time"|lang }}:</span> {{ modem_data.connected_time }}<br>
<span class="text-secondary">Принято/передано:</span> {{ modem_data.downloaded }} / {{ modem_data.uploaded }} <span class="text-secondary">{{ "modem_tx_rx"|lang }}:</span> {{ modem_data.downloaded }} / {{ modem_data.uploaded }}
<br> <br>
<a href="/modems/verbose.cgi?id={{ modem }}">Подробная информация</a> <a href="/modems/verbose.cgi?id={{ modem }}">{{ "modem_verbose_info"|lang }}</a>

View File

@ -2,7 +2,7 @@
{% block content %} {% block content %}
{{ breadcrumbs([ {{ breadcrumbs([
{'link': '/modems.cgi', 'text': "Модемы"}, {'link': '/modems.cgi', 'text': 'modems'|lang},
{'text': modem_name} {'text': modem_name}
]) }} ]) }}

View File

@ -4,7 +4,7 @@
{{ breadcrumbs([{'text': 'modems'|lang}]) }} {{ breadcrumbs([{'text': 'modems'|lang}]) }}
{% for modem in modems %} {% for modem in modems %}
<h6 class="text-primary{% if not loop.first %} mt-4{% endif %}">{{ modems.getfullname(modem) }}</h6> <h6 class="text-primary{% if not loop.first %} mt-4{% endif %}">{{ (modem|lang('modems'))['full'] }}</h6>
<div id="modem_data_{{ modem }}"> <div id="modem_data_{{ modem }}">
{% include "loading.j2" %} {% include "loading.j2" %}
</div> </div>

View File

@ -7,7 +7,7 @@
<div class="nav nav-tabs" id="nav-tab"> <div class="nav nav-tabs" id="nav-tab">
{% for modem in modems.keys() %} {% for modem in modems.keys() %}
{% if selected_modem != modem %}<a href="/sms.cgi?id={{ modem }}" class="text-decoration-none">{% endif %} {% if selected_modem != modem %}<a href="/sms.cgi?id={{ modem }}" class="text-decoration-none">{% endif %}
<button class="nav-link{% if modem == selected_modem %} active{% endif %}" type="button">{{ modems.getshortname(modem) }}</button> <button class="nav-link{% if modem == selected_modem %} active{% endif %}" type="button">{{ (modem|lang('modems'))['short'] }}</button>
{% if selected_modem != modem %}</a>{% endif %} {% if selected_modem != modem %}</a>{% endif %}
{% endfor %} {% endfor %}
</div> </div>
@ -43,11 +43,11 @@
</div> </div>
<h6 class="text-primary mt-4"> <h6 class="text-primary mt-4">
Последние {{ "sms_latest"|lang }}
{% if not is_outbox %} {% if not is_outbox %}
<b>входящие</b> <span class="text-black-50">|</span> <a href="/sms.cgi?id={{ selected_modem }}&amp;outbox=1">исходящие</a> <b>{{ "sms_inbox"|lang }}</b> <span class="text-black-50">|</span> <a href="/sms.cgi?id={{ selected_modem }}&amp;outbox=1">{{ "sms_outbox"|lang }}</a>
{% else %} {% else %}
<a href="/sms.cgi?id={{ selected_modem }}">входящие</a> <span class="text-black-50">|</span> <b>исходящие</b> <a href="/sms.cgi?id={{ selected_modem }}">{{ "sms_inbox"|lang }}</a> <span class="text-black-50">|</span> <b>{{ "sms_outbox"|lang }}</b>
{% endif %} {% endif %}
</h6> </h6>