kepil 0.2.0__py3-none-any.whl
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.
- kepil/__init__.py +3 -0
- kepil/admin/__init__.py +5 -0
- kepil/admin/__main__.py +7 -0
- kepil/admin/app.py +492 -0
- kepil/admin/theme.py +109 -0
- kepil/admin/views.py +638 -0
- kepil/compliance/__init__.py +15 -0
- kepil/compliance/generator.py +158 -0
- kepil/compliance/packs/generic.json +241 -0
- kepil/compliance/packs.py +84 -0
- kepil/confirm/__init__.py +4 -0
- kepil/gateway/__init__.py +3 -0
- kepil/gateway/action_gateway.py +84 -0
- kepil/journal/__init__.py +7 -0
- kepil/journal/anchors.py +74 -0
- kepil/journal/chain.py +134 -0
- kepil/mandate/__init__.py +3 -0
- kepil/mandate/mandate.py +63 -0
- kepil/mcp/__init__.py +5 -0
- kepil/mcp/__main__.py +5 -0
- kepil/mcp/server.py +228 -0
- kepil/meter/__init__.py +15 -0
- kepil/meter/counters.py +144 -0
- kepil/notify/__init__.py +48 -0
- kepil/notify/telegram.py +171 -0
- kepil/orders/__init__.py +20 -0
- kepil/orders/service.py +316 -0
- kepil/professions/__init__.py +26 -0
- kepil/professions/base.py +89 -0
- kepil/professions/definition.py +186 -0
- kepil/professions/definitions/ai_audit.json +85 -0
- kepil/professions/definitions/automation.json +86 -0
- kepil/professions/definitions/leads.json +98 -0
- kepil/professions/definitions/primary_docs.json +89 -0
- kepil/professions/definitions/tender.json +88 -0
- kepil/registry/__init__.py +6 -0
- kepil/registry/passport.py +59 -0
- kepil/registry/store.py +130 -0
- kepil/storage.py +63 -0
- kepil-0.2.0.dist-info/METADATA +139 -0
- kepil-0.2.0.dist-info/RECORD +45 -0
- kepil-0.2.0.dist-info/WHEEL +5 -0
- kepil-0.2.0.dist-info/entry_points.txt +2 -0
- kepil-0.2.0.dist-info/licenses/LICENSE +661 -0
- kepil-0.2.0.dist-info/top_level.txt +1 -0
kepil/__init__.py
ADDED
kepil/admin/__init__.py
ADDED
kepil/admin/__main__.py
ADDED
kepil/admin/app.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""HTTP-панель оператора.
|
|
2
|
+
|
|
3
|
+
Стандартная библиотека, серверный рендеринг, обычные формы. Ни одного внешнего
|
|
4
|
+
пакета: ядро должно ставиться в закрытом контуре и проходить требование о доле
|
|
5
|
+
локализации (см. NOTICE.md).
|
|
6
|
+
|
|
7
|
+
Запуск:
|
|
8
|
+
python -m kepil.admin # http://localhost:7317
|
|
9
|
+
python -m kepil.admin 8080
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import urllib.parse
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
from .. import compliance, meter, notify
|
|
20
|
+
from ..journal import Journal, anchor, anchors
|
|
21
|
+
from ..orders import service as orders
|
|
22
|
+
from ..professions import definition as professions
|
|
23
|
+
from ..registry import store as agents
|
|
24
|
+
from ..storage import data_dir
|
|
25
|
+
from . import views
|
|
26
|
+
|
|
27
|
+
Params = dict[str, str]
|
|
28
|
+
Handler = Callable[["Request"], "Response"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Response:
|
|
32
|
+
def __init__(self, body: str = "", status: int = 200, redirect: str | None = None,
|
|
33
|
+
content_type: str = "text/html; charset=utf-8",
|
|
34
|
+
raw: bytes | None = None, filename: str | None = None) -> None:
|
|
35
|
+
self.body, self.status, self.redirect = body, status, redirect
|
|
36
|
+
self.content_type, self.raw, self.filename = content_type, raw, filename
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Request:
|
|
40
|
+
def __init__(self, path: str, params: Params, query: Params) -> None:
|
|
41
|
+
self.path, self.params, self.query = path, params, query
|
|
42
|
+
|
|
43
|
+
def form(self, name: str, default: str = "") -> str:
|
|
44
|
+
return self.params.get(name, default).strip()
|
|
45
|
+
|
|
46
|
+
def lines(self, name: str) -> list[str]:
|
|
47
|
+
return [x.strip() for x in self.params.get(name, "").splitlines() if x.strip()]
|
|
48
|
+
|
|
49
|
+
def pairs(self, name: str) -> dict[str, str]:
|
|
50
|
+
out: dict[str, str] = {}
|
|
51
|
+
for line in self.lines(name):
|
|
52
|
+
key, _, value = line.partition("=")
|
|
53
|
+
if key.strip():
|
|
54
|
+
out[key.strip()] = value.strip()
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- страницы ---------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def _counts() -> dict[str, int]:
|
|
61
|
+
awaiting = sum(1 for o in orders.list_orders() if o.status == "awaiting")
|
|
62
|
+
return {"orders": awaiting} if awaiting else {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def page(title: str, active: str, body: str, message=None) -> Response:
|
|
66
|
+
return Response(views.layout(title, active, body, _counts(), message))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def overview(_: Request) -> Response:
|
|
70
|
+
return page("Обзор", "overview", views.overview(
|
|
71
|
+
orders.list_orders(), professions.load_all(), agents.all_passports(),
|
|
72
|
+
orders.verify_journal(), agents.review_due()))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def orders_list(_: Request) -> Response:
|
|
76
|
+
return page("Заказы", "orders",
|
|
77
|
+
views.orders_page(orders.list_orders(), professions.load_all()))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def order_create(request: Request) -> Response:
|
|
81
|
+
client = {"name": request.form("client_name"),
|
|
82
|
+
"bin": request.form("client_bin") or "000000000000"}
|
|
83
|
+
days = int(request.form("days") or 7)
|
|
84
|
+
order = orders.create(request.form("profession"), client, days=days)
|
|
85
|
+
return Response(redirect=f"/orders/{order.id}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def order_view(request: Request) -> Response:
|
|
89
|
+
order = orders.get(request.query["id"])
|
|
90
|
+
records = [r for r in Journal(orders.journal_path()) if r.get("order_id") == order.id]
|
|
91
|
+
return page(f"Заказ {order.id}", "orders",
|
|
92
|
+
views.order_page(order, order.definition(),
|
|
93
|
+
views.journal_table(records)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def order_step(request: Request) -> Response:
|
|
97
|
+
orders.run_next(orders.get(request.query["id"]))
|
|
98
|
+
return Response(redirect=f"/orders/{request.query['id']}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def order_confirm(request: Request) -> Response:
|
|
102
|
+
order = orders.get(request.query["id"])
|
|
103
|
+
orders.confirm(order, request.form("approve") == "1", request.form("note"))
|
|
104
|
+
return Response(redirect=f"/orders/{order.id}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def order_stop(request: Request) -> Response:
|
|
108
|
+
orders.stop(orders.get(request.query["id"]), request.form("reason"))
|
|
109
|
+
return Response(redirect=f"/orders/{request.query['id']}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def order_resume(request: Request) -> Response:
|
|
113
|
+
orders.resume(orders.get(request.query["id"]))
|
|
114
|
+
return Response(redirect=f"/orders/{request.query['id']}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def order_rollback(request: Request) -> Response:
|
|
118
|
+
order = orders.get(request.query["id"])
|
|
119
|
+
done, message = orders.rollback(order, request.form("step"))
|
|
120
|
+
order = orders.get(order.id)
|
|
121
|
+
records = [r for r in Journal(orders.journal_path()) if r.get("order_id") == order.id]
|
|
122
|
+
body = views.order_page(order, order.definition(), views.journal_table(records),
|
|
123
|
+
message if done else "")
|
|
124
|
+
return page(f"Заказ {order.id}", "orders", body,
|
|
125
|
+
None if done else ("err", message))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def order_reset(request: Request) -> Response:
|
|
129
|
+
order = orders.get(request.query["id"])
|
|
130
|
+
order.cursor, order.results, order.pending, order.status = 0, [], None, "new"
|
|
131
|
+
order.mandate["spent"] = {}
|
|
132
|
+
order.save()
|
|
133
|
+
return Response(redirect=f"/orders/{order.id}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def professions_list(_: Request) -> Response:
|
|
137
|
+
return page("Профессии", "professions",
|
|
138
|
+
views.professions_page(professions.load_all()))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def profession_view(request: Request) -> Response:
|
|
142
|
+
definition = professions.get(request.query["id"])
|
|
143
|
+
return page(definition.name, "professions", views.profession_form(definition))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def profession_save(request: Request) -> Response:
|
|
147
|
+
current = professions.get(request.query["id"])
|
|
148
|
+
try:
|
|
149
|
+
steps = [professions.Step.parse(line, i)
|
|
150
|
+
for i, line in enumerate(request.lines("steps"))]
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
return page(current.name, "professions",
|
|
153
|
+
views.profession_form(current), ("err", str(exc)))
|
|
154
|
+
|
|
155
|
+
limits: dict[str, float] = {}
|
|
156
|
+
for key, value in request.pairs("limits").items():
|
|
157
|
+
try:
|
|
158
|
+
limits[key] = float(value.replace(",", "."))
|
|
159
|
+
except ValueError:
|
|
160
|
+
return page(current.name, "professions", views.profession_form(current),
|
|
161
|
+
("err", f"лимит «{key}»: нужно число"))
|
|
162
|
+
|
|
163
|
+
updated = professions.ProfessionDefinition(
|
|
164
|
+
id=current.id,
|
|
165
|
+
name=request.form("name") or current.name,
|
|
166
|
+
summary=request.form("summary"),
|
|
167
|
+
purpose=request.form("purpose"),
|
|
168
|
+
does_not=request.lines("does_not"),
|
|
169
|
+
intake=request.lines("intake"),
|
|
170
|
+
steps=steps,
|
|
171
|
+
irreversible=request.lines("irreversible"),
|
|
172
|
+
rollback=request.pairs("rollback"),
|
|
173
|
+
limits=limits,
|
|
174
|
+
allowed_systems=request.lines("allowed_systems"),
|
|
175
|
+
forbidden_actions=request.lines("forbidden_actions"),
|
|
176
|
+
risk_class=request.form("risk_class") or current.risk_class,
|
|
177
|
+
risk_rationale=request.form("risk_rationale"),
|
|
178
|
+
deliverable=request.form("deliverable"),
|
|
179
|
+
human_baseline_minutes=float(request.form("human_baseline_minutes") or 0),
|
|
180
|
+
)
|
|
181
|
+
problems = updated.validate()
|
|
182
|
+
if problems:
|
|
183
|
+
return page(updated.name, "professions",
|
|
184
|
+
views.profession_form(updated, problems))
|
|
185
|
+
professions.save(updated)
|
|
186
|
+
return Response(redirect="/professions")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def profession_new(request: Request) -> Response:
|
|
190
|
+
new_id, name = request.form("id"), request.form("name")
|
|
191
|
+
blank = professions.ProfessionDefinition(
|
|
192
|
+
id=new_id, name=name or new_id, summary="",
|
|
193
|
+
purpose="Опишите, что делает агент этой профессии",
|
|
194
|
+
does_not=["опишите границы: чего агент не делает никогда"],
|
|
195
|
+
intake=["что нужно получить от клиента до старта"],
|
|
196
|
+
steps=[professions.Step.parse("read:source | Первый шаг", 0)],
|
|
197
|
+
)
|
|
198
|
+
problems = blank.validate()
|
|
199
|
+
if problems:
|
|
200
|
+
return page("Профессии", "professions",
|
|
201
|
+
views.professions_page(professions.load_all()),
|
|
202
|
+
("err", "; ".join(problems)))
|
|
203
|
+
professions.save(blank)
|
|
204
|
+
return Response(redirect=f"/professions/{new_id}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def profession_duplicate(request: Request) -> Response:
|
|
208
|
+
source = request.query["id"]
|
|
209
|
+
existing = professions.load_all()
|
|
210
|
+
new_id = f"{source}_copy"
|
|
211
|
+
index = 2
|
|
212
|
+
while new_id in existing:
|
|
213
|
+
new_id, index = f"{source}_copy{index}", index + 1
|
|
214
|
+
professions.duplicate(source, new_id, f"{existing[source].name} (копия)")
|
|
215
|
+
return Response(redirect=f"/professions/{new_id}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def profession_delete(request: Request) -> Response:
|
|
219
|
+
try:
|
|
220
|
+
professions.delete(request.query["id"])
|
|
221
|
+
except KeyError as exc:
|
|
222
|
+
return page("Профессии", "professions",
|
|
223
|
+
views.professions_page(professions.load_all()), ("err", str(exc)))
|
|
224
|
+
return Response(redirect="/professions")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def agents_list(_: Request) -> Response:
|
|
228
|
+
return page("Агенты", "agents", views.agents_page(agents.all_passports()))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def agent_status(request: Request) -> Response:
|
|
232
|
+
agents.set_status(request.query["id"], request.form("status"))
|
|
233
|
+
return Response(redirect="/agents")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def agent_reissue(request: Request) -> Response:
|
|
237
|
+
agents.reissue(request.query["id"])
|
|
238
|
+
return Response(redirect="/agents")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def journal_view(request: Request) -> Response:
|
|
242
|
+
journal = Journal(orders.journal_path())
|
|
243
|
+
records = list(journal)
|
|
244
|
+
return page("Журнал", "journal",
|
|
245
|
+
views.journal_page(records[-40:], orders.verify_journal(),
|
|
246
|
+
len(records), views.anchors_block(anchors(journal))))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def journal_anchor(request: Request) -> Response:
|
|
250
|
+
try:
|
|
251
|
+
anchor(Journal(orders.journal_path()))
|
|
252
|
+
except ValueError as exc:
|
|
253
|
+
return page("Журнал", "journal",
|
|
254
|
+
views.journal_page([], orders.verify_journal(), 0), ("err", str(exc)))
|
|
255
|
+
return Response(redirect="/journal")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# --- счётчик и комплаенс ----------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def meter_view(_: Request) -> Response:
|
|
261
|
+
return page("Счётчик", "meter", views.meter_page(
|
|
262
|
+
meter.totals(), meter.by_profession(), meter.by_agent(),
|
|
263
|
+
professions.load_all()))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _pack_id(request: "Request") -> str | None:
|
|
267
|
+
"""Выбранный пакет документации; по умолчанию — встроенный универсальный."""
|
|
268
|
+
return request.query.get("pack") or request.params.get("pack") or None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _compliance_set(agent_id: str, pack_id: str | None = None):
|
|
272
|
+
passport = agents.get(agent_id)
|
|
273
|
+
if passport is None:
|
|
274
|
+
raise KeyError(f"паспорт '{agent_id}' не найден")
|
|
275
|
+
profession_id = agent_id.split(".")[1]
|
|
276
|
+
definition = professions.get(profession_id)
|
|
277
|
+
counters = meter.by_agent().get(agent_id, meter.Counters())
|
|
278
|
+
stats = {"actions": counters.actions, "denied": counters.denied,
|
|
279
|
+
"confirmations": counters.confirmations, "returns": counters.returns,
|
|
280
|
+
"journal_ok": orders.verify_journal()[0]}
|
|
281
|
+
return definition, compliance.build(definition, passport, agents.settings(),
|
|
282
|
+
stats, pack_id)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def compliance_index(request: Request) -> Response:
|
|
286
|
+
pack_id = _pack_id(request)
|
|
287
|
+
items = []
|
|
288
|
+
for passport in agents.all_passports():
|
|
289
|
+
try:
|
|
290
|
+
definition, documents = _compliance_set(passport["agent_id"], pack_id)
|
|
291
|
+
except KeyError:
|
|
292
|
+
continue
|
|
293
|
+
items.append({
|
|
294
|
+
"agent_id": passport["agent_id"],
|
|
295
|
+
"profession_name": definition.name,
|
|
296
|
+
"risk_class": passport["risk_class"],
|
|
297
|
+
"required": sum(1 for d in documents if d.required),
|
|
298
|
+
"total": len(documents),
|
|
299
|
+
"todo": compliance.missing_marks(documents),
|
|
300
|
+
})
|
|
301
|
+
return page("Комплаенс", "compliance",
|
|
302
|
+
views.compliance_index(items, compliance.available_packs(), pack_id))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def compliance_view(request: Request) -> Response:
|
|
306
|
+
pack_id = _pack_id(request)
|
|
307
|
+
_, documents = _compliance_set(request.query["id"], pack_id)
|
|
308
|
+
selected = request.query.get("doc") or documents[0].key
|
|
309
|
+
body = next((d.body for d in documents if d.key == selected), documents[0].body)
|
|
310
|
+
return page("Комплаенс", "compliance",
|
|
311
|
+
views.compliance_set(request.query["id"], documents, selected, body))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def compliance_download(request: Request) -> Response:
|
|
315
|
+
import io
|
|
316
|
+
import zipfile
|
|
317
|
+
|
|
318
|
+
agent_id = request.query["id"]
|
|
319
|
+
_, documents = _compliance_set(agent_id, _pack_id(request))
|
|
320
|
+
buffer = io.BytesIO()
|
|
321
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
322
|
+
for document in documents:
|
|
323
|
+
archive.writestr(document.filename, document.body)
|
|
324
|
+
return Response(raw=buffer.getvalue(), content_type="application/zip",
|
|
325
|
+
filename=f"{agent_id}-compliance.zip")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def journal_export(_: Request) -> Response:
|
|
329
|
+
path = orders.journal_path()
|
|
330
|
+
body = path.read_bytes() if path.exists() else b""
|
|
331
|
+
return Response(raw=body, content_type="application/x-ndjson",
|
|
332
|
+
filename="journal.jsonl")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def settings_view(_: Request) -> Response:
|
|
336
|
+
return page("Настройки", "settings",
|
|
337
|
+
views.settings_page(agents.settings(), data_dir().resolve()))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def settings_save(request: Request) -> Response:
|
|
341
|
+
agents.save_settings({
|
|
342
|
+
"name": request.form("name"),
|
|
343
|
+
"bin": request.form("bin"),
|
|
344
|
+
"operator": request.form("operator"),
|
|
345
|
+
"telegram_token": request.form("telegram_token"),
|
|
346
|
+
"telegram_chat_id": request.form("telegram_chat_id"),
|
|
347
|
+
})
|
|
348
|
+
return Response(redirect="/settings")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def settings_test(_: Request) -> Response:
|
|
352
|
+
"""Проверка канала: бот должен прислать сообщение в указанный чат."""
|
|
353
|
+
client = notify.bot()
|
|
354
|
+
if client is None:
|
|
355
|
+
message = ("err", "заполните токен и чат, затем сохраните настройки")
|
|
356
|
+
else:
|
|
357
|
+
try:
|
|
358
|
+
message = ("ok", f"связь есть: сообщение отправлено ботом @{client.check()}")
|
|
359
|
+
except notify.TelegramError as exc:
|
|
360
|
+
message = ("err", f"не получилось: {exc}")
|
|
361
|
+
return page("Настройки", "settings",
|
|
362
|
+
views.settings_page(agents.settings(), data_dir().resolve()), message)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
ROUTES: list[tuple[str, str, Handler]] = [
|
|
366
|
+
("GET", "/", overview),
|
|
367
|
+
("GET", "/orders", orders_list),
|
|
368
|
+
("POST", "/orders/new", order_create),
|
|
369
|
+
("GET", "/orders/<id>", order_view),
|
|
370
|
+
("POST", "/orders/<id>/step", order_step),
|
|
371
|
+
("POST", "/orders/<id>/confirm", order_confirm),
|
|
372
|
+
("POST", "/orders/<id>/reset", order_reset),
|
|
373
|
+
("POST", "/orders/<id>/stop", order_stop),
|
|
374
|
+
("POST", "/orders/<id>/resume", order_resume),
|
|
375
|
+
("POST", "/orders/<id>/rollback", order_rollback),
|
|
376
|
+
("GET", "/professions", professions_list),
|
|
377
|
+
("POST", "/professions/new", profession_new),
|
|
378
|
+
("GET", "/professions/<id>", profession_view),
|
|
379
|
+
("POST", "/professions/<id>", profession_save),
|
|
380
|
+
("POST", "/professions/<id>/duplicate", profession_duplicate),
|
|
381
|
+
("POST", "/professions/<id>/delete", profession_delete),
|
|
382
|
+
("GET", "/agents", agents_list),
|
|
383
|
+
("POST", "/agents/<id>/status", agent_status),
|
|
384
|
+
("POST", "/agents/<id>/reissue", agent_reissue),
|
|
385
|
+
("GET", "/meter", meter_view),
|
|
386
|
+
("GET", "/compliance", compliance_index),
|
|
387
|
+
("GET", "/compliance/<id>", compliance_view),
|
|
388
|
+
("GET", "/compliance/<id>/download", compliance_download),
|
|
389
|
+
("GET", "/journal", journal_view),
|
|
390
|
+
("POST", "/journal/anchor", journal_anchor),
|
|
391
|
+
("POST", "/journal/verify", journal_view),
|
|
392
|
+
("GET", "/journal/export", journal_export),
|
|
393
|
+
("GET", "/settings", settings_view),
|
|
394
|
+
("POST", "/settings", settings_save),
|
|
395
|
+
("POST", "/settings/test", settings_test),
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def resolve(method: str, path: str) -> tuple[Handler, Params] | None:
|
|
400
|
+
"""Сопоставляет путь с маршрутом; <id> — единственный вид параметра."""
|
|
401
|
+
parts = [p for p in path.strip("/").split("/") if p]
|
|
402
|
+
for route_method, pattern, handler in ROUTES:
|
|
403
|
+
if route_method != method:
|
|
404
|
+
continue
|
|
405
|
+
expected = [p for p in pattern.strip("/").split("/") if p]
|
|
406
|
+
if len(expected) != len(parts):
|
|
407
|
+
continue
|
|
408
|
+
query: Params = {}
|
|
409
|
+
for want, got in zip(expected, parts):
|
|
410
|
+
if want == "<id>":
|
|
411
|
+
query["id"] = urllib.parse.unquote(got)
|
|
412
|
+
elif want != got:
|
|
413
|
+
break
|
|
414
|
+
else:
|
|
415
|
+
return handler, query
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
420
|
+
server_version = "Kepil"
|
|
421
|
+
|
|
422
|
+
def log_message(self, *args: Any) -> None:
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
def do_GET(self) -> None:
|
|
426
|
+
self._dispatch("GET")
|
|
427
|
+
|
|
428
|
+
def do_POST(self) -> None:
|
|
429
|
+
self._dispatch("POST")
|
|
430
|
+
|
|
431
|
+
def _dispatch(self, method: str) -> None:
|
|
432
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
433
|
+
path = parsed.path
|
|
434
|
+
found = resolve(method, path)
|
|
435
|
+
if not found:
|
|
436
|
+
self._send(Response("<h1>404</h1><p>Страница не найдена. "
|
|
437
|
+
"<a href=\"/\">На главную</a></p>", status=404))
|
|
438
|
+
return
|
|
439
|
+
handler, query = found
|
|
440
|
+
for key, value in urllib.parse.parse_qs(parsed.query).items():
|
|
441
|
+
query.setdefault(key, value[0])
|
|
442
|
+
params: Params = {}
|
|
443
|
+
if method == "POST":
|
|
444
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
445
|
+
raw = self.rfile.read(length).decode("utf-8") if length else ""
|
|
446
|
+
params = {k: v[0] for k, v in urllib.parse.parse_qs(raw).items()}
|
|
447
|
+
try:
|
|
448
|
+
response = handler(Request(path, params, query))
|
|
449
|
+
except KeyError as exc:
|
|
450
|
+
response = Response(f"<h1>Не найдено</h1><p>{exc}</p>", status=404)
|
|
451
|
+
except Exception as exc: # ошибка панели не должна ронять сервер
|
|
452
|
+
response = Response(f"<h1>Ошибка</h1><pre>{exc!r}</pre>", status=500)
|
|
453
|
+
self._send(response)
|
|
454
|
+
|
|
455
|
+
def _send(self, response: Response) -> None:
|
|
456
|
+
if response.redirect:
|
|
457
|
+
self.send_response(303)
|
|
458
|
+
self.send_header("Location", response.redirect)
|
|
459
|
+
self.end_headers()
|
|
460
|
+
return
|
|
461
|
+
body = response.raw if response.raw is not None else response.body.encode("utf-8")
|
|
462
|
+
self.send_response(response.status)
|
|
463
|
+
self.send_header("Content-Type", response.content_type)
|
|
464
|
+
self.send_header("Content-Length", str(len(body)))
|
|
465
|
+
if response.filename:
|
|
466
|
+
self.send_header("Content-Disposition",
|
|
467
|
+
f'attachment; filename="{response.filename}"')
|
|
468
|
+
self.end_headers()
|
|
469
|
+
self.wfile.write(body)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _start_telegram() -> None:
|
|
473
|
+
"""Приём нажатий из Telegram живёт рядом с панелью, если канал настроен."""
|
|
474
|
+
import threading
|
|
475
|
+
|
|
476
|
+
client = notify.bot()
|
|
477
|
+
if client is None:
|
|
478
|
+
print("Telegram не настроен: подтверждения ждут в панели")
|
|
479
|
+
return
|
|
480
|
+
threading.Thread(target=notify.run, args=(client,), daemon=True).start()
|
|
481
|
+
print("Telegram подключён: подтверждения придут в телефон")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def serve(port: int = 7317, host: str = "127.0.0.1") -> None:
|
|
485
|
+
print(f"Kepil · панель оператора: http://localhost:{port}")
|
|
486
|
+
print(f"данные: {data_dir().resolve()}")
|
|
487
|
+
_start_telegram()
|
|
488
|
+
ThreadingHTTPServer((host, port), _Handler).serve_forever()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
if __name__ == "__main__":
|
|
492
|
+
serve(int(sys.argv[1]) if len(sys.argv) > 1 else 7317)
|
kepil/admin/theme.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Оформление панели.
|
|
2
|
+
|
|
3
|
+
Панель оператора — плотный рабочий инструмент, а не витрина: тёмный фон, чтобы
|
|
4
|
+
не выжигать глаза в смену, моноширинный шрифт для всего, что сверяют глазами
|
|
5
|
+
(хеши, действия, идентификаторы), и цвет только там, где он означает решение:
|
|
6
|
+
разрешено, отказано, ждёт человека.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
CSS = """
|
|
10
|
+
:root{
|
|
11
|
+
--bg:#12140F; --panel:#191C16; --panel-2:#20241D; --line:#2E332A;
|
|
12
|
+
--ink:#E9ECE3; --ink-2:#A6AE9E; --ink-3:#767E70;
|
|
13
|
+
--ok:#7FBFA6; --deny:#D98A7B; --wait:#D6AC5A; --accent:#7FBFA6;
|
|
14
|
+
}
|
|
15
|
+
*{box-sizing:border-box}
|
|
16
|
+
html,body{height:100%}
|
|
17
|
+
body{margin:0;background:var(--bg);color:var(--ink);display:flex;
|
|
18
|
+
font:14px/1.5 "Segoe UI",system-ui,sans-serif}
|
|
19
|
+
a{color:var(--accent);text-decoration:none}
|
|
20
|
+
a:hover{text-decoration:underline}
|
|
21
|
+
.mono,code{font-family:Consolas,"Cascadia Mono",monospace}
|
|
22
|
+
|
|
23
|
+
nav{width:210px;flex:none;background:var(--panel);border-right:1px solid var(--line);
|
|
24
|
+
padding:16px 0;display:flex;flex-direction:column;height:100vh;position:sticky;top:0}
|
|
25
|
+
nav .brand{padding:0 18px 16px;border-bottom:1px solid var(--line);margin-bottom:10px}
|
|
26
|
+
nav .brand b{font-size:18px;letter-spacing:.02em}
|
|
27
|
+
nav .brand span{display:block;color:var(--ink-3);font-size:11.5px;margin-top:2px}
|
|
28
|
+
nav a{display:flex;justify-content:space-between;gap:8px;padding:8px 18px;
|
|
29
|
+
color:var(--ink-2);font-size:13.5px;border-left:2px solid transparent}
|
|
30
|
+
nav a:hover{color:var(--ink);text-decoration:none;background:var(--panel-2)}
|
|
31
|
+
nav a.on{color:var(--ink);border-left-color:var(--accent);background:var(--panel-2)}
|
|
32
|
+
nav a .count{color:var(--ink-3);font-family:Consolas,monospace;font-size:11.5px}
|
|
33
|
+
nav .foot{margin-top:auto;padding:12px 18px 0;border-top:1px solid var(--line);
|
|
34
|
+
color:var(--ink-3);font-size:11.5px}
|
|
35
|
+
|
|
36
|
+
main{flex:1;min-width:0;padding:22px 30px 60px;max-width:1180px}
|
|
37
|
+
h1{font-size:21px;margin:0 0 4px;font-weight:600;letter-spacing:.01em}
|
|
38
|
+
.lede{color:var(--ink-3);font-size:13px;margin:0 0 22px}
|
|
39
|
+
h2{font-size:11px;letter-spacing:.11em;text-transform:uppercase;color:var(--ink-3);
|
|
40
|
+
margin:26px 0 11px;font-weight:600}
|
|
41
|
+
h2:first-of-type{margin-top:0}
|
|
42
|
+
|
|
43
|
+
.grid{display:grid;gap:14px}
|
|
44
|
+
.g2{grid-template-columns:repeat(auto-fit,minmax(300px,1fr))}
|
|
45
|
+
.g3{grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
|
|
46
|
+
.card{background:var(--panel);border:1px solid var(--line);padding:14px 16px}
|
|
47
|
+
.card h3{margin:0 0 8px;font-size:14.5px;font-weight:600}
|
|
48
|
+
.card p{margin:0 0 8px;color:var(--ink-2);font-size:13px}
|
|
49
|
+
.card p:last-child{margin-bottom:0}
|
|
50
|
+
.tile .n{font-family:Consolas,monospace;font-size:26px;line-height:1.1}
|
|
51
|
+
.tile .l{color:var(--ink-3);font-size:12px;margin-top:4px}
|
|
52
|
+
|
|
53
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
54
|
+
th{text-align:left;font-weight:500;color:var(--ink-3);font-size:11px;
|
|
55
|
+
letter-spacing:.08em;text-transform:uppercase;padding:0 12px 8px 0;
|
|
56
|
+
border-bottom:1px solid var(--line)}
|
|
57
|
+
td{padding:8px 12px 8px 0;border-bottom:1px solid var(--line);vertical-align:top}
|
|
58
|
+
tr:last-child td{border-bottom:none}
|
|
59
|
+
td.mono,th.mono{font-family:Consolas,monospace;font-size:12px}
|
|
60
|
+
|
|
61
|
+
.pill{display:inline-block;font-size:11.5px;padding:2px 8px;border:1px solid var(--line);
|
|
62
|
+
border-radius:2px;color:var(--ink-2);white-space:nowrap}
|
|
63
|
+
.pill.ok{color:var(--ok);border-color:#2C4A40}
|
|
64
|
+
.pill.deny{color:var(--deny);border-color:#4A2E28}
|
|
65
|
+
.pill.wait{color:var(--wait);border-color:#4A3D1E}
|
|
66
|
+
|
|
67
|
+
.steps{display:flex;flex-direction:column;gap:1px;background:var(--line);
|
|
68
|
+
border:1px solid var(--line)}
|
|
69
|
+
.step{display:grid;grid-template-columns:22px 1fr auto;gap:10px;
|
|
70
|
+
background:var(--panel);padding:9px 13px}
|
|
71
|
+
.step.idle{background:var(--panel-2);opacity:.45}
|
|
72
|
+
.step .m{font-family:Consolas,monospace}
|
|
73
|
+
.step.ok .m{color:var(--ok)} .step.deny .m{color:var(--deny)} .step.wait .m{color:var(--wait)}
|
|
74
|
+
.step .t{font-size:13.5px}
|
|
75
|
+
.step .a{font-family:Consolas,monospace;font-size:11.5px;color:var(--ink-3);margin-top:2px}
|
|
76
|
+
.step .w{font-size:12.5px;margin-top:5px;padding-left:10px;border-left:2px solid var(--line)}
|
|
77
|
+
.step.deny .w{color:var(--deny)} .step.wait .w{color:var(--wait)} .step.ok .w{color:var(--ink-3)}
|
|
78
|
+
.step .c{font-family:Consolas,monospace;font-size:11.5px;color:var(--ink-3);white-space:nowrap}
|
|
79
|
+
|
|
80
|
+
form.inline{display:inline}
|
|
81
|
+
label{display:block;color:var(--ink-3);font-size:11.5px;letter-spacing:.06em;
|
|
82
|
+
text-transform:uppercase;margin:14px 0 5px}
|
|
83
|
+
label .hint{text-transform:none;letter-spacing:0;color:var(--ink-3);font-size:12px;
|
|
84
|
+
display:block;margin-top:3px;opacity:.85}
|
|
85
|
+
input[type=text],input[type=number],select,textarea{width:100%;background:var(--panel-2);
|
|
86
|
+
border:1px solid var(--line);color:var(--ink);padding:8px 10px;font:inherit;font-size:13.5px}
|
|
87
|
+
textarea{font-family:Consolas,monospace;font-size:12.5px;line-height:1.6;resize:vertical}
|
|
88
|
+
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}
|
|
89
|
+
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:16px}
|
|
90
|
+
.cols{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr))}
|
|
91
|
+
|
|
92
|
+
button{font:inherit;font-size:13.5px;padding:8px 15px;border:1px solid var(--line);
|
|
93
|
+
background:var(--panel-2);color:var(--ink);cursor:pointer}
|
|
94
|
+
button:hover{border-color:var(--accent)}
|
|
95
|
+
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
96
|
+
button.primary{background:var(--accent);color:#10130F;border-color:var(--accent);font-weight:600}
|
|
97
|
+
button.danger{color:var(--deny)}
|
|
98
|
+
button:disabled{opacity:.35;cursor:not-allowed}
|
|
99
|
+
|
|
100
|
+
.msg{padding:11px 14px;border:1px solid var(--line);margin-bottom:18px;font-size:13.5px}
|
|
101
|
+
.msg.ok{border-left:3px solid var(--ok)}
|
|
102
|
+
.msg.err{border-left:3px solid var(--deny);color:var(--deny)}
|
|
103
|
+
.meter{height:4px;background:var(--panel-2);border:1px solid var(--line);margin-top:6px}
|
|
104
|
+
.meter i{display:block;height:100%;background:var(--accent)}
|
|
105
|
+
dl.kv{display:grid;grid-template-columns:170px 1fr;gap:6px 14px;margin:0;font-size:13px}
|
|
106
|
+
dl.kv dt{color:var(--ink-3)}
|
|
107
|
+
dl.kv dd{margin:0;overflow-wrap:anywhere}
|
|
108
|
+
.empty{color:var(--ink-3);font-size:13px;padding:10px 0}
|
|
109
|
+
"""
|