215 lines
7.3 KiB
Python
215 lines
7.3 KiB
Python
"""
|
||
Сервис синхронизации данных из 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,
|
||
}
|