v1.7.0: refactor max_bot to flat structure, add VCF+UserModel+NLP history context, portal pages and proxy fixes

- 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
This commit is contained in:
2026-05-29 02:30:30 +03:00
parent 493e0b37a1
commit 72b6879f4b
234 changed files with 26768 additions and 6240 deletions
+1 -143
View File
@@ -3,78 +3,7 @@
{% block content %}
<button class="sidebar-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')"></button>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo sidebar-logo-text">Aegis<span>One</span></div>
<div style="font-size:12px;color:var(--text-muted);letter-spacing:0.05em;">Service Portal</div>
</div>
<nav class="sidebar-nav">
<div class="sidebar-section">Панель</div>
<a href="/service/dashboard" class="active">Дашборд</a>
{% if user.role == 'owner' %}
<div class="sidebar-section">Управление</div>
<a href="/service/users">Сотрудники</a>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/assignments">Назначение сотрудников</a>
<a href="/service/sla">SLA контракты</a>
<a href="/service/ceo">CEO дашборд</a>
<a href="/service/coefficients">Коэффициенты</a>
<a href="/service/charts">Графики</a>
<a href="/service/ideas">Идеи</a>
{% elif user.role == 'engineer' %}
<div class="sidebar-section">Управление</div>
<a href="/service/customers">Клиенты</a>
<a href="/service/objects">Объекты</a>
<a href="/service/sla">SLA контракты</a>
<a href="/service/documents/tech-access">Доступ техников к документам</a>
{% endif %}
<div class="sidebar-section">Работа</div>
{% if user.role in ['engineer', 'technician'] %}
<a href="/service/tasks">Задачи</a>
<a href="/service/reports">Отчёты</a>
<a href="/service/incidents">Инциденты</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/questionnaire">Опросник</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/questionnaire-config">Настройка опросника</a>
{% endif %}
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/passports">Паспорта объектов</a>
{% endif %}
{% if user.role == 'technician' %}
<a href="/service/checklist">Чек-лист</a>
{% endif %}
{% if user.role == 'owner' %}
<div class="sidebar-section">Внешние системы</div>
<a href="https://git.aegisone.ru" target="_blank" rel="noopener">Gitea</a>
<a href="{{ PORTAINER_URL }}" target="_blank" rel="noopener">Portainer</a>
{% endif %}
<div class="sidebar-section">Блог</div>
{% if user.role == 'owner' %}
<a href="/service/blog">Управление блогом</a>
{% endif %}
<a href="/blog/">Публичный блог</a>
<div class="sidebar-section">Контент</div>
{% if user.role == 'owner' %}
<a href="/service/cases">Примеры из практики</a>
{% endif %}
<div class="sidebar-section">Документация</div>
{% if user.role in ['owner', 'engineer'] %}
<a href="/service/documents/">Просмотр документации</a>
{% endif %}
{% if user.role == 'owner' %}
<a href="/service/documents/admin/permissions">Управление документами</a>
{% endif %}
</nav>
<div class="sidebar-footer">
<div class="user-name">{{ user.full_name }}</div>
<a href="/service/logout" style="display:inline-block;margin:6px 0;font-size:12px;">Выйти</a>
<div class="version-pill" onclick="openChangelog()" title="История изменений">v{{ app_version }}</div>
</div>
</aside>
{% include "_sidebar.html" %}
<main class="main">
<div class="main-header">
<div>
@@ -182,75 +111,4 @@ document.addEventListener('DOMContentLoaded', applyDashSettings);
.card-link { text-decoration:none; color:inherit; display:block; transition:transform 0.2s, box-shadow 0.2s; }
.card-link:hover { transform:translateY(-2px); box-shadow:var(--shadow); cursor:pointer; }
</style>
<div class="modal-overlay" id="changelog-modal"><div class="modal">
<button type="button" class="modal-close" onclick="closeChangelog()"></button>
<h2>История изменений</h2>
<div id="changelog-content" class="doc-content" style="padding:0;background:transparent;border:none;"></div>
</div></div>
<script>
function openChangelog(){
var modal = document.getElementById('changelog-modal');
modal.classList.add('open');
var content = document.getElementById('changelog-content');
if(!content.dataset.loaded){
content.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
fetch('/service/api/changelog').then(function(r){ return r.json() }).then(function(data){
content.innerHTML = data.html || '<p>Нет данных</p>';
content.dataset.loaded = '1';
});
}
}
function closeChangelog(){
document.getElementById('changelog-modal').classList.remove('open');
}
document.addEventListener('DOMContentLoaded', function(){
var nav = document.querySelector('.navbar__menu');
if(!nav) return;
var sidebarLinks = document.querySelectorAll('.sidebar-nav a[href]');
var saved = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
sidebarLinks.forEach(function(link){
var star = document.createElement('span');
star.className = 'sidebar-star';
var url = link.getAttribute('href');
star.textContent = saved.indexOf(url) !== -1 ? '\u2605' : '\u2606';
star.style.cssText = 'cursor:pointer;margin-right:6px;font-size:14px;user-select:none;';
star.addEventListener('click', function(e){
e.preventDefault(); e.stopPropagation();
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
var idx = favs.indexOf(url);
if(idx !== -1){ favs.splice(idx,1); star.textContent = '\u2606'; }
else { favs.push(url); star.textContent = '\u2605'; }
localStorage.setItem('sidebar_favorites', JSON.stringify(favs));
renderFavorites();
});
link.insertBefore(star, link.firstChild);
});
function renderFavorites(){
var existing = nav.querySelectorAll('.nav-fav');
existing.forEach(function(e){ e.remove(); });
var favs = JSON.parse(localStorage.getItem('sidebar_favorites') || '[]');
favs.forEach(function(url){
var orig = document.querySelector('.sidebar-nav a[href="' + url + '"]');
if(!orig) return;
var li = document.createElement('li');
li.className = 'nav-fav';
var a = document.createElement('a');
a.href = url; a.textContent = orig.textContent.replace(/^[☆★]/, '').trim();
li.appendChild(a);
nav.insertBefore(li, nav.lastElementChild);
});
}
renderFavorites();
// Restore sidebar scroll position
var sidebarNav = document.querySelector('.sidebar-nav');
var savedScroll = sessionStorage.getItem('sidebar_scroll');
if (sidebarNav && savedScroll) sidebarNav.scrollTop = parseInt(savedScroll, 10);
// Save scroll position before navigating
sidebarNav && sidebarNav.addEventListener('click', function(){
sessionStorage.setItem('sidebar_scroll', this.scrollTop);
});
});
</script>
{% endblock %}