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
@@ -0,0 +1,179 @@
{% extends "page.html" %}
{% block page_content %}
<style>
.tickets-table { width:100%; border-collapse:collapse; }
.tickets-table th, .tickets-table td { padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); font-size:13px; }
.tickets-table th { font-weight:600; color:var(--text-muted); background:var(--light-bg); }
.tickets-table td { vertical-align:middle; }
.status-badge { display:inline-block; padding:3px 10px; border-radius:12px; font-size:12px; font-weight:600; }
.status-nova { background:#cce5ff; color:#004085; }
.status-v-rabote { background:#fff3cd; color:#856404; }
.status-zakryta { background:#d4edda; color:#155724; }
.status-select { padding:4px 8px; border-radius:6px; border:1px solid var(--border); font-size:12px; }
.ticket-detail-card { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-top:16px; display:none; }
.ticket-detail-card.open { display:block; }
.ticket-detail-card h3 { margin-bottom:12px; }
.ticket-detail-card .field { margin-bottom:8px; }
.ticket-detail-card .field label { font-weight:600; font-size:12px; color:var(--text-muted); display:block; }
.ticket-detail-card .field .value { font-size:14px; }
.filter-bar { display:flex; gap:12px; margin-bottom:16px; align-items:center; flex-wrap:wrap; }
.filter-bar select, .filter-bar input { padding:8px 12px; border:1px solid var(--border); border-radius:6px; font-size:13px; }
</style>
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between;align-items:center;">
<h2>Заявки от чат-бота</h2>
<span id="ticket-count" style="font-size:13px;color:var(--text-muted);">0 заявок</span>
</div>
<div class="filter-bar">
<select id="status-filter" onchange="filterTickets()">
<option value="">Все статусы</option>
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Закрыта">Закрыта</option>
</select>
<input type="text" id="ticket-search" placeholder="Поиск..." oninput="filterTickets()">
<button class="btn btn-sm" onclick="loadTickets()">Обновить</button>
</div>
<table class="tickets-table">
<thead>
<tr>
<th>ID</th>
<th>Заголовок</th>
<th>Приоритет</th>
<th>Статус</th>
<th>Дата</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tickets-body">
<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div class="ticket-detail-card" id="ticket-detail">
<h3 id="ticket-detail-title">Заявка #</h3>
<div class="field"><label>Описание</label><div class="value" id="ticket-detail-desc"></div></div>
<div class="field"><label>Приоритет</label><div class="value" id="ticket-detail-priority"></div></div>
<div class="field"><label>Статус</label>
<select id="ticket-detail-status" onchange="updateTicketStatus()">
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Закрыта">Закрыта</option>
</select>
</div>
<div class="field"><label>Дата</label><div class="value" id="ticket-detail-date"></div></div>
<button class="btn" onclick="closeDetail()">Закрыть</button>
</div>
<script>
var allTickets = [];
var currentTicketId = null;
function loadTickets(){
var tbody = document.getElementById('tickets-body');
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Загрузка...</td></tr>';
fetch('/api/bot/tickets').then(function(r){ return r.json() }).then(function(data){
allTickets = data;
document.getElementById('ticket-count').textContent = data.length + ' заявок';
renderTickets(data);
});
}
function renderTickets(tickets){
var tbody = document.getElementById('tickets-body');
if(!tickets.length){
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--text-muted);">Нет заявок</td></tr>';
return;
}
var html = '';
tickets.forEach(function(t){
var statusClass = 'status-nova';
if(t.status === 'В работе') statusClass = 'status-v-rabote';
else if(t.status === 'Закрыта') statusClass = 'status-zakryta';
var dateStr = t.created_at ? new Date(t.created_at).toLocaleString() : '';
html += '<tr>' +
'<td>' + t.id + '</td>' +
'<td><a href="javascript:void(0)" onclick="showDetail(' + t.id + ')">' + escapeHtml(t.title) + '</a></td>' +
'<td>' + t.priority + '</td>' +
'<td><span class="status-badge ' + statusClass + '">' + t.status + '</span></td>' +
'<td style="font-size:12px;color:var(--text-muted);">' + dateStr + '</td>' +
'<td>' +
' <select class="status-select" onchange="changeStatus(' + t.id + ', this.value)">' +
' <option value="">Сменить статус</option>' +
' <option value="Новая">Новая</option>' +
' <option value="В работе">В работе</option>' +
' <option value="Закрыта">Закрыта</option>' +
' </select>' +
'</td>' +
'</tr>';
});
tbody.innerHTML = html;
}
function filterTickets(){
var statusFilter = document.getElementById('status-filter').value;
var search = document.getElementById('ticket-search').value.toLowerCase();
var filtered = allTickets.filter(function(t){
if(statusFilter && t.status !== statusFilter) return false;
if(search && !t.title.toLowerCase().includes(search) && !(t.description || '').toLowerCase().includes(search)) return false;
return true;
});
renderTickets(filtered);
}
function showDetail(id){
currentTicketId = id;
var ticket = allTickets.find(function(t){ return t.id === id; });
if(!ticket) return;
document.getElementById('ticket-detail-title').textContent = 'Заявка #' + ticket.id;
document.getElementById('ticket-detail-desc').textContent = ticket.description || '—';
document.getElementById('ticket-detail-priority').textContent = ticket.priority;
document.getElementById('ticket-detail-status').value = ticket.status;
document.getElementById('ticket-detail-date').textContent = ticket.created_at ? new Date(ticket.created_at).toLocaleString() : '—';
document.getElementById('ticket-detail').classList.add('open');
}
function closeDetail(){
document.getElementById('ticket-detail').classList.remove('open');
currentTicketId = null;
}
function changeStatus(id, status){
if(!status) return;
updateTicketStatusApi(id, status);
}
function updateTicketStatus(){
if(currentTicketId){
var status = document.getElementById('ticket-detail-status').value;
updateTicketStatusApi(currentTicketId, status);
}
}
function updateTicketStatusApi(id, status){
fetch('/api/bot/tickets/' + id, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({status: status})
}).then(function(r){ return r.json() }).then(function(d){
if(d.ok){
loadTickets();
if(currentTicketId === id) showDetail(id);
} else {
showNotification('Ошибка: ' + (d.error || 'неизвестная'), 'error');
}
});
}
function escapeHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
loadTickets();
</script>
{% endblock %}