json-admin 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- json_admin-0.1.0/LICENSE +28 -0
- json_admin-0.1.0/PKG-INFO +118 -0
- json_admin-0.1.0/README.md +73 -0
- json_admin-0.1.0/json_admin.egg-info/PKG-INFO +118 -0
- json_admin-0.1.0/json_admin.egg-info/SOURCES.txt +11 -0
- json_admin-0.1.0/json_admin.egg-info/dependency_links.txt +1 -0
- json_admin-0.1.0/json_admin.egg-info/requires.txt +4 -0
- json_admin-0.1.0/json_admin.egg-info/top_level.txt +1 -0
- json_admin-0.1.0/jsonadmin/__init__.py +6 -0
- json_admin-0.1.0/jsonadmin/admin.py +707 -0
- json_admin-0.1.0/jsonadmin/pages.py +72 -0
- json_admin-0.1.0/pyproject.toml +30 -0
- json_admin-0.1.0/setup.cfg +4 -0
json_admin-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, LoveBloodAndDiamonds
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: json-admin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple web admin interface to manage .json file
|
|
5
|
+
Author-email: LoveBloodAndDiamonds <ayazshakirzyanov27@gmail.com>
|
|
6
|
+
License: BSD 3-Clause License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025, LoveBloodAndDiamonds
|
|
9
|
+
|
|
10
|
+
Redistribution and use in source and binary forms, with or without
|
|
11
|
+
modification, are permitted provided that the following conditions are met:
|
|
12
|
+
|
|
13
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
14
|
+
list of conditions and the following disclaimer.
|
|
15
|
+
|
|
16
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
17
|
+
this list of conditions and the following disclaimer in the documentation
|
|
18
|
+
and/or other materials provided with the distribution.
|
|
19
|
+
|
|
20
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
21
|
+
contributors may be used to endorse or promote products derived from
|
|
22
|
+
this software without specific prior written permission.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
25
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
26
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
33
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
34
|
+
|
|
35
|
+
Project-URL: Github, https://github.com/LoveBloodAndDiamonds/json-admin
|
|
36
|
+
Project-URL: Author, https://t.me/LoveBloodAndDiamonds
|
|
37
|
+
Requires-Python: >=3.13
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Requires-Dist: jinja2>=3.1.6
|
|
41
|
+
Requires-Dist: litestar>=2.18.0
|
|
42
|
+
Requires-Dist: pydantic>=2.11.0
|
|
43
|
+
Requires-Dist: uvicorn>=0.41.0
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# Json-admin
|
|
47
|
+
|
|
48
|
+
Библиотека с реализацией простого интерфейса для управления .json-файлом с настройками приложения.
|
|
49
|
+
|
|
50
|
+
## Быстрый старт
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from litestar import Litestar
|
|
54
|
+
from pydantic import BaseModel, Field
|
|
55
|
+
|
|
56
|
+
from jsonadmin import Admin, HtmlPage, JsonPage
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AppSettings(BaseModel):
|
|
60
|
+
"""Настройки приложения."""
|
|
61
|
+
|
|
62
|
+
debug: bool = Field(default=False, description="Включает debug-режим")
|
|
63
|
+
retries: int = Field(default=3, ge=0, description="Количество повторных попыток")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FeatureFlags(BaseModel):
|
|
67
|
+
"""Настройки feature flags."""
|
|
68
|
+
|
|
69
|
+
use_cache: bool = Field(default=True, description="Включает кеширование")
|
|
70
|
+
beta_mode: bool = Field(default=False, description="Включает beta-режим")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
app = Litestar(route_handlers=[])
|
|
74
|
+
admin = Admin(
|
|
75
|
+
app=app,
|
|
76
|
+
passwd="super-strong-password",
|
|
77
|
+
title="My App Admin",
|
|
78
|
+
index="index.html",
|
|
79
|
+
login="login.html",
|
|
80
|
+
pages=[
|
|
81
|
+
HtmlPage(
|
|
82
|
+
slug="info",
|
|
83
|
+
title="Информация",
|
|
84
|
+
icon="fa-solid fa-circle-info",
|
|
85
|
+
content="""
|
|
86
|
+
<h2>Welcome</h2>
|
|
87
|
+
<p>This page is read-only and renders embedded HTML block.</p>
|
|
88
|
+
""",
|
|
89
|
+
),
|
|
90
|
+
JsonPage(
|
|
91
|
+
slug="settings",
|
|
92
|
+
title="Настройки",
|
|
93
|
+
file_path="data/app_settings.json",
|
|
94
|
+
model=AppSettings,
|
|
95
|
+
icon="fa-solid fa-gear",
|
|
96
|
+
),
|
|
97
|
+
JsonPage(
|
|
98
|
+
slug="features",
|
|
99
|
+
title="Флаги",
|
|
100
|
+
file_path="data/feature_flags.json",
|
|
101
|
+
model=FeatureFlags,
|
|
102
|
+
icon="fa-solid fa-flag",
|
|
103
|
+
),
|
|
104
|
+
],
|
|
105
|
+
base_url="/",
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
После запуска:
|
|
110
|
+
- `GET /` покажет форму входа по паролю.
|
|
111
|
+
- после входа доступны вкладки-страницы с JSON-редактором.
|
|
112
|
+
- `HtmlPage` — read-only вкладка с HTML-блоком. `content` может быть:
|
|
113
|
+
- встроенной строкой HTML,
|
|
114
|
+
- путём к `.html` файлу,
|
|
115
|
+
- `Callable[[], str]`.
|
|
116
|
+
- кнопка `Сохранить` валидирует данные через обязательную Pydantic-модель и сохраняет JSON в файл.
|
|
117
|
+
- можно переопределить интерфейс через Jinja-шаблоны в `jsonadmin/html/` (по умолчанию) или через `templates_dir=...`.
|
|
118
|
+
- для вкладок можно передать `icon` с классом Font Awesome, например `fa-solid fa-gear`.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Json-admin
|
|
2
|
+
|
|
3
|
+
Библиотека с реализацией простого интерфейса для управления .json-файлом с настройками приложения.
|
|
4
|
+
|
|
5
|
+
## Быстрый старт
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from litestar import Litestar
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from jsonadmin import Admin, HtmlPage, JsonPage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AppSettings(BaseModel):
|
|
15
|
+
"""Настройки приложения."""
|
|
16
|
+
|
|
17
|
+
debug: bool = Field(default=False, description="Включает debug-режим")
|
|
18
|
+
retries: int = Field(default=3, ge=0, description="Количество повторных попыток")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FeatureFlags(BaseModel):
|
|
22
|
+
"""Настройки feature flags."""
|
|
23
|
+
|
|
24
|
+
use_cache: bool = Field(default=True, description="Включает кеширование")
|
|
25
|
+
beta_mode: bool = Field(default=False, description="Включает beta-режим")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
app = Litestar(route_handlers=[])
|
|
29
|
+
admin = Admin(
|
|
30
|
+
app=app,
|
|
31
|
+
passwd="super-strong-password",
|
|
32
|
+
title="My App Admin",
|
|
33
|
+
index="index.html",
|
|
34
|
+
login="login.html",
|
|
35
|
+
pages=[
|
|
36
|
+
HtmlPage(
|
|
37
|
+
slug="info",
|
|
38
|
+
title="Информация",
|
|
39
|
+
icon="fa-solid fa-circle-info",
|
|
40
|
+
content="""
|
|
41
|
+
<h2>Welcome</h2>
|
|
42
|
+
<p>This page is read-only and renders embedded HTML block.</p>
|
|
43
|
+
""",
|
|
44
|
+
),
|
|
45
|
+
JsonPage(
|
|
46
|
+
slug="settings",
|
|
47
|
+
title="Настройки",
|
|
48
|
+
file_path="data/app_settings.json",
|
|
49
|
+
model=AppSettings,
|
|
50
|
+
icon="fa-solid fa-gear",
|
|
51
|
+
),
|
|
52
|
+
JsonPage(
|
|
53
|
+
slug="features",
|
|
54
|
+
title="Флаги",
|
|
55
|
+
file_path="data/feature_flags.json",
|
|
56
|
+
model=FeatureFlags,
|
|
57
|
+
icon="fa-solid fa-flag",
|
|
58
|
+
),
|
|
59
|
+
],
|
|
60
|
+
base_url="/",
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
После запуска:
|
|
65
|
+
- `GET /` покажет форму входа по паролю.
|
|
66
|
+
- после входа доступны вкладки-страницы с JSON-редактором.
|
|
67
|
+
- `HtmlPage` — read-only вкладка с HTML-блоком. `content` может быть:
|
|
68
|
+
- встроенной строкой HTML,
|
|
69
|
+
- путём к `.html` файлу,
|
|
70
|
+
- `Callable[[], str]`.
|
|
71
|
+
- кнопка `Сохранить` валидирует данные через обязательную Pydantic-модель и сохраняет JSON в файл.
|
|
72
|
+
- можно переопределить интерфейс через Jinja-шаблоны в `jsonadmin/html/` (по умолчанию) или через `templates_dir=...`.
|
|
73
|
+
- для вкладок можно передать `icon` с классом Font Awesome, например `fa-solid fa-gear`.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: json-admin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple web admin interface to manage .json file
|
|
5
|
+
Author-email: LoveBloodAndDiamonds <ayazshakirzyanov27@gmail.com>
|
|
6
|
+
License: BSD 3-Clause License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025, LoveBloodAndDiamonds
|
|
9
|
+
|
|
10
|
+
Redistribution and use in source and binary forms, with or without
|
|
11
|
+
modification, are permitted provided that the following conditions are met:
|
|
12
|
+
|
|
13
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
14
|
+
list of conditions and the following disclaimer.
|
|
15
|
+
|
|
16
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
17
|
+
this list of conditions and the following disclaimer in the documentation
|
|
18
|
+
and/or other materials provided with the distribution.
|
|
19
|
+
|
|
20
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
21
|
+
contributors may be used to endorse or promote products derived from
|
|
22
|
+
this software without specific prior written permission.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
25
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
26
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
33
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
34
|
+
|
|
35
|
+
Project-URL: Github, https://github.com/LoveBloodAndDiamonds/json-admin
|
|
36
|
+
Project-URL: Author, https://t.me/LoveBloodAndDiamonds
|
|
37
|
+
Requires-Python: >=3.13
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Requires-Dist: jinja2>=3.1.6
|
|
41
|
+
Requires-Dist: litestar>=2.18.0
|
|
42
|
+
Requires-Dist: pydantic>=2.11.0
|
|
43
|
+
Requires-Dist: uvicorn>=0.41.0
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# Json-admin
|
|
47
|
+
|
|
48
|
+
Библиотека с реализацией простого интерфейса для управления .json-файлом с настройками приложения.
|
|
49
|
+
|
|
50
|
+
## Быстрый старт
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from litestar import Litestar
|
|
54
|
+
from pydantic import BaseModel, Field
|
|
55
|
+
|
|
56
|
+
from jsonadmin import Admin, HtmlPage, JsonPage
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AppSettings(BaseModel):
|
|
60
|
+
"""Настройки приложения."""
|
|
61
|
+
|
|
62
|
+
debug: bool = Field(default=False, description="Включает debug-режим")
|
|
63
|
+
retries: int = Field(default=3, ge=0, description="Количество повторных попыток")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FeatureFlags(BaseModel):
|
|
67
|
+
"""Настройки feature flags."""
|
|
68
|
+
|
|
69
|
+
use_cache: bool = Field(default=True, description="Включает кеширование")
|
|
70
|
+
beta_mode: bool = Field(default=False, description="Включает beta-режим")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
app = Litestar(route_handlers=[])
|
|
74
|
+
admin = Admin(
|
|
75
|
+
app=app,
|
|
76
|
+
passwd="super-strong-password",
|
|
77
|
+
title="My App Admin",
|
|
78
|
+
index="index.html",
|
|
79
|
+
login="login.html",
|
|
80
|
+
pages=[
|
|
81
|
+
HtmlPage(
|
|
82
|
+
slug="info",
|
|
83
|
+
title="Информация",
|
|
84
|
+
icon="fa-solid fa-circle-info",
|
|
85
|
+
content="""
|
|
86
|
+
<h2>Welcome</h2>
|
|
87
|
+
<p>This page is read-only and renders embedded HTML block.</p>
|
|
88
|
+
""",
|
|
89
|
+
),
|
|
90
|
+
JsonPage(
|
|
91
|
+
slug="settings",
|
|
92
|
+
title="Настройки",
|
|
93
|
+
file_path="data/app_settings.json",
|
|
94
|
+
model=AppSettings,
|
|
95
|
+
icon="fa-solid fa-gear",
|
|
96
|
+
),
|
|
97
|
+
JsonPage(
|
|
98
|
+
slug="features",
|
|
99
|
+
title="Флаги",
|
|
100
|
+
file_path="data/feature_flags.json",
|
|
101
|
+
model=FeatureFlags,
|
|
102
|
+
icon="fa-solid fa-flag",
|
|
103
|
+
),
|
|
104
|
+
],
|
|
105
|
+
base_url="/",
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
После запуска:
|
|
110
|
+
- `GET /` покажет форму входа по паролю.
|
|
111
|
+
- после входа доступны вкладки-страницы с JSON-редактором.
|
|
112
|
+
- `HtmlPage` — read-only вкладка с HTML-блоком. `content` может быть:
|
|
113
|
+
- встроенной строкой HTML,
|
|
114
|
+
- путём к `.html` файлу,
|
|
115
|
+
- `Callable[[], str]`.
|
|
116
|
+
- кнопка `Сохранить` валидирует данные через обязательную Pydantic-модель и сохраняет JSON в файл.
|
|
117
|
+
- можно переопределить интерфейс через Jinja-шаблоны в `jsonadmin/html/` (по умолчанию) или через `templates_dir=...`.
|
|
118
|
+
- для вкладок можно передать `icon` с классом Font Awesome, например `fa-solid fa-gear`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
json_admin.egg-info/PKG-INFO
|
|
5
|
+
json_admin.egg-info/SOURCES.txt
|
|
6
|
+
json_admin.egg-info/dependency_links.txt
|
|
7
|
+
json_admin.egg-info/requires.txt
|
|
8
|
+
json_admin.egg-info/top_level.txt
|
|
9
|
+
jsonadmin/__init__.py
|
|
10
|
+
jsonadmin/admin.py
|
|
11
|
+
jsonadmin/pages.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jsonadmin
|
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"""Минимальная админка для редактирования JSON-файлов на Litestar."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from jinja2 import Environment, FileSystemLoader, TemplateNotFound, select_autoescape
|
|
11
|
+
from litestar import Litestar, Request, Response, get, post
|
|
12
|
+
from litestar.datastructures import Cookie
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from jsonadmin.pages import BasePage, HtmlPage, JsonPage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Admin:
|
|
19
|
+
"""Управляет JSON-страницами и авторизацией по единому паролю."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
app: Litestar,
|
|
24
|
+
passwd: str,
|
|
25
|
+
pages: list[BasePage] | None = None,
|
|
26
|
+
title: str = "JSON Admin",
|
|
27
|
+
templates_dir: str | Path | None = None,
|
|
28
|
+
index: str = "index.html",
|
|
29
|
+
login: str = "login.html",
|
|
30
|
+
base_url: str = "/",
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Инициализирует админ-панель и регистрирует маршруты.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
app: Экземпляр Litestar-приложения.
|
|
36
|
+
passwd: Пароль для входа в админку.
|
|
37
|
+
pages: Список вкладок админки.
|
|
38
|
+
title: Название приложения в интерфейсе админки.
|
|
39
|
+
templates_dir: Директория с пользовательскими HTML-шаблонами. По умолчанию используется `jsonadmin/html`.
|
|
40
|
+
index: Имя шаблона страницы редактора.
|
|
41
|
+
login: Имя шаблона страницы входа.
|
|
42
|
+
base_url: Базовый URL, на котором будет доступна админка.
|
|
43
|
+
|
|
44
|
+
"""
|
|
45
|
+
self._app: Litestar = app
|
|
46
|
+
self._passwd: str = passwd
|
|
47
|
+
self._title: str = title
|
|
48
|
+
self._base_url: str = self._normalize_base_url(base_url)
|
|
49
|
+
if templates_dir is None:
|
|
50
|
+
self._templates_dir = Path(__file__).resolve().parent / "html"
|
|
51
|
+
else:
|
|
52
|
+
self._templates_dir = Path(templates_dir)
|
|
53
|
+
self._index_template: str = index
|
|
54
|
+
self._login_template: str = login
|
|
55
|
+
self._template_env: Environment | None = self._init_template_env(self._templates_dir)
|
|
56
|
+
self._cookie_name: str = "jsonadmin_session"
|
|
57
|
+
self._pages: dict[str, BasePage] = {}
|
|
58
|
+
self._token_secret: str = hashlib.sha256(passwd.encode("utf-8")).hexdigest()
|
|
59
|
+
self._register_routes()
|
|
60
|
+
|
|
61
|
+
for page in pages or []:
|
|
62
|
+
self.add_page(page)
|
|
63
|
+
|
|
64
|
+
def add_page(self, page: BasePage) -> None:
|
|
65
|
+
"""Регистрирует новую вкладку админки.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
page: Описание страницы админки.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ValueError: Если slug уже занят.
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
if page.slug in self._pages:
|
|
75
|
+
raise ValueError(f"Page with slug='{page.slug}' is already registered")
|
|
76
|
+
self._pages[page.slug] = page
|
|
77
|
+
|
|
78
|
+
def _register_routes(self) -> None:
|
|
79
|
+
"""Добавляет маршруты админки в Litestar-приложение."""
|
|
80
|
+
|
|
81
|
+
@get(path=self._route(""))
|
|
82
|
+
async def index(request: Request[Any, Any, Any]) -> Response[str]:
|
|
83
|
+
"""Отображает главную страницу или форму входа.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
request: Входящий HTTP-запрос.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Response[str]: HTML-ответ.
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
if not self._is_authorized(request):
|
|
93
|
+
return self._login_page(error_text="")
|
|
94
|
+
|
|
95
|
+
if not self._pages:
|
|
96
|
+
return self._html_response(f"<h1>{self._title}</h1><p>No registered pages.</p>")
|
|
97
|
+
|
|
98
|
+
first_slug = next(iter(self._pages))
|
|
99
|
+
return self._redirect(self._route(f"/page/{first_slug}"))
|
|
100
|
+
|
|
101
|
+
@post(path=self._route("/login"))
|
|
102
|
+
async def login(request: Request[Any, Any, Any]) -> Response[str]:
|
|
103
|
+
"""Проверяет пароль и устанавливает сессионную cookie.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
request: Входящий HTTP-запрос.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Response[str]: Редирект на страницу админки или форма с ошибкой.
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
form = await request.form()
|
|
113
|
+
input_passwd = str(form.get("passwd", ""))
|
|
114
|
+
if not hmac.compare_digest(input_passwd, self._passwd):
|
|
115
|
+
return self._login_page(error_text="Invalid password")
|
|
116
|
+
|
|
117
|
+
response = self._redirect(self._route(""))
|
|
118
|
+
response.set_cookie(self._auth_cookie())
|
|
119
|
+
return response
|
|
120
|
+
|
|
121
|
+
@post(path=self._route("/logout"))
|
|
122
|
+
async def logout() -> Response[str]:
|
|
123
|
+
"""Удаляет сессионную cookie и завершает сессию.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Response[str]: Редирект на форму входа.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
response = self._redirect(self._route(""))
|
|
130
|
+
response.delete_cookie(key=self._cookie_name, path=self._route(""))
|
|
131
|
+
return response
|
|
132
|
+
|
|
133
|
+
@get(path=self._route("/page/{slug:str}"))
|
|
134
|
+
async def page_view(request: Request[Any, Any, Any], slug: str) -> Response[str]:
|
|
135
|
+
"""Показывает страницу редактирования JSON.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
request: Входящий HTTP-запрос.
|
|
139
|
+
slug: Идентификатор страницы.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Response[str]: HTML страницы редактора.
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
if not self._is_authorized(request):
|
|
146
|
+
return self._redirect(self._route(""))
|
|
147
|
+
|
|
148
|
+
page = self._pages.get(slug)
|
|
149
|
+
if page is None:
|
|
150
|
+
return self._html_response("<h1>404</h1><p>Page not found</p>", status_code=404)
|
|
151
|
+
|
|
152
|
+
if isinstance(page, HtmlPage):
|
|
153
|
+
html_content = await self._resolve_html_page_content(page)
|
|
154
|
+
return self._html_page(page=page, content_html=html_content, error_text="")
|
|
155
|
+
if not isinstance(page, JsonPage):
|
|
156
|
+
return self._html_response(
|
|
157
|
+
"<h1>500</h1><p>Unsupported page type</p>", status_code=500
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
payload, load_error = await self._read_json_payload(page.path)
|
|
161
|
+
pretty_json = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
162
|
+
schema_text = self._build_schema_text(page.model)
|
|
163
|
+
return self._editor_page(
|
|
164
|
+
page=page, json_text=pretty_json, schema_text=schema_text, error_text=load_error
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@post(path=self._route("/page/{slug:str}"))
|
|
168
|
+
async def save_page(request: Request[Any, Any, Any], slug: str) -> Response[str]:
|
|
169
|
+
"""Сохраняет JSON после валидации.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
request: Входящий HTTP-запрос.
|
|
173
|
+
slug: Идентификатор страницы.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Response[str]: HTML страницы редактора с результатом операции.
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
if not self._is_authorized(request):
|
|
180
|
+
return self._redirect(self._route(""))
|
|
181
|
+
|
|
182
|
+
page = self._pages.get(slug)
|
|
183
|
+
if page is None:
|
|
184
|
+
return self._html_response("<h1>404</h1><p>Page not found</p>", status_code=404)
|
|
185
|
+
|
|
186
|
+
if isinstance(page, HtmlPage):
|
|
187
|
+
html_content = await self._resolve_html_page_content(page)
|
|
188
|
+
return self._html_page(
|
|
189
|
+
page=page,
|
|
190
|
+
content_html=html_content,
|
|
191
|
+
error_text="HtmlPage is read-only.",
|
|
192
|
+
status_code=405,
|
|
193
|
+
)
|
|
194
|
+
if not isinstance(page, JsonPage):
|
|
195
|
+
return self._html_response(
|
|
196
|
+
"<h1>500</h1><p>Unsupported page type</p>", status_code=500
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
form = await request.form()
|
|
200
|
+
json_text = str(form.get("payload", ""))
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
raw_payload = json.loads(json_text)
|
|
204
|
+
except json.JSONDecodeError as exc:
|
|
205
|
+
schema_text = self._build_schema_text(page.model)
|
|
206
|
+
return self._editor_page(
|
|
207
|
+
page=page,
|
|
208
|
+
json_text=json_text,
|
|
209
|
+
schema_text=schema_text,
|
|
210
|
+
error_text=f"JSON error: {exc}",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
saved_payload = page.validate_payload(raw_payload)
|
|
215
|
+
except Exception as exc: # noqa: BLE001
|
|
216
|
+
schema_text = self._build_schema_text(page.model)
|
|
217
|
+
return self._editor_page(
|
|
218
|
+
page=page,
|
|
219
|
+
json_text=json_text,
|
|
220
|
+
schema_text=schema_text,
|
|
221
|
+
error_text=f"Model validation error: {exc}",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
await self._write_json_payload(page.path, saved_payload)
|
|
225
|
+
pretty_json = json.dumps(saved_payload, ensure_ascii=False, indent=2)
|
|
226
|
+
schema_text = self._build_schema_text(page.model)
|
|
227
|
+
return self._editor_page(
|
|
228
|
+
page=page,
|
|
229
|
+
json_text=pretty_json,
|
|
230
|
+
schema_text=schema_text,
|
|
231
|
+
error_text="",
|
|
232
|
+
success_text="Изменения сохранены",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Регистрируем обработчики в Litestar-приложении.
|
|
236
|
+
for handler in (index, login, logout, page_view, save_page):
|
|
237
|
+
self._app.register(handler)
|
|
238
|
+
|
|
239
|
+
def _init_template_env(self, templates_dir: Path) -> Environment | None:
|
|
240
|
+
"""Создает Jinja-окружение для HTML-шаблонов.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
templates_dir: Директория с шаблонами.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Environment | None: Настроенное окружение или `None`, если директории нет.
|
|
247
|
+
|
|
248
|
+
"""
|
|
249
|
+
if not templates_dir.exists() or not templates_dir.is_dir():
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
return Environment(
|
|
253
|
+
loader=FileSystemLoader(str(templates_dir)),
|
|
254
|
+
autoescape=select_autoescape(enabled_extensions=("html", "htm")),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def _build_nav_pages(self, active_slug: str) -> list[dict[str, str | bool]]:
|
|
258
|
+
"""Собирает список вкладок для навигации.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
active_slug: Slug текущей активной страницы.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
list[dict[str, str | bool]]: Данные вкладок для шаблона.
|
|
265
|
+
|
|
266
|
+
"""
|
|
267
|
+
nav_pages: list[dict[str, str | bool]] = []
|
|
268
|
+
for nav_page in self._pages.values():
|
|
269
|
+
nav_pages.append( # noqa
|
|
270
|
+
{
|
|
271
|
+
"slug": nav_page.slug,
|
|
272
|
+
"title": nav_page.title,
|
|
273
|
+
"icon": nav_page.icon,
|
|
274
|
+
"href": self._route(f"/page/{nav_page.slug}"),
|
|
275
|
+
"active": nav_page.slug == active_slug,
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
return nav_pages
|
|
279
|
+
|
|
280
|
+
async def _resolve_html_page_content(self, page: HtmlPage) -> str:
|
|
281
|
+
"""Получает HTML-контент для HtmlPage.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
page: Конфигурация HTML-вкладки.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
str: Готовый HTML-блок.
|
|
288
|
+
|
|
289
|
+
"""
|
|
290
|
+
content = page.content
|
|
291
|
+
if callable(content):
|
|
292
|
+
|
|
293
|
+
def _call() -> str:
|
|
294
|
+
return content()
|
|
295
|
+
|
|
296
|
+
rendered = await asyncio.to_thread(_call)
|
|
297
|
+
return str(rendered)
|
|
298
|
+
|
|
299
|
+
if isinstance(content, Path):
|
|
300
|
+
return await asyncio.to_thread(content.read_text, "utf-8")
|
|
301
|
+
|
|
302
|
+
possible_path = Path(content)
|
|
303
|
+
if possible_path.suffix.lower() in {".html", ".htm"} and possible_path.exists():
|
|
304
|
+
return await asyncio.to_thread(possible_path.read_text, "utf-8")
|
|
305
|
+
|
|
306
|
+
return content
|
|
307
|
+
|
|
308
|
+
def _render_editor_template(
|
|
309
|
+
self,
|
|
310
|
+
page: BasePage,
|
|
311
|
+
json_text: str,
|
|
312
|
+
schema_text: str,
|
|
313
|
+
error_text: str,
|
|
314
|
+
success_text: str,
|
|
315
|
+
content_html: str = "",
|
|
316
|
+
save_action: str = "",
|
|
317
|
+
) -> str | None:
|
|
318
|
+
"""Рендерит пользовательский HTML-шаблон editor-страницы.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
page: Активная страница.
|
|
322
|
+
json_text: Содержимое JSON в textarea.
|
|
323
|
+
schema_text: JSON-схема модели.
|
|
324
|
+
error_text: Сообщение об ошибке.
|
|
325
|
+
success_text: Сообщение об успехе.
|
|
326
|
+
content_html: Контент HtmlPage для встраивания в основной блок.
|
|
327
|
+
save_action: URL для сохранения JsonPage.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
str | None: HTML-контент или `None`, если шаблоны недоступны.
|
|
331
|
+
|
|
332
|
+
"""
|
|
333
|
+
if self._template_env is None:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
template = self._template_env.get_template(self._index_template)
|
|
338
|
+
except TemplateNotFound:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
return template.render(
|
|
342
|
+
app_title=self._title,
|
|
343
|
+
page_title=page.title,
|
|
344
|
+
nav_pages=self._build_nav_pages(active_slug=page.slug),
|
|
345
|
+
save_action=save_action,
|
|
346
|
+
logout_action=self._route("/logout"),
|
|
347
|
+
payload=json_text,
|
|
348
|
+
schema_text=schema_text,
|
|
349
|
+
error_text=error_text,
|
|
350
|
+
success_text=success_text,
|
|
351
|
+
content_html=content_html,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _render_login_template(self, error_text: str) -> str | None:
|
|
355
|
+
"""Рендерит пользовательский HTML-шаблон страницы входа.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
error_text: Сообщение об ошибке входа.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
str | None: HTML-контент или `None`, если шаблоны недоступны.
|
|
362
|
+
|
|
363
|
+
"""
|
|
364
|
+
if self._template_env is None:
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
template = self._template_env.get_template(self._login_template)
|
|
369
|
+
except TemplateNotFound:
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
return template.render(
|
|
373
|
+
app_title=self._title,
|
|
374
|
+
page_title="Login",
|
|
375
|
+
login_action=self._route("/login"),
|
|
376
|
+
error_text=error_text,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def _normalize_base_url(self, base_url: str) -> str:
|
|
380
|
+
"""Нормализует базовый URL для стабильного роутинга.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
base_url: Исходный URL.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
str: Нормализованный путь, начинающийся с `/`.
|
|
387
|
+
|
|
388
|
+
"""
|
|
389
|
+
if not base_url:
|
|
390
|
+
return "/"
|
|
391
|
+
|
|
392
|
+
if not base_url.startswith("/"):
|
|
393
|
+
base_url = f"/{base_url}"
|
|
394
|
+
|
|
395
|
+
if len(base_url) > 1 and base_url.endswith("/"):
|
|
396
|
+
base_url = base_url[:-1]
|
|
397
|
+
|
|
398
|
+
return base_url
|
|
399
|
+
|
|
400
|
+
def _route(self, suffix: str) -> str:
|
|
401
|
+
"""Строит конечный путь маршрута.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
suffix: Дополнение к базовому URL.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
str: Полный маршрут.
|
|
408
|
+
|
|
409
|
+
"""
|
|
410
|
+
suffix = suffix or ""
|
|
411
|
+
if suffix and not suffix.startswith("/"):
|
|
412
|
+
suffix = f"/{suffix}"
|
|
413
|
+
|
|
414
|
+
if self._base_url == "/":
|
|
415
|
+
return suffix or "/"
|
|
416
|
+
|
|
417
|
+
return f"{self._base_url}{suffix}"
|
|
418
|
+
|
|
419
|
+
def _make_session_token(self) -> str:
|
|
420
|
+
"""Создает подпись текущей сессии на основе пароля.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
str: Токен сессионной cookie.
|
|
424
|
+
|
|
425
|
+
"""
|
|
426
|
+
return hmac.new(
|
|
427
|
+
key=self._token_secret.encode("utf-8"),
|
|
428
|
+
msg=b"jsonadmin-authenticated",
|
|
429
|
+
digestmod=hashlib.sha256,
|
|
430
|
+
).hexdigest()
|
|
431
|
+
|
|
432
|
+
def _is_authorized(self, request: Request[Any, Any, Any]) -> bool:
|
|
433
|
+
"""Проверяет валидность cookie авторизации.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
request: Входящий HTTP-запрос.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
bool: `True`, если сессия подтверждена.
|
|
440
|
+
|
|
441
|
+
"""
|
|
442
|
+
cookie = request.cookies.get(self._cookie_name)
|
|
443
|
+
if not cookie:
|
|
444
|
+
return False
|
|
445
|
+
return hmac.compare_digest(cookie, self._make_session_token())
|
|
446
|
+
|
|
447
|
+
def _auth_cookie(self) -> Cookie:
|
|
448
|
+
"""Создает защищенную cookie после успешного входа.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Cookie: Конфигурация cookie авторизации.
|
|
452
|
+
|
|
453
|
+
"""
|
|
454
|
+
return Cookie(
|
|
455
|
+
key=self._cookie_name,
|
|
456
|
+
value=self._make_session_token(),
|
|
457
|
+
httponly=True,
|
|
458
|
+
secure=False,
|
|
459
|
+
samesite="strict",
|
|
460
|
+
path=self._route(""),
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
async def _read_json_payload(self, file_path: Path) -> tuple[Any, str]:
|
|
464
|
+
"""Читает JSON-файл и возвращает данные либо ошибку.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
file_path: Путь до файла.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
tuple[Any, str]: Распарсенные данные и текст ошибки.
|
|
471
|
+
|
|
472
|
+
"""
|
|
473
|
+
if not file_path.exists():
|
|
474
|
+
return {}, ""
|
|
475
|
+
|
|
476
|
+
def _read_text() -> str:
|
|
477
|
+
return file_path.read_text(encoding="utf-8")
|
|
478
|
+
|
|
479
|
+
text = await asyncio.to_thread(_read_text)
|
|
480
|
+
if not text.strip():
|
|
481
|
+
return {}, ""
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
return json.loads(text), ""
|
|
485
|
+
except json.JSONDecodeError as exc:
|
|
486
|
+
return {}, f"File contains invalid JSON: {exc}"
|
|
487
|
+
|
|
488
|
+
async def _write_json_payload(self, file_path: Path, payload: Any) -> None:
|
|
489
|
+
"""Сохраняет JSON в файл в читаемом формате.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
file_path: Путь до файла.
|
|
493
|
+
payload: Данные для сериализации.
|
|
494
|
+
|
|
495
|
+
"""
|
|
496
|
+
json_text = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
497
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
498
|
+
|
|
499
|
+
def _write_text() -> None:
|
|
500
|
+
file_path.write_text(json_text, encoding="utf-8")
|
|
501
|
+
|
|
502
|
+
await asyncio.to_thread(_write_text)
|
|
503
|
+
|
|
504
|
+
def _build_schema_text(self, model: type[BaseModel]) -> str:
|
|
505
|
+
"""Формирует текст JSON-схемы из Pydantic-модели.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
model: Модель страницы.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
str: Схема или пустая строка.
|
|
512
|
+
|
|
513
|
+
"""
|
|
514
|
+
schema = model.model_json_schema()
|
|
515
|
+
return json.dumps(schema, ensure_ascii=False, indent=2)
|
|
516
|
+
|
|
517
|
+
def _login_page(self, error_text: str) -> Response[str]:
|
|
518
|
+
"""Строит HTML формы входа.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
error_text: Сообщение об ошибке авторизации.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
Response[str]: HTML-ответ.
|
|
525
|
+
|
|
526
|
+
"""
|
|
527
|
+
rendered_html = self._render_login_template(error_text=error_text)
|
|
528
|
+
if rendered_html is not None:
|
|
529
|
+
return self._html_response(rendered_html)
|
|
530
|
+
|
|
531
|
+
error_html = f"<p>{error_text}</p>" if error_text else ""
|
|
532
|
+
html = f"""
|
|
533
|
+
<!doctype html>
|
|
534
|
+
<html lang="ru">
|
|
535
|
+
<head><meta charset="utf-8"><title>Вход</title></head>
|
|
536
|
+
<body>
|
|
537
|
+
<h1>{self._title}</h1>
|
|
538
|
+
{error_html}
|
|
539
|
+
<form method="post" action="{self._route("/login")}">
|
|
540
|
+
<label for="passwd">Password</label><br>
|
|
541
|
+
<input id="passwd" name="passwd" type="password" required autofocus><br><br>
|
|
542
|
+
<button type="submit">Sign in</button>
|
|
543
|
+
</form>
|
|
544
|
+
</body>
|
|
545
|
+
</html>
|
|
546
|
+
"""
|
|
547
|
+
return self._html_response(html)
|
|
548
|
+
|
|
549
|
+
def _editor_page(
|
|
550
|
+
self,
|
|
551
|
+
page: JsonPage,
|
|
552
|
+
json_text: str,
|
|
553
|
+
schema_text: str,
|
|
554
|
+
error_text: str,
|
|
555
|
+
success_text: str = "",
|
|
556
|
+
) -> Response[str]:
|
|
557
|
+
"""Строит HTML-страницу редактирования JSON.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
page: Конфигурация активной вкладки.
|
|
561
|
+
json_text: Текущий JSON для textarea.
|
|
562
|
+
schema_text: JSON-схема модели для показа.
|
|
563
|
+
error_text: Read or save error message.
|
|
564
|
+
success_text: Сообщение об успешном сохранении.
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
Response[str]: HTML-ответ страницы.
|
|
568
|
+
|
|
569
|
+
"""
|
|
570
|
+
rendered_html = self._render_editor_template(
|
|
571
|
+
page=page,
|
|
572
|
+
json_text=json_text,
|
|
573
|
+
schema_text=schema_text,
|
|
574
|
+
error_text=error_text,
|
|
575
|
+
success_text=success_text,
|
|
576
|
+
save_action=self._route(f"/page/{page.slug}"),
|
|
577
|
+
)
|
|
578
|
+
if rendered_html is not None:
|
|
579
|
+
return self._html_response(rendered_html)
|
|
580
|
+
|
|
581
|
+
nav_parts: list[str] = []
|
|
582
|
+
for nav_page in self._pages.values():
|
|
583
|
+
icon_html = f'<i class="{nav_page.icon}"></i> ' if nav_page.icon else ""
|
|
584
|
+
if nav_page.slug == page.slug:
|
|
585
|
+
nav_parts.append(f"<b>{icon_html}{nav_page.title}</b>")
|
|
586
|
+
else:
|
|
587
|
+
nav_parts.append(
|
|
588
|
+
f'<a href="{self._route(f"/page/{nav_page.slug}")}">{icon_html}{nav_page.title}</a>'
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
nav_html = " | ".join(nav_parts)
|
|
592
|
+
error_html = f"<p>{error_text}</p>" if error_text else ""
|
|
593
|
+
success_html = f"<p>{success_text}</p>" if success_text else ""
|
|
594
|
+
schema_html = f"<h3>Схема модели</h3><pre>{schema_text}</pre>" if schema_text else ""
|
|
595
|
+
|
|
596
|
+
html = f"""
|
|
597
|
+
<!doctype html>
|
|
598
|
+
<html lang="ru">
|
|
599
|
+
<head><meta charset="utf-8"><title>{page.title}</title></head>
|
|
600
|
+
<body>
|
|
601
|
+
<h1>{self._title}</h1>
|
|
602
|
+
<p>{nav_html}</p>
|
|
603
|
+
<form method="post" action="{self._route(f"/page/{page.slug}")}">
|
|
604
|
+
<h2>{page.title}</h2>
|
|
605
|
+
{error_html}
|
|
606
|
+
{success_html}
|
|
607
|
+
<textarea name="payload" rows="30" cols="120">{json_text}</textarea><br><br>
|
|
608
|
+
<button type="submit">Сохранить</button>
|
|
609
|
+
</form>
|
|
610
|
+
<form method="post" action="{self._route("/logout")}">
|
|
611
|
+
<button type="submit">Выйти</button>
|
|
612
|
+
</form>
|
|
613
|
+
{schema_html}
|
|
614
|
+
</body>
|
|
615
|
+
</html>
|
|
616
|
+
"""
|
|
617
|
+
return self._html_response(html)
|
|
618
|
+
|
|
619
|
+
def _html_page(
|
|
620
|
+
self,
|
|
621
|
+
page: HtmlPage,
|
|
622
|
+
content_html: str,
|
|
623
|
+
error_text: str,
|
|
624
|
+
status_code: int = 200,
|
|
625
|
+
) -> Response[str]:
|
|
626
|
+
"""Строит read-only страницу HtmlPage.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
page: Активная HTML-вкладка.
|
|
630
|
+
content_html: HTML-блок страницы.
|
|
631
|
+
error_text: Сообщение об ошибке.
|
|
632
|
+
status_code: HTTP-статус ответа.
|
|
633
|
+
|
|
634
|
+
Returns:
|
|
635
|
+
Response[str]: HTML-ответ страницы.
|
|
636
|
+
|
|
637
|
+
"""
|
|
638
|
+
rendered_html = self._render_editor_template(
|
|
639
|
+
page=page,
|
|
640
|
+
json_text="",
|
|
641
|
+
schema_text="",
|
|
642
|
+
error_text=error_text,
|
|
643
|
+
success_text="",
|
|
644
|
+
content_html=content_html,
|
|
645
|
+
save_action="",
|
|
646
|
+
)
|
|
647
|
+
if rendered_html is not None:
|
|
648
|
+
return self._html_response(rendered_html, status_code=status_code)
|
|
649
|
+
|
|
650
|
+
nav_parts: list[str] = []
|
|
651
|
+
for nav_page in self._pages.values():
|
|
652
|
+
icon_html = f'<i class="{nav_page.icon}"></i> ' if nav_page.icon else ""
|
|
653
|
+
if nav_page.slug == page.slug:
|
|
654
|
+
nav_parts.append(f"<b>{icon_html}{nav_page.title}</b>")
|
|
655
|
+
else:
|
|
656
|
+
nav_parts.append(
|
|
657
|
+
f'<a href="{self._route(f"/page/{nav_page.slug}")}">{icon_html}{nav_page.title}</a>'
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
nav_html = " | ".join(nav_parts)
|
|
661
|
+
error_html = f"<p>{error_text}</p>" if error_text else ""
|
|
662
|
+
|
|
663
|
+
html = f"""
|
|
664
|
+
<!doctype html>
|
|
665
|
+
<html lang="ru">
|
|
666
|
+
<head><meta charset="utf-8"><title>{page.title}</title></head>
|
|
667
|
+
<body>
|
|
668
|
+
<h1>{self._title}</h1>
|
|
669
|
+
<p>{nav_html}</p>
|
|
670
|
+
{error_html}
|
|
671
|
+
<div>{content_html}</div>
|
|
672
|
+
<form method="post" action="{self._route("/logout")}">
|
|
673
|
+
<button type="submit">Out</button>
|
|
674
|
+
</form>
|
|
675
|
+
</body>
|
|
676
|
+
</html>
|
|
677
|
+
"""
|
|
678
|
+
return self._html_response(html, status_code=status_code)
|
|
679
|
+
|
|
680
|
+
def _html_response(self, content: str, status_code: int = 200) -> Response[str]:
|
|
681
|
+
"""Создает HTML-ответ с едиными заголовками.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
content: Тело HTML-страницы.
|
|
685
|
+
status_code: HTTP-код ответа.
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
Response[str]: Готовый HTTP-ответ.
|
|
689
|
+
|
|
690
|
+
"""
|
|
691
|
+
return Response(
|
|
692
|
+
content=content,
|
|
693
|
+
media_type="text/html; charset=utf-8",
|
|
694
|
+
status_code=status_code,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
def _redirect(self, location: str) -> Response[str]:
|
|
698
|
+
"""Создает HTTP-редирект.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
location: URL для перехода.
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
Response[str]: Ответ с кодом 303.
|
|
705
|
+
|
|
706
|
+
"""
|
|
707
|
+
return Response(content="", status_code=303, headers={"Location": location})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Модели страниц для админки."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BasePage(BaseModel):
|
|
11
|
+
"""Общая модель страницы админки.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
slug: Уникальный идентификатор страницы для URL.
|
|
15
|
+
title: Заголовок вкладки в интерфейсе.
|
|
16
|
+
icon: CSS-класс иконки (например, `fa-solid fa-gear`).
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
21
|
+
|
|
22
|
+
slug: str = Field(min_length=1)
|
|
23
|
+
title: str = Field(min_length=1)
|
|
24
|
+
icon: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JsonPage(BasePage):
|
|
28
|
+
"""Конфигурация вкладки редактирования JSON.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
file_path: Путь до JSON-файла.
|
|
32
|
+
model: Pydantic-модель для валидации содержимого.
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
file_path: str | Path
|
|
37
|
+
model: type[BaseModel]
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def path(self) -> Path:
|
|
41
|
+
"""Возвращает путь к JSON-файлу как Path."""
|
|
42
|
+
return Path(self.file_path)
|
|
43
|
+
|
|
44
|
+
def validate_payload(self, payload: Any) -> Any:
|
|
45
|
+
"""Проверяет JSON через модель страницы.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
payload: Данные, распарсенные из JSON.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Any: Проверенные данные для сохранения.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
validated = self.model.model_validate(payload)
|
|
55
|
+
return validated.model_dump(mode="json")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HtmlPage(BasePage):
|
|
59
|
+
"""Конфигурация read-only HTML вкладки.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
content: Контент вкладки.
|
|
63
|
+
Поддерживается:
|
|
64
|
+
- str (встроенный HTML),
|
|
65
|
+
- str/Path к .html файлу,
|
|
66
|
+
- Callable[[], str] для динамической генерации.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
model_config = ConfigDict(frozen=True, extra="forbid", arbitrary_types_allowed=True)
|
|
71
|
+
|
|
72
|
+
content: str | Path | Callable[[], str]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "json-admin"
|
|
3
|
+
|
|
4
|
+
# • PATCH (x.y.Z) → увеличивается при багфиксе, который не ломает совместимость.
|
|
5
|
+
# • MINOR (x.Y.z) → увеличивается при добавлении новой функциональности, но без ломающих изменений (backward-compatible).
|
|
6
|
+
# • MAJOR (X.y.z) → увеличивается при изменениях, которые ломают обратную совместимость.
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
|
|
9
|
+
description = "Simple web admin interface to manage .json file"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = {file = "LICENSE"}
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "LoveBloodAndDiamonds", email = "ayazshakirzyanov27@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
requires-python = ">=3.13"
|
|
16
|
+
dependencies = [
|
|
17
|
+
"jinja2>=3.1.6",
|
|
18
|
+
"litestar>=2.18.0",
|
|
19
|
+
"pydantic>=2.11.0",
|
|
20
|
+
"uvicorn>=0.41.0",
|
|
21
|
+
]
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"build>=1.3.0",
|
|
25
|
+
"pre-commit>=4.3.0",
|
|
26
|
+
"twine>=6.2.0",
|
|
27
|
+
]
|
|
28
|
+
[project.urls]
|
|
29
|
+
Github = "https://github.com/LoveBloodAndDiamonds/json-admin"
|
|
30
|
+
Author = "https://t.me/LoveBloodAndDiamonds"
|