синхронизация данных с интрой

This commit is contained in:
dv 2026-03-26 11:57:12 +03:00
parent a10824f8d6
commit 1ee5049ab4
20 changed files with 1190 additions and 6 deletions

View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(python -c \":*)"
]
}
}

7
.env
View file

@ -10,3 +10,10 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme ADMIN_PASSWORD=changeme
ADMIN_HOST=0.0.0.0 ADMIN_HOST=0.0.0.0
ADMIN_PORT=8080 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

View file

@ -10,3 +10,10 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme ADMIN_PASSWORD=changeme
ADMIN_HOST=0.0.0.0 ADMIN_HOST=0.0.0.0
ADMIN_PORT=8080 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

View file

@ -1,7 +1,15 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import RedirectResponse 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: 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(bot_users.router, prefix="/admin")
app.include_router(tickets.router, prefix="/admin") app.include_router(tickets.router, prefix="/admin")
app.include_router(chat_log.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 return app

View 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
View 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)

View file

@ -52,10 +52,13 @@
<nav> <nav>
<span class="brand">🤖 Support Admin</span> <span class="brand">🤖 Support Admin</span>
<a href="/admin/orgs">Организации</a> <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/bot-users">Пользователи бота</a>
<a href="/admin/tickets">Заявки</a> <a href="/admin/tickets">Заявки бота</a>
<a href="/admin/chat-log">Чаты</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> </nav>
<div class="container"> <div class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}

View 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 %}

View 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 %}

View 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 %}

View file

@ -15,3 +15,18 @@ ADMIN_USERNAME: str = os.environ.get("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD: str = os.environ.get("ADMIN_PASSWORD", "changeme") ADMIN_PASSWORD: str = os.environ.get("ADMIN_PASSWORD", "changeme")
ADMIN_HOST: str = os.environ.get("ADMIN_HOST", "0.0.0.0") ADMIN_HOST: str = os.environ.get("ADMIN_HOST", "0.0.0.0")
ADMIN_PORT: int = int(os.environ.get("ADMIN_PORT", "8080")) 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()
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -24,6 +24,18 @@ async def init_db() -> None:
await _db.execute(stmt) await _db.execute(stmt)
await _db.commit() 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: def get_db() -> aiosqlite.Connection:
if _db is None: if _db is None:

View 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

View file

@ -4,6 +4,7 @@ PRAGMA foreign_keys=ON;
CREATE TABLE IF NOT EXISTS organizations ( CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
intradesk_id INTEGER UNIQUE, -- id из intradesk_clients (NULL = ручное создание)
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@ -64,3 +65,68 @@ CREATE TABLE IF NOT EXISTS message_log (
attachment_json TEXT, -- JSON-строка списка вложений attachment_json TEXT, -- JSON-строка списка вложений
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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
View 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,
}

View file

@ -5,3 +5,4 @@ jinja2==3.1.6
aiosqlite aiosqlite
python-dotenv python-dotenv
python-multipart python-multipart
httpx>=0.27.0

View file

@ -15,6 +15,9 @@ if exist "venv\Scripts\activate.bat" (
:: Устанавливаем зависимости если нужно (раскомментировать при первом запуске) :: Устанавливаем зависимости если нужно (раскомментировать при первом запуске)
:: pip install -r requirements.txt :: pip install -r requirements.txt
echo [INFO] Запуск... Admin-панель: http://localhost:8080/admin/
echo.
python main.py python main.py
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (