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.
@@ -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,4 @@
1
+ jinja2>=3.1.6
2
+ litestar>=2.18.0
3
+ pydantic>=2.11.0
4
+ uvicorn>=0.41.0
@@ -0,0 +1 @@
1
+ jsonadmin
@@ -0,0 +1,6 @@
1
+ """Публичный API библиотеки json-admin."""
2
+
3
+ from jsonadmin.admin import Admin
4
+ from jsonadmin.pages import BasePage, HtmlPage, JsonPage
5
+
6
+ __all__ = ["Admin", "BasePage", "JsonPage", "HtmlPage"]
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+