синхронизация данных с интрой
This commit is contained in:
parent
a10824f8d6
commit
1ee5049ab4
20 changed files with 1190 additions and 6 deletions
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -c \":*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
7
.env
7
.env
|
|
@ -10,3 +10,10 @@ ADMIN_USERNAME=admin
|
|||
ADMIN_PASSWORD=changeme
|
||||
ADMIN_HOST=0.0.0.0
|
||||
ADMIN_PORT=8080
|
||||
|
||||
# Intradesk API
|
||||
INTRADESK_BASE_URL=https://apigw.intradesk.ru
|
||||
INTRADESK_API_KEY=4842AA91805F4630853EF42D74
|
||||
INTRADESK_TASKS_API_KEY=26221B839B6643E68345FD26BF
|
||||
INTRADESK_EXECUTOR_GROUP=243025
|
||||
INTRADESK_EXECUTORS=2227907,525111,631481,2222953
|
||||
|
|
|
|||
|
|
@ -10,3 +10,10 @@ ADMIN_USERNAME=admin
|
|||
ADMIN_PASSWORD=changeme
|
||||
ADMIN_HOST=0.0.0.0
|
||||
ADMIN_PORT=8080
|
||||
|
||||
# Intradesk API
|
||||
INTRADESK_BASE_URL=https://apigw.intradesk.ru
|
||||
INTRADESK_API_KEY=your_settings_api_key
|
||||
INTRADESK_TASKS_API_KEY=your_tasks_api_key
|
||||
INTRADESK_EXECUTOR_GROUP=0
|
||||
INTRADESK_EXECUTORS=id1,id2,id3
|
||||
|
|
|
|||
12
admin/app.py
12
admin/app.py
|
|
@ -1,7 +1,15 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from admin.routes import organizations, org_users, bot_users, tickets, chat_log
|
||||
from admin.routes import (
|
||||
bot_users,
|
||||
chat_log,
|
||||
intradesk_clients,
|
||||
organizations,
|
||||
org_users,
|
||||
sync,
|
||||
tickets,
|
||||
)
|
||||
|
||||
|
||||
def create_admin_app() -> FastAPI:
|
||||
|
|
@ -16,5 +24,7 @@ def create_admin_app() -> FastAPI:
|
|||
app.include_router(bot_users.router, prefix="/admin")
|
||||
app.include_router(tickets.router, prefix="/admin")
|
||||
app.include_router(chat_log.router, prefix="/admin")
|
||||
app.include_router(sync.router, prefix="/admin")
|
||||
app.include_router(intradesk_clients.router, prefix="/admin")
|
||||
|
||||
return app
|
||||
|
|
|
|||
84
admin/routes/intradesk_clients.py
Normal file
84
admin/routes/intradesk_clients.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""
|
||||
Роуты просмотра синхронизированных данных из Intradesk.
|
||||
|
||||
GET /admin/intradesk/clients — список клиентов
|
||||
GET /admin/intradesk/clients/{client_id}/tasks — заявки клиента
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from admin.auth import require_admin
|
||||
from admin.templates_env import templates
|
||||
from database.connection import get_db
|
||||
from database.queries.intradesk import (
|
||||
count_tasks_for_client,
|
||||
get_all_clients,
|
||||
get_client,
|
||||
get_distinct_statuses,
|
||||
get_tasks_for_client,
|
||||
get_users_for_client,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_PAGE_SIZE = 30
|
||||
|
||||
|
||||
@router.get("/intradesk/clients", dependencies=[Depends(require_admin)])
|
||||
async def clients_list(request: Request, search: str = ""):
|
||||
db = get_db()
|
||||
clients = await get_all_clients(db, search=search)
|
||||
return templates.TemplateResponse(
|
||||
"intradesk/clients.html",
|
||||
{"request": request, "clients": clients, "search": search},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/intradesk/clients/{client_id}/tasks",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def client_tasks(
|
||||
request: Request,
|
||||
client_id: int,
|
||||
status: str = "",
|
||||
page: int = 0,
|
||||
):
|
||||
db = get_db()
|
||||
client = await get_client(db, client_id)
|
||||
if client is None:
|
||||
return templates.TemplateResponse(
|
||||
"intradesk/clients.html",
|
||||
{
|
||||
"request": request,
|
||||
"clients": [],
|
||||
"search": "",
|
||||
"error": f"Клиент {client_id} не найден",
|
||||
},
|
||||
)
|
||||
|
||||
tasks = await get_tasks_for_client(
|
||||
db, client_id, status_filter=status, page=page, page_size=_PAGE_SIZE
|
||||
)
|
||||
total = await count_tasks_for_client(db, client_id, status_filter=status)
|
||||
statuses = await get_distinct_statuses(db)
|
||||
users = await get_users_for_client(db, client_id)
|
||||
|
||||
pages = max(1, (total + _PAGE_SIZE - 1) // _PAGE_SIZE)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"intradesk/client_tasks.html",
|
||||
{
|
||||
"request": request,
|
||||
"client": client,
|
||||
"tasks": tasks,
|
||||
"users": users,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"page_size": _PAGE_SIZE,
|
||||
"status": status,
|
||||
"statuses": statuses,
|
||||
},
|
||||
)
|
||||
53
admin/routes/sync.py
Normal file
53
admin/routes/sync.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""
|
||||
Роуты страницы синхронизации с Intradesk.
|
||||
|
||||
GET /admin/sync — страница с кнопками и историей
|
||||
POST /admin/sync/run — запустить синхронизацию (sync_type в форме)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from admin.auth import require_admin
|
||||
from admin.templates_env import templates
|
||||
from database.connection import get_db
|
||||
from database.queries.intradesk import get_last_sync, get_recent_sync_logs
|
||||
from external.intradesk_sync import run_sync
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SYNC_LABELS = {
|
||||
"clients": "Клиенты / организации",
|
||||
"users": "Пользователи (контактные лица)",
|
||||
"tasks": "Заявки",
|
||||
"all": "Всё сразу",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sync", dependencies=[Depends(require_admin)])
|
||||
async def sync_page(request: Request):
|
||||
db = get_db()
|
||||
logs = await get_recent_sync_logs(db, limit=20)
|
||||
|
||||
last: dict[str, dict | None] = {}
|
||||
for k in ("clients", "users", "tasks"):
|
||||
last[k] = await get_last_sync(db, k)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"sync/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"logs": logs,
|
||||
"last": last,
|
||||
"sync_labels": _SYNC_LABELS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync/run", dependencies=[Depends(require_admin)])
|
||||
async def sync_run(sync_type: str = Form(...)):
|
||||
if sync_type not in _SYNC_LABELS:
|
||||
return RedirectResponse("/admin/sync", status_code=303)
|
||||
await run_sync(sync_type)
|
||||
return RedirectResponse("/admin/sync", status_code=303)
|
||||
|
|
@ -52,10 +52,13 @@
|
|||
<nav>
|
||||
<span class="brand">🤖 Support Admin</span>
|
||||
<a href="/admin/orgs">Организации</a>
|
||||
<a href="/admin/org-users">Клиенты (белый список)</a>
|
||||
<a href="/admin/org-users">Белый список</a>
|
||||
<a href="/admin/bot-users">Пользователи бота</a>
|
||||
<a href="/admin/tickets">Заявки</a>
|
||||
<a href="/admin/tickets">Заявки бота</a>
|
||||
<a href="/admin/chat-log">Чаты</a>
|
||||
<span style="opacity:.4">|</span>
|
||||
<a href="/admin/intradesk/clients">🏢 Intradesk</a>
|
||||
<a href="/admin/sync">🔄 Синхронизация</a>
|
||||
</nav>
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
|
|
|
|||
155
admin/templates/intradesk/client_tasks.html
Normal file
155
admin/templates/intradesk/client_tasks.html
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Заявки — {{ client.name }} — Support Admin{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# ── Шапка клиента ── #}
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<a href="/admin/intradesk/clients" class="btn btn-secondary">← Назад</a>
|
||||
<h1 style="margin:0">{{ client.name }}</h1>
|
||||
{% if client.client_type == 10 %}
|
||||
<span class="badge badge-auth">Компания</span>
|
||||
{% elif client.client_type == 20 %}
|
||||
<span class="badge badge-pending">Физлицо</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Краткая инфо о клиенте ── #}
|
||||
{% if client.email or client.address or client.description %}
|
||||
<div class="card" style="padding:14px 20px">
|
||||
<div style="display:flex;gap:24px;flex-wrap:wrap;font-size:.85rem">
|
||||
{% if client.email %}<div>📧 {{ client.email }}</div>{% endif %}
|
||||
{% if client.address %}<div>📍 {{ client.address }}</div>{% endif %}
|
||||
{% if client.description %}
|
||||
<div style="flex-basis:100%;color:#555">{{ client.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Контактные лица ── #}
|
||||
{% if users %}
|
||||
<div class="card">
|
||||
<h2>Контактные лица ({{ users | length }})</h2>
|
||||
<table>
|
||||
<thead><tr><th>Имя</th><th>Email</th><th>Телефон</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.name or (u.last_name ~ ' ' ~ u.first_name ~ ' ' ~ u.middle_name)|trim }}</td>
|
||||
<td style="font-size:.85rem">{{ u.email or '—' }}</td>
|
||||
<td style="font-size:.85rem">{{ u.phone or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Фильтр заявок ── #}
|
||||
<div class="card">
|
||||
<div class="filter-bar" style="margin-bottom:12px">
|
||||
<form method="get" style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<input type="hidden" name="page" value="0">
|
||||
<select name="status" onchange="this.form.submit()" style="width:auto">
|
||||
<option value="">Все статусы</option>
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if status == s %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary">Применить</button>
|
||||
{% if status %}
|
||||
<a href="?page=0" class="btn btn-secondary">Сбросить</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
<span style="margin-left:auto;font-size:.85rem;color:#666">
|
||||
Всего заявок: <strong>{{ total }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# ── Таблица заявок ── #}
|
||||
{% if tasks %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Тема</th>
|
||||
<th>Статус</th>
|
||||
<th>Приоритет</th>
|
||||
<th>Сервис</th>
|
||||
<th>Исполнитель</th>
|
||||
<th>Создана</th>
|
||||
<th>Обновлена</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in tasks %}
|
||||
<tr>
|
||||
<td style="font-size:.8rem;color:#666;white-space:nowrap">{{ t.task_number or t.id }}</td>
|
||||
<td style="max-width:260px">
|
||||
<span title="{{ t.name }}">
|
||||
{{ t.name[:80] }}{% if t.name | length > 80 %}…{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
{% set sn = t.status_name | lower %}
|
||||
{% if 'выполнен' in sn %}
|
||||
<span class="badge badge-closed">{{ t.status_name }}</span>
|
||||
{% elif 'работ' in sn %}
|
||||
<span class="badge badge-in_progress">{{ t.status_name }}</span>
|
||||
{% elif 'открыт' in sn %}
|
||||
<span class="badge badge-open">{{ t.status_name }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pending">{{ t.status_name or '—' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size:.85rem;white-space:nowrap">
|
||||
{% set pn = t.priority_name | lower %}
|
||||
{% if 'критич' in pn %}🔴{% elif 'высок' in pn %}🟠{% elif 'средн' in pn %}🟡{% else %}⚪{% endif %}
|
||||
{{ t.priority_name or '—' }}
|
||||
</td>
|
||||
<td style="font-size:.8rem;color:#555;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
title="{{ t.service_full_name or '' }}">
|
||||
{{ t.service_name or '—' }}
|
||||
</td>
|
||||
<td style="font-size:.85rem">{{ t.executor_name or '—' }}</td>
|
||||
<td style="font-size:.8rem;color:#666;white-space:nowrap">
|
||||
{{ (t.created_at or '')[:10] or '—' }}
|
||||
</td>
|
||||
<td style="font-size:.8rem;color:#666;white-space:nowrap">
|
||||
{{ (t.updated_at or '')[:10] or '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if t.description %}
|
||||
<tr style="background:#fafafa">
|
||||
<td></td>
|
||||
<td colspan="7" style="font-size:.8rem;color:#555;padding:4px 12px 8px">
|
||||
{{ t.description | striptags | truncate(200) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{# ── Пагинация ── #}
|
||||
{% if pages > 1 %}
|
||||
<div style="display:flex;gap:8px;margin-top:14px;align-items:center;flex-wrap:wrap">
|
||||
{% if page > 0 %}
|
||||
<a href="?status={{ status }}&page={{ page - 1 }}" class="btn btn-secondary">← Назад</a>
|
||||
{% endif %}
|
||||
<span style="font-size:.85rem;color:#666">
|
||||
Стр. {{ page + 1 }} из {{ pages }}
|
||||
</span>
|
||||
{% if page < pages - 1 %}
|
||||
<a href="?status={{ status }}&page={{ page + 1 }}" class="btn btn-secondary">Вперёд →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p style="color:#999">
|
||||
{% if status %}Нет заявок со статусом «{{ status }}».{% else %}Нет заявок.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
80
admin/templates/intradesk/clients.html
Normal file
80
admin/templates/intradesk/clients.html
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Клиенты Intradesk — Support Admin{% endblock %}
|
||||
{% block content %}
|
||||
<h1>🏢 Клиенты Intradesk</h1>
|
||||
|
||||
{% if error %}
|
||||
<div style="background:#fce8e6;color:#d93025;padding:10px 14px;border-radius:6px;margin-bottom:14px">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="filter-bar">
|
||||
<form method="get" action="/admin/intradesk/clients" style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Поиск по названию…" style="width:280px">
|
||||
<button type="submit" class="btn btn-primary">Найти</button>
|
||||
{% if search %}
|
||||
<a href="/admin/intradesk/clients" class="btn btn-secondary">Сбросить</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
<a href="/admin/sync" class="btn btn-secondary" style="margin-left:auto">🔄 Синхронизация</a>
|
||||
</div>
|
||||
|
||||
{% if clients %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Тип</th>
|
||||
<th>Email</th>
|
||||
<th>Адрес</th>
|
||||
<th>Обновлён</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in clients %}
|
||||
<tr>
|
||||
<td style="color:#999;font-size:.8rem">{{ c.id }}</td>
|
||||
<td><strong>{{ c.name }}</strong></td>
|
||||
<td>
|
||||
{% if c.client_type == 10 %}
|
||||
<span class="badge badge-auth">Компания</span>
|
||||
{% elif c.client_type == 20 %}
|
||||
<span class="badge badge-pending">Физлицо</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pending">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size:.85rem">{{ c.email or '—' }}</td>
|
||||
<td style="font-size:.8rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
|
||||
title="{{ c.address or '' }}">
|
||||
{{ c.address or '—' }}
|
||||
</td>
|
||||
<td style="font-size:.8rem;color:#666;white-space:nowrap">
|
||||
{{ (c.updated_at or '')[:10] or '—' }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/intradesk/clients/{{ c.id }}/tasks" class="btn btn-primary"
|
||||
style="font-size:.8rem;padding:5px 10px">
|
||||
📋 Заявки
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="color:#666;font-size:.8rem;margin-top:10px">
|
||||
Всего: {{ clients | length }}
|
||||
{% if search %}(по фильтру){% endif %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p style="color:#999">
|
||||
{% if search %}Нет клиентов, соответствующих «{{ search }}».{% else %}
|
||||
Нет данных. <a href="/admin/sync">Запустите синхронизацию</a>.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
91
admin/templates/sync/index.html
Normal file
91
admin/templates/sync/index.html
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Синхронизация — Support Admin{% endblock %}
|
||||
{% block content %}
|
||||
<h1>🔄 Синхронизация с Intradesk</h1>
|
||||
|
||||
{# ── Кнопки запуска ── #}
|
||||
<div class="card">
|
||||
<h2>Запустить синхронизацию</h2>
|
||||
<p style="color:#666;font-size:.85rem;margin-bottom:16px">
|
||||
Данные загружаются из Intradesk API и сохраняются локально.
|
||||
Синхронизация выполняется синхронно — страница обновится по завершении.
|
||||
</p>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||||
{% for key, label in sync_labels.items() %}
|
||||
<form method="post" action="/admin/sync/run">
|
||||
<input type="hidden" name="sync_type" value="{{ key }}">
|
||||
<button type="submit"
|
||||
class="btn {% if key == 'all' %}btn-primary{% else %}btn-secondary{% endif %}"
|
||||
style="{% if key == 'all' %}font-weight:700{% endif %}">
|
||||
{% if key == 'all' %}🚀{% elif key == 'clients' %}🏢{% elif key == 'users' %}👤{% else %}📋{% endif %}
|
||||
{{ label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Последняя успешная синхронизация ── #}
|
||||
<div class="card">
|
||||
<h2>Последняя успешная синхронизация</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Тип</th><th>Дата</th><th>Записей</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, label in sync_labels.items() if key != 'all' %}
|
||||
<tr>
|
||||
<td>{{ label }}</td>
|
||||
{% set rec = last[key] %}
|
||||
{% if rec %}
|
||||
<td>{{ rec.finished_at or rec.started_at }}</td>
|
||||
<td>{{ rec.records_synced }}</td>
|
||||
{% else %}
|
||||
<td colspan="2" style="color:#999">Не выполнялась</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# ── История запусков ── #}
|
||||
<div class="card">
|
||||
<h2>История запусков (последние 20)</h2>
|
||||
{% if logs %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Тип</th><th>Начало</th><th>Конец</th>
|
||||
<th>Статус</th><th>Записей</th><th>Ошибка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.id }}</td>
|
||||
<td>{{ sync_labels.get(log.sync_type, log.sync_type) }}</td>
|
||||
<td style="white-space:nowrap;font-size:.8rem">{{ log.started_at }}</td>
|
||||
<td style="white-space:nowrap;font-size:.8rem">{{ log.finished_at or '—' }}</td>
|
||||
<td>
|
||||
{% if log.status == 'success' %}
|
||||
<span class="badge badge-closed">✓ Успешно</span>
|
||||
{% elif log.status == 'error' %}
|
||||
<span class="badge badge-open">✗ Ошибка</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pending">⟳ Выполняется</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ log.records_synced }}</td>
|
||||
<td style="color:#d93025;font-size:.8rem;max-width:300px;word-break:break-all">
|
||||
{{ log.error_message or '' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p style="color:#999">Синхронизаций ещё не было.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
15
config.py
15
config.py
|
|
@ -15,3 +15,18 @@ ADMIN_USERNAME: str = os.environ.get("ADMIN_USERNAME", "admin")
|
|||
ADMIN_PASSWORD: str = os.environ.get("ADMIN_PASSWORD", "changeme")
|
||||
ADMIN_HOST: str = os.environ.get("ADMIN_HOST", "0.0.0.0")
|
||||
ADMIN_PORT: int = int(os.environ.get("ADMIN_PORT", "8080"))
|
||||
|
||||
# ── Intradesk API ──────────────────────────────────────────────────────────────
|
||||
INTRADESK_BASE_URL: str = os.environ.get("INTRADESK_BASE_URL", "https://apigw.intradesk.ru")
|
||||
# Ключ для /settings/odata (клиенты, пользователи)
|
||||
INTRADESK_API_KEY: str = os.environ.get("INTRADESK_API_KEY", "")
|
||||
# Ключ для /tasklist/odata (заявки)
|
||||
INTRADESK_TASKS_API_KEY: str = os.environ.get("INTRADESK_TASKS_API_KEY", "")
|
||||
# ID группы исполнителей для фильтрации заявок
|
||||
INTRADESK_EXECUTOR_GROUP: str = os.environ.get("INTRADESK_EXECUTOR_GROUP", "")
|
||||
# Список ID исполнителей через запятую (доп. фильтр)
|
||||
INTRADESK_EXECUTORS: list[str] = [
|
||||
e.strip()
|
||||
for e in os.environ.get("INTRADESK_EXECUTORS", "").split(",")
|
||||
if e.strip()
|
||||
]
|
||||
|
|
|
|||
BIN
data/support.db
BIN
data/support.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -24,6 +24,18 @@ async def init_db() -> None:
|
|||
await _db.execute(stmt)
|
||||
await _db.commit()
|
||||
|
||||
# ── Миграции для существующих БД ─────────────────────────────────────────
|
||||
# Добавляем intradesk_id в organizations, если ещё нет
|
||||
try:
|
||||
await _db.execute("ALTER TABLE organizations ADD COLUMN intradesk_id INTEGER")
|
||||
await _db.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_orgs_intradesk_id "
|
||||
"ON organizations(intradesk_id)"
|
||||
)
|
||||
await _db.commit()
|
||||
except Exception:
|
||||
pass # колонка уже существует
|
||||
|
||||
|
||||
def get_db() -> aiosqlite.Connection:
|
||||
if _db is None:
|
||||
|
|
|
|||
375
database/queries/intradesk.py
Normal file
375
database/queries/intradesk.py
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
"""Запросы к таблицам intradesk_* (синхронизированные справочники)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import aiosqlite
|
||||
|
||||
|
||||
# ── Clients ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async def upsert_client(db: aiosqlite.Connection, item: dict) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO intradesk_clients
|
||||
(id, name, client_type, email, address, description, updated_at, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
client_type = excluded.client_type,
|
||||
email = excluded.email,
|
||||
address = excluded.address,
|
||||
description = excluded.description,
|
||||
updated_at = excluded.updated_at,
|
||||
synced_at = excluded.synced_at
|
||||
""",
|
||||
(
|
||||
item["id"],
|
||||
item.get("name") or "",
|
||||
item.get("clientType"),
|
||||
item.get("email"),
|
||||
item.get("address"),
|
||||
item.get("description"),
|
||||
item.get("updatedAt"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_all_clients(db: aiosqlite.Connection, search: str = "") -> list[dict]:
|
||||
q = "SELECT * FROM intradesk_clients"
|
||||
params: list = []
|
||||
if search:
|
||||
q += " WHERE name LIKE ?"
|
||||
params.append(f"%{search}%")
|
||||
q += " ORDER BY name"
|
||||
async with db.execute(q, params) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_client(db: aiosqlite.Connection, client_id: int) -> dict | None:
|
||||
async with db.execute(
|
||||
"SELECT * FROM intradesk_clients WHERE id = ?", (client_id,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── Users ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def upsert_user(db: aiosqlite.Connection, item: dict) -> None:
|
||||
# Основная группа — первая в списке groups
|
||||
client_id: int | None = None
|
||||
groups = item.get("groups") or []
|
||||
if groups:
|
||||
client_id = groups[0].get("userGroupId")
|
||||
|
||||
# Телефон — первый из списка
|
||||
phones = item.get("phoneNumbers") or []
|
||||
phone = phones[0].get("phoneNumber") if phones else item.get("phoneNumber")
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO intradesk_users
|
||||
(id, name, first_name, last_name, middle_name, email, phone, client_id, is_archived, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
first_name = excluded.first_name,
|
||||
last_name = excluded.last_name,
|
||||
middle_name = excluded.middle_name,
|
||||
email = excluded.email,
|
||||
phone = excluded.phone,
|
||||
client_id = excluded.client_id,
|
||||
is_archived = excluded.is_archived,
|
||||
synced_at = excluded.synced_at
|
||||
""",
|
||||
(
|
||||
item["id"],
|
||||
item.get("name") or "",
|
||||
item.get("firstName") or "",
|
||||
item.get("lastName") or "",
|
||||
item.get("middleName") or "",
|
||||
item.get("email") or "",
|
||||
phone,
|
||||
client_id,
|
||||
1 if item.get("isArchived") else 0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_users_for_client(
|
||||
db: aiosqlite.Connection, client_id: int
|
||||
) -> list[dict]:
|
||||
async with db.execute(
|
||||
"SELECT * FROM intradesk_users WHERE client_id = ? ORDER BY name",
|
||||
(client_id,),
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Tasks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def upsert_task(
|
||||
db: aiosqlite.Connection, item: dict, dicts: dict[int, str] | None = None
|
||||
) -> None:
|
||||
dicts = dicts or {}
|
||||
|
||||
status_id = item.get("status")
|
||||
status_name = dicts.get(status_id, "") if status_id else ""
|
||||
|
||||
priority_id = item.get("priority")
|
||||
priority_name = dicts.get(priority_id, "") if priority_id else ""
|
||||
|
||||
executor_id = item.get("executor")
|
||||
executor_name = dicts.get(executor_id, "") if executor_id else ""
|
||||
|
||||
initiator_id = item.get("initiator")
|
||||
initiator_name = dicts.get(initiator_id, "") if initiator_id else ""
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO intradesk_tasks (
|
||||
id, task_number, name, description,
|
||||
status_id, status_name,
|
||||
priority_id, priority_name,
|
||||
client_id, initiator_id, initiator_name,
|
||||
executor_id, executor_name, executor_group,
|
||||
service_name, service_full_name,
|
||||
created_at, updated_at, closed_at, created_by, updated_by,
|
||||
synced_at
|
||||
)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
task_number = excluded.task_number,
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
status_id = excluded.status_id,
|
||||
status_name = excluded.status_name,
|
||||
priority_id = excluded.priority_id,
|
||||
priority_name = excluded.priority_name,
|
||||
client_id = excluded.client_id,
|
||||
initiator_id = excluded.initiator_id,
|
||||
initiator_name = excluded.initiator_name,
|
||||
executor_id = excluded.executor_id,
|
||||
executor_name = excluded.executor_name,
|
||||
executor_group = excluded.executor_group,
|
||||
service_name = excluded.service_name,
|
||||
service_full_name = excluded.service_full_name,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at,
|
||||
closed_at = excluded.closed_at,
|
||||
created_by = excluded.created_by,
|
||||
updated_by = excluded.updated_by,
|
||||
synced_at = excluded.synced_at
|
||||
""",
|
||||
(
|
||||
item["id"],
|
||||
item.get("tasknumber"),
|
||||
item.get("name") or "",
|
||||
item.get("description") or "",
|
||||
status_id,
|
||||
status_name,
|
||||
priority_id,
|
||||
priority_name,
|
||||
item.get("clientid"),
|
||||
initiator_id,
|
||||
initiator_name,
|
||||
executor_id,
|
||||
executor_name,
|
||||
item.get("executorgroup"),
|
||||
item.get("servicename") or "",
|
||||
item.get("servicefullname") or "",
|
||||
item.get("createdat"),
|
||||
item.get("updatedat"),
|
||||
item.get("closedat"),
|
||||
item.get("createdby") or "",
|
||||
item.get("updatedby") or "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_tasks_for_client(
|
||||
db: aiosqlite.Connection,
|
||||
client_id: int,
|
||||
status_filter: str = "",
|
||||
page: int = 0,
|
||||
page_size: int = 30,
|
||||
) -> list[dict]:
|
||||
q = "SELECT * FROM intradesk_tasks WHERE client_id = ?"
|
||||
params: list = [client_id]
|
||||
if status_filter:
|
||||
q += " AND status_name = ?"
|
||||
params.append(status_filter)
|
||||
q += " ORDER BY updated_at DESC LIMIT ? OFFSET ?"
|
||||
params += [page_size, page * page_size]
|
||||
async with db.execute(q, params) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def count_tasks_for_client(
|
||||
db: aiosqlite.Connection, client_id: int, status_filter: str = ""
|
||||
) -> int:
|
||||
q = "SELECT COUNT(*) FROM intradesk_tasks WHERE client_id = ?"
|
||||
params: list = [client_id]
|
||||
if status_filter:
|
||||
q += " AND status_name = ?"
|
||||
params.append(status_filter)
|
||||
async with db.execute(q, params) as cur:
|
||||
row = await cur.fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
|
||||
async def get_distinct_statuses(db: aiosqlite.Connection) -> list[str]:
|
||||
async with db.execute(
|
||||
"SELECT DISTINCT status_name FROM intradesk_tasks WHERE status_name != '' ORDER BY status_name"
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
# ── Sync log ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def start_sync_log(db: aiosqlite.Connection, sync_type: str) -> int:
|
||||
async with db.execute(
|
||||
"INSERT INTO intradesk_sync_log (sync_type, status) VALUES (?, 'running')",
|
||||
(sync_type,),
|
||||
) as cur:
|
||||
log_id = cur.lastrowid
|
||||
await db.commit()
|
||||
return log_id # type: ignore[return-value]
|
||||
|
||||
|
||||
async def finish_sync_log(
|
||||
db: aiosqlite.Connection,
|
||||
log_id: int,
|
||||
status: str,
|
||||
records_synced: int,
|
||||
error_message: str = "",
|
||||
) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE intradesk_sync_log
|
||||
SET status = ?, records_synced = ?, error_message = ?,
|
||||
finished_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, records_synced, error_message or None, log_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_recent_sync_logs(
|
||||
db: aiosqlite.Connection, limit: int = 10
|
||||
) -> list[dict]:
|
||||
async with db.execute(
|
||||
"SELECT * FROM intradesk_sync_log ORDER BY id DESC LIMIT ?", (limit,)
|
||||
) as cur:
|
||||
rows = await cur.fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_last_sync(
|
||||
db: aiosqlite.Connection, sync_type: str
|
||||
) -> dict | None:
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT * FROM intradesk_sync_log
|
||||
WHERE sync_type = ? AND status = 'success'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(sync_type,),
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── Whitelist sync ────────────────────────────────────────────────────────────
|
||||
|
||||
def _normalize_phone(raw: str) -> str:
|
||||
"""Убираем всё кроме цифр, заменяем ведущую 8 → 7 (как в боте)."""
|
||||
digits = re.sub(r"\D", "", raw or "")
|
||||
if digits.startswith("8") and len(digits) == 11:
|
||||
digits = "7" + digits[1:]
|
||||
return digits
|
||||
|
||||
|
||||
async def sync_whitelist_from_intradesk(db: aiosqlite.Connection) -> int:
|
||||
"""
|
||||
Наполнить таблицы organizations и org_users данными из intradesk_clients /
|
||||
intradesk_users. Вызывается автоматически после синхронизации.
|
||||
|
||||
Возвращает количество затронутых строк в org_users.
|
||||
"""
|
||||
# 1. Загружаем существующий маппинг intradesk_id → local org_id
|
||||
org_map: dict[int, int] = {}
|
||||
async with db.execute(
|
||||
"SELECT id, intradesk_id FROM organizations WHERE intradesk_id IS NOT NULL"
|
||||
) as cur:
|
||||
for row in await cur.fetchall():
|
||||
org_map[row[1]] = row[0]
|
||||
|
||||
# 2. Для каждого intradesk_client создаём или обновляем organizations
|
||||
async with db.execute("SELECT id, name FROM intradesk_clients") as cur:
|
||||
clients = await cur.fetchall()
|
||||
|
||||
for c in clients:
|
||||
iid, cname = c[0], c[1]
|
||||
if iid in org_map:
|
||||
# Обновляем имя на случай переименования в Intradesk
|
||||
await db.execute(
|
||||
"UPDATE organizations SET name = ? WHERE intradesk_id = ?",
|
||||
(cname, iid),
|
||||
)
|
||||
else:
|
||||
# Создаём новую организацию
|
||||
async with db.execute(
|
||||
"INSERT INTO organizations (name, intradesk_id) VALUES (?, ?)",
|
||||
(cname, iid),
|
||||
) as cur:
|
||||
org_map[iid] = cur.lastrowid # type: ignore[assignment]
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 3. Для каждого активного intradesk_user с телефоном — upsert в org_users
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT id, phone, client_id, name, first_name, last_name
|
||||
FROM intradesk_users
|
||||
WHERE phone IS NOT NULL AND phone != '' AND is_archived = 0
|
||||
"""
|
||||
) as cur:
|
||||
users = await cur.fetchall()
|
||||
|
||||
count = 0
|
||||
for u in users:
|
||||
raw_phone = u[1] or ""
|
||||
phone = _normalize_phone(raw_phone)
|
||||
if len(phone) < 7:
|
||||
continue # явно некорректный номер
|
||||
|
||||
client_id = u[2]
|
||||
org_id = org_map.get(client_id) if client_id else None
|
||||
if org_id is None:
|
||||
continue # клиент ещё не синхронизирован
|
||||
|
||||
# Формируем отображаемое имя
|
||||
display = (u[3] or "").strip() or (
|
||||
f"{u[5] or ''} {u[4] or ''}".strip() or None
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO org_users (org_id, phone, display_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(phone) DO UPDATE SET
|
||||
org_id = excluded.org_id,
|
||||
display_name = COALESCE(excluded.display_name, org_users.display_name)
|
||||
""",
|
||||
(org_id, phone, display),
|
||||
)
|
||||
count += 1
|
||||
|
||||
await db.commit()
|
||||
return count
|
||||
|
|
@ -4,6 +4,7 @@ PRAGMA foreign_keys=ON;
|
|||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
intradesk_id INTEGER UNIQUE, -- id из intradesk_clients (NULL = ручное создание)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
|
|
@ -64,3 +65,68 @@ CREATE TABLE IF NOT EXISTS message_log (
|
|||
attachment_json TEXT, -- JSON-строка списка вложений
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Intradesk: синхронизируемые справочники ────────────────────────────────
|
||||
|
||||
-- Клиенты/организации из Intradesk (clientType 10=компания, 20=физлицо)
|
||||
CREATE TABLE IF NOT EXISTS intradesk_clients (
|
||||
id INTEGER PRIMARY KEY, -- id из Intradesk
|
||||
name TEXT NOT NULL,
|
||||
client_type INTEGER, -- 10 / 20
|
||||
email TEXT,
|
||||
address TEXT,
|
||||
description TEXT,
|
||||
updated_at TEXT,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Пользователи (контактные лица) клиентов из Intradesk
|
||||
CREATE TABLE IF NOT EXISTS intradesk_users (
|
||||
id INTEGER PRIMARY KEY, -- id (contactPersonId) из Intradesk
|
||||
name TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
middle_name TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
client_id INTEGER, -- userGroup.id из Intradesk
|
||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Заявки из Intradesk
|
||||
CREATE TABLE IF NOT EXISTS intradesk_tasks (
|
||||
id INTEGER PRIMARY KEY, -- id из Intradesk
|
||||
task_number INTEGER,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
status_id INTEGER,
|
||||
status_name TEXT,
|
||||
priority_id INTEGER,
|
||||
priority_name TEXT,
|
||||
client_id INTEGER, -- clientid из Intradesk
|
||||
initiator_id INTEGER,
|
||||
initiator_name TEXT,
|
||||
executor_id INTEGER,
|
||||
executor_name TEXT,
|
||||
executor_group INTEGER,
|
||||
service_name TEXT,
|
||||
service_full_name TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
closed_at TEXT,
|
||||
created_by TEXT,
|
||||
updated_by TEXT,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Лог запусков синхронизации
|
||||
CREATE TABLE IF NOT EXISTS intradesk_sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sync_type TEXT NOT NULL, -- clients / users / tasks / all
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running', -- running / success / error
|
||||
records_synced INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT
|
||||
);
|
||||
|
|
|
|||
215
external/intradesk_sync.py
vendored
Normal file
215
external/intradesk_sync.py
vendored
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""
|
||||
Сервис синхронизации данных из Intradesk API.
|
||||
|
||||
Поддерживаемые типы синхронизации:
|
||||
- clients — организации/клиенты (/settings/odata/v2/Clients)
|
||||
- users — контактные лица (/settings/odata/ClientUsers)
|
||||
- tasks — заявки (/tasklist/odata/v3/tasks)
|
||||
- all — всё вместе
|
||||
|
||||
Запуск: await run_sync("all")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import httpx
|
||||
|
||||
import config
|
||||
from database.connection import get_db
|
||||
from database.queries.intradesk import (
|
||||
finish_sync_log,
|
||||
start_sync_log,
|
||||
sync_whitelist_from_intradesk,
|
||||
upsert_client,
|
||||
upsert_task,
|
||||
upsert_user,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PAGE_SIZE = 100
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_url(path: str, api_key: str, extra: dict | None = None) -> str:
|
||||
"""
|
||||
Собирает URL с параметрами.
|
||||
Ключи начинающиеся на '$' вставляются без URL-кодирования имени
|
||||
(OData требует именно $filter, $top и т.д.).
|
||||
"""
|
||||
base = f"{config.INTRADESK_BASE_URL.rstrip('/')}{path}?ApiKey={api_key}"
|
||||
if extra:
|
||||
for k, v in extra.items():
|
||||
base += f"&{k}={urllib.parse.quote(str(v), safe='')}"
|
||||
return base
|
||||
|
||||
|
||||
async def _fetch_page(
|
||||
client: httpx.AsyncClient, path: str, api_key: str, extra: dict, skip: int
|
||||
) -> tuple[list[dict], dict[int, str]]:
|
||||
"""Запросить одну страницу OData и вернуть (items, dicts_map)."""
|
||||
params = dict(extra)
|
||||
params["$top"] = str(_PAGE_SIZE)
|
||||
params["$skip"] = str(skip)
|
||||
url = _build_url(path, api_key, params)
|
||||
resp = await client.get(url, timeout=60.0)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
items: list[dict] = data.get("value", [])
|
||||
|
||||
# Аккумулируем словари (статусы, приоритеты, пользователи, группы...)
|
||||
dicts_map: dict[int, str] = {}
|
||||
for d in data.get("dictionaries", []):
|
||||
raw_name = d.get("name")
|
||||
if isinstance(raw_name, dict):
|
||||
# name может быть {"ru": "...", "en": null}
|
||||
raw_name = raw_name.get("ru") or raw_name.get("en") or ""
|
||||
dicts_map[d["id"]] = raw_name or ""
|
||||
|
||||
return items, dicts_map
|
||||
|
||||
|
||||
async def _fetch_all(
|
||||
path: str, api_key: str, extra: dict | None = None
|
||||
) -> tuple[list[dict], dict[int, str]]:
|
||||
"""Постранично скачать все записи OData-эндпоинта."""
|
||||
all_items: list[dict] = []
|
||||
all_dicts: dict[int, str] = {}
|
||||
extra = extra or {}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
skip = 0
|
||||
while True:
|
||||
items, dicts_map = await _fetch_page(client, path, api_key, extra, skip)
|
||||
all_items.extend(items)
|
||||
all_dicts.update(dicts_map)
|
||||
if len(items) < _PAGE_SIZE:
|
||||
break
|
||||
skip += _PAGE_SIZE
|
||||
|
||||
return all_items, all_dicts
|
||||
|
||||
|
||||
# ── Sync functions ────────────────────────────────────────────────────────────
|
||||
|
||||
async def sync_clients() -> int:
|
||||
"""Синхронизировать клиентов/организации."""
|
||||
items, _ = await _fetch_all(
|
||||
"/settings/odata/v2/Clients",
|
||||
config.INTRADESK_API_KEY,
|
||||
{"$filter": "(isarchived eq false)", "$orderby": "name asc"},
|
||||
)
|
||||
db = get_db()
|
||||
for item in items:
|
||||
await upsert_client(db, item)
|
||||
await db.commit()
|
||||
logger.info("sync_clients: %d records", len(items))
|
||||
return len(items)
|
||||
|
||||
|
||||
async def sync_users() -> int:
|
||||
"""Синхронизировать контактные лица клиентов."""
|
||||
items, _ = await _fetch_all(
|
||||
"/settings/odata/ClientUsers",
|
||||
config.INTRADESK_API_KEY,
|
||||
{"$filter": "(isarchived eq false)", "$orderby": "id desc"},
|
||||
)
|
||||
db = get_db()
|
||||
for item in items:
|
||||
await upsert_user(db, item)
|
||||
await db.commit()
|
||||
logger.info("sync_users: %d records", len(items))
|
||||
return len(items)
|
||||
|
||||
|
||||
async def sync_whitelist() -> int:
|
||||
"""Обновить organizations + org_users из уже синхронизированных данных Intradesk."""
|
||||
db = get_db()
|
||||
count = await sync_whitelist_from_intradesk(db)
|
||||
logger.info("sync_whitelist: %d org_users затронуто", count)
|
||||
return count
|
||||
|
||||
|
||||
async def sync_tasks() -> int:
|
||||
"""Синхронизировать заявки по нашей группе исполнителей."""
|
||||
if not config.INTRADESK_TASKS_API_KEY:
|
||||
logger.warning("sync_tasks: INTRADESK_TASKS_API_KEY не задан, пропуск")
|
||||
return 0
|
||||
|
||||
# Строим OData $filter
|
||||
conditions: list[str] = []
|
||||
if config.INTRADESK_EXECUTOR_GROUP:
|
||||
conditions.append(f"executorgroup eq {config.INTRADESK_EXECUTOR_GROUP}")
|
||||
for eid in config.INTRADESK_EXECUTORS:
|
||||
conditions.append(f"executor eq {eid}")
|
||||
|
||||
if not conditions:
|
||||
logger.warning("sync_tasks: не задан ни executor_group, ни executors — фильтр пуст")
|
||||
filter_str = "isarchived eq false"
|
||||
else:
|
||||
filter_str = "(" + " or ".join(conditions) + ")"
|
||||
|
||||
items, dicts_map = await _fetch_all(
|
||||
"/tasklist/odata/v3/tasks",
|
||||
config.INTRADESK_TASKS_API_KEY,
|
||||
{"$filter": filter_str, "$orderby": "updatedat desc"},
|
||||
)
|
||||
db = get_db()
|
||||
for item in items:
|
||||
await upsert_task(db, item, dicts_map)
|
||||
await db.commit()
|
||||
logger.info("sync_tasks: %d records", len(items))
|
||||
return len(items)
|
||||
|
||||
|
||||
# ── Orchestrator ──────────────────────────────────────────────────────────────
|
||||
|
||||
_SYNC_FUNCS = {
|
||||
"clients": sync_clients,
|
||||
"users": sync_users,
|
||||
"tasks": sync_tasks,
|
||||
}
|
||||
|
||||
# После каких типов синхронизации нужно обновить белый список
|
||||
_NEEDS_WHITELIST = {"clients", "users", "all"}
|
||||
|
||||
|
||||
async def run_sync(sync_type: str) -> dict:
|
||||
"""
|
||||
Запустить синхронизацию и вернуть результат.
|
||||
|
||||
sync_type: 'clients' | 'users' | 'tasks' | 'all'
|
||||
Возвращает dict: {sync_type, status, records_synced, error_message}
|
||||
"""
|
||||
db = get_db()
|
||||
log_id = await start_sync_log(db, sync_type)
|
||||
|
||||
if sync_type == "all":
|
||||
funcs = [sync_clients, sync_users, sync_tasks, sync_whitelist]
|
||||
elif sync_type in _NEEDS_WHITELIST:
|
||||
funcs = [_SYNC_FUNCS[sync_type], sync_whitelist]
|
||||
else:
|
||||
funcs = [_SYNC_FUNCS[sync_type]]
|
||||
|
||||
total = 0
|
||||
error_msg = ""
|
||||
try:
|
||||
for fn in funcs:
|
||||
total += await fn()
|
||||
status = "success"
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
status = "error"
|
||||
logger.exception("Ошибка синхронизации '%s': %s", sync_type, exc)
|
||||
|
||||
await finish_sync_log(db, log_id, status, total, error_msg)
|
||||
return {
|
||||
"sync_type": sync_type,
|
||||
"status": status,
|
||||
"records_synced": total,
|
||||
"error_message": error_msg,
|
||||
}
|
||||
|
|
@ -5,3 +5,4 @@ jinja2==3.1.6
|
|||
aiosqlite
|
||||
python-dotenv
|
||||
python-multipart
|
||||
httpx>=0.27.0
|
||||
|
|
|
|||
3
run.bat
3
run.bat
|
|
@ -15,6 +15,9 @@ if exist "venv\Scripts\activate.bat" (
|
|||
:: Устанавливаем зависимости если нужно (раскомментировать при первом запуске)
|
||||
:: pip install -r requirements.txt
|
||||
|
||||
echo [INFO] Запуск... Admin-панель: http://localhost:8080/admin/
|
||||
echo.
|
||||
|
||||
python main.py
|
||||
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue