72b6879f4b
- Refactored max_bot from nested packages to flat module structure - Q2: Extended BotUser model (patronymic, email, org, address, vcf_raw, contact_hash, phone_verified, email_verified, last_interaction, total_conversations, total_tickets) - Q2: VCF parser (FN, N, TEL, EMAIL, ORG, ADR), upsert on re-contact, NLP history context (_get_user_history_context -> YandexGPT) - Q1: Broadcast preview modal with 10s confirmation timer - Q3: CSS var(--white)->var(--bg-card), var(--text)->var(--text-primary) - Q4: bot_settings showNotification(), editable max_bot_id - Q5: Webhook secret passthrough via X-Max-Bot-Api-Secret - Masking sensitive keys, dialog_cleared handler, migrate via _add_column_if_not_exists() - Rate limit (asyncio.sleep 0.5 per 10), dead code removed, conv.intent context in contact.py - Portal pages: bot_consent, bot_kb (edit), bot_settings, bot_test, bot_tickets, portal_settings - Tests: 21/21 passing, added test_yandex_gpt.py, test_email_sender.py - Deploy: deploy_full.sh, schema.sql, seed_knowledge_base.sql
292 lines
9.9 KiB
Python
292 lines
9.9 KiB
Python
import os
|
||
import re
|
||
import markdown
|
||
from pathlib import Path
|
||
from fastapi import APIRouter, Request, Depends, Form
|
||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from app.database import get_db
|
||
from datetime import datetime
|
||
from app.config import get_settings
|
||
from app.services.docs_permissions import load_permissions, save_permissions, discover_docs, can_access_doc, get_allowed_docs
|
||
from app.dependencies import get_current_user, require_role
|
||
|
||
settings = get_settings()
|
||
router = APIRouter(prefix="/service/documents", tags=["documents"])
|
||
from app.templating import templates
|
||
|
||
|
||
def now_str():
|
||
return datetime.now().strftime("%d.%m.%Y %H:%M")
|
||
|
||
|
||
def _doc_ctx(request, user, **kw):
|
||
original_user = request.session.get("original_user")
|
||
available_roles = {"owner": ["owner", "engineer", "technician"], "engineer": ["engineer", "technician"]}
|
||
c = {"request": request, "user": user, "now": now_str(),
|
||
"role_permissions": getattr(request.state, 'role_permissions', {}),
|
||
"role_override_active": original_user is not None,
|
||
"original_role": original_user["role"] if original_user else None,
|
||
"available_roles": available_roles.get(user["role"], [user["role"]]) if user else []}
|
||
c.update(kw)
|
||
return c
|
||
|
||
|
||
def transliterate_slug(text: str) -> str:
|
||
translit_map = {
|
||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh',
|
||
'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o',
|
||
'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'kh', 'ц': 'ts',
|
||
'ч': 'ch', 'ш': 'sh', 'щ': 'shch', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu',
|
||
'я': 'ya', ' ': '_',
|
||
}
|
||
result = ''.join(translit_map.get(c.lower(), c) for c in text)
|
||
result = re.sub(r'[^a-zA-Z0-9_\-]', '', result)
|
||
result = re.sub(r'_+', '_', result)
|
||
return result.strip('_').lower()
|
||
|
||
|
||
def render_markdown(content: str) -> str:
|
||
md = markdown.Markdown(extensions=['tables', 'fenced_code', 'toc', 'attr_list'])
|
||
return md.convert(content)
|
||
|
||
|
||
@router.get("/")
|
||
async def documents_list(
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
docs = get_allowed_docs(user["role"])
|
||
return templates.TemplateResponse("documents/list.html", _doc_ctx(request, user, docs=docs))
|
||
|
||
|
||
@router.get("/admin/permissions")
|
||
async def permissions_page(
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
if user["role"] != "owner":
|
||
return HTMLResponse(content="Доступ запрещён", status_code=403)
|
||
|
||
perms = discover_docs()
|
||
return templates.TemplateResponse("documents/permissions.html", _doc_ctx(request, user, perms=perms))
|
||
|
||
|
||
@router.post("/admin/permissions")
|
||
async def permissions_update(
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
if user["role"] != "owner":
|
||
return HTMLResponse(content="Доступ запрещён", status_code=403)
|
||
|
||
form = await request.form()
|
||
perms = discover_docs()
|
||
|
||
for slug in perms.keys():
|
||
engineer_view = form.get(f"{slug}_engineer_view") == "on"
|
||
engineer_edit = form.get(f"{slug}_engineer_edit") == "on"
|
||
technician_view = form.get(f"{slug}_technician_view") == "on"
|
||
|
||
if engineer_edit and not engineer_view:
|
||
engineer_view = True
|
||
|
||
perms[slug]["permissions"]["engineer"] = {
|
||
"view": engineer_view,
|
||
"edit": engineer_edit,
|
||
"cancel": engineer_edit,
|
||
}
|
||
perms[slug]["permissions"]["technician"] = {
|
||
"view": technician_view,
|
||
}
|
||
|
||
save_permissions(perms)
|
||
return RedirectResponse(url="/service/documents/admin/permissions", status_code=302)
|
||
|
||
|
||
@router.post("/admin/create")
|
||
async def document_create(
|
||
request: Request,
|
||
title: str = Form(...),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
if user["role"] != "owner":
|
||
return HTMLResponse(content="Доступ запрещён", status_code=403)
|
||
|
||
slug = transliterate_slug(title)
|
||
if not slug:
|
||
return HTMLResponse(content="Некорректное название", status_code=400)
|
||
|
||
docs_dir = settings.DOCS_DIR
|
||
os.makedirs(docs_dir, exist_ok=True)
|
||
filename = f"{slug}.md"
|
||
filepath = os.path.join(docs_dir, filename)
|
||
|
||
if os.path.exists(filepath):
|
||
return HTMLResponse(content="Документ уже существует", status_code=400)
|
||
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
f.write(f"# {title}\n\n")
|
||
|
||
perms = load_permissions()
|
||
perms[slug] = {
|
||
"title": title,
|
||
"filename": filename,
|
||
"sort_order": len(perms),
|
||
"permissions": {
|
||
"engineer": {"view": False, "edit": False, "cancel": False},
|
||
"technician": {"view": False},
|
||
},
|
||
}
|
||
save_permissions(perms)
|
||
|
||
return RedirectResponse(url=f"/service/documents/{slug}/edit", status_code=302)
|
||
|
||
|
||
@router.get("/tech-access")
|
||
async def tech_access_page(
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
if user["role"] != "engineer":
|
||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
||
|
||
perms = discover_docs()
|
||
editable_docs = [
|
||
doc for slug, doc in perms.items()
|
||
if doc.get("permissions", {}).get("engineer", {}).get("edit", False)
|
||
]
|
||
return templates.TemplateResponse("documents/tech_access.html", _doc_ctx(request, user, docs=editable_docs, perms=perms))
|
||
|
||
|
||
@router.post("/tech-access")
|
||
async def tech_access_update(
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
if user["role"] != "engineer":
|
||
return HTMLResponse(content="Доступ только для инженеров", status_code=403)
|
||
|
||
form = await request.form()
|
||
perms = discover_docs()
|
||
|
||
for slug in perms.keys():
|
||
if not perms[slug].get("permissions", {}).get("engineer", {}).get("edit", False):
|
||
continue
|
||
technician_view = form.get(f"{slug}_technician_view") == "on"
|
||
perms[slug]["permissions"]["technician"] = {"view": technician_view}
|
||
|
||
save_permissions(perms)
|
||
return RedirectResponse(url="/service/documents/tech-access", status_code=302)
|
||
|
||
|
||
@router.get("/{slug}")
|
||
async def document_view(
|
||
slug: str,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
if not can_access_doc(user["role"], slug, "view"):
|
||
return HTMLResponse(content="Доступ запрещён", status_code=403)
|
||
|
||
docs_dir = settings.DOCS_DIR
|
||
perms = discover_docs()
|
||
doc = perms.get(slug)
|
||
if not doc:
|
||
return HTMLResponse(content="Документ не найден", status_code=404)
|
||
|
||
filepath = os.path.join(docs_dir, doc["filename"])
|
||
if not os.path.exists(filepath):
|
||
return HTMLResponse(content="Файл не найден", status_code=404)
|
||
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
|
||
html_content = render_markdown(content)
|
||
return templates.TemplateResponse("documents/view.html", _doc_ctx(request, user,
|
||
doc=doc, content=html_content,
|
||
can_edit=can_access_doc(user["role"], slug, "edit"),
|
||
can_download=user["role"] == "owner",
|
||
))
|
||
|
||
|
||
@router.get("/{slug}/edit")
|
||
async def document_edit(
|
||
slug: str,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
if not can_access_doc(user["role"], slug, "edit"):
|
||
return HTMLResponse(content="Доступ запрещён", status_code=403)
|
||
|
||
docs_dir = settings.DOCS_DIR
|
||
perms = discover_docs()
|
||
doc = perms.get(slug)
|
||
if not doc:
|
||
return HTMLResponse(content="Документ не найден", status_code=404)
|
||
|
||
filepath = os.path.join(docs_dir, doc["filename"])
|
||
if not os.path.exists(filepath):
|
||
return HTMLResponse(content="Файл не найден", status_code=404)
|
||
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
|
||
return templates.TemplateResponse("documents/edit.html", _doc_ctx(request, user, doc=doc, content=content))
|
||
|
||
|
||
@router.post("/{slug}/edit")
|
||
async def document_update(
|
||
slug: str,
|
||
request: Request,
|
||
content: str = Form(...),
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
if not can_access_doc(user["role"], slug, "edit"):
|
||
return HTMLResponse(content="Доступ запрещён", status_code=403)
|
||
|
||
docs_dir = settings.DOCS_DIR
|
||
perms = discover_docs()
|
||
doc = perms.get(slug)
|
||
if not doc:
|
||
return HTMLResponse(content="Документ не найден", status_code=404)
|
||
|
||
filepath = os.path.join(docs_dir, doc["filename"])
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
|
||
return RedirectResponse(url=f"/service/documents/{slug}", status_code=302)
|
||
|
||
|
||
@router.get("/{slug}/download")
|
||
async def document_download(
|
||
slug: str,
|
||
request: Request,
|
||
db: AsyncSession = Depends(get_db),
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
if user["role"] != "owner":
|
||
return HTMLResponse(content="Скачивание доступно только владельцу", status_code=403)
|
||
|
||
from fastapi.responses import FileResponse
|
||
docs_dir = settings.DOCS_DIR
|
||
perms = discover_docs()
|
||
doc = perms.get(slug)
|
||
if not doc:
|
||
return HTMLResponse(content="Документ не найден", status_code=404)
|
||
|
||
filepath = os.path.join(docs_dir, doc["filename"])
|
||
if not os.path.exists(filepath):
|
||
return HTMLResponse(content="Файл не найден", status_code=404)
|
||
|
||
return FileResponse(filepath, filename=doc["filename"], media_type="text/markdown")
|