diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index ef9422a..570d39d 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -31,7 +31,7 @@ from database.queries.intradesk import get_intradesk_contact_id_for_phone from database.queries.organizations import get_organization from database.queries.ticket_attachments import add_attachment from database.queries.tickets import create_ticket, set_intradesk_task_id -from external.intradesk_api import create_intradesk_task +from external.intradesk_api import create_intradesk_task, invalidate_client_cache logger = get_logger("bot.handlers.create_ticket") @@ -432,6 +432,8 @@ async def cb_confirm_ticket( "Intradesk task created: id=%s number=%s for ticket_id=%s", iid, intradesk_number, ticket_id, ) + if intradesk_id: + invalidate_client_cache(intradesk_id) # ── Итоговое сообщение ──────────────────────────────────────────────────── lines = ["✅ Заявка успешно создана!"] diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index 10041bf..1dbcaa7 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -25,7 +25,13 @@ from bot.states import CommentStates from database.connection import get_db from database.queries.intradesk import get_intradesk_contact_id_for_phone from database.queries.organizations import get_organization -from external.intradesk_api import add_task_comment, get_client_open_tasks, get_task_last_comment +from external.intradesk_api import ( + add_task_comment, + get_client_open_tasks, + get_task_last_comment, + invalidate_client_cache, + invalidate_task_comment, +) router = Router(router_id="view_tickets") @@ -329,17 +335,19 @@ async def on_comment_text( return data = await context.get_data() + task_id = data.get("comment_task_id") task_number = data.get("comment_task_number") # Сбрасываем состояние до отправки API — защита от повторов await context.clear() - # Получаем contact_person_id + # Получаем данные организации (нужны и для contact_person_id, и для инвалидации кэша) + intradesk_id: int | None = None contact_person_id: int | None = None - if bot_user.get("phone") and bot_user.get("org_id"): + if bot_user.get("org_id"): org = await get_organization(bot_user["org_id"]) intradesk_id = org.get("intradesk_id") if org else None - if intradesk_id: + if intradesk_id and bot_user.get("phone"): db = get_db() contact_person_id = await get_intradesk_contact_id_for_phone( db, intradesk_id, bot_user["phone"] @@ -351,6 +359,13 @@ async def on_comment_text( contact_person_id=contact_person_id, ) + if ok: + # Сбрасываем кэш — следующий переход в меню/список покажет свежие данные + if task_id: + invalidate_task_comment(task_id) + if intradesk_id: + invalidate_client_cache(intradesk_id) + from bot.handlers.menu import build_main_menu_text if ok: diff --git a/data/support.db-shm b/data/support.db-shm index f39aba2..36a1946 100644 Binary files a/data/support.db-shm and b/data/support.db-shm differ diff --git a/data/support.db-wal b/data/support.db-wal index adba682..dd101ba 100644 Binary files a/data/support.db-wal and b/data/support.db-wal differ diff --git a/external/intradesk_api.py b/external/intradesk_api.py index 9ec2a61..342cc64 100644 --- a/external/intradesk_api.py +++ b/external/intradesk_api.py @@ -3,13 +3,20 @@ Intradesk REST API: создание заявок и чтение истории Эндпоинты: POST /changes/v3/tasks — создать заявку + PUT /changes/v3/tasks — изменить заявку / добавить комментарий GET /taskhistory/api/v2.0/lifetime/{id}/full — история (комментарии, смена статуса...) + +Кэширование: + Все GET-запросы кэшируются в памяти с TTL, чтобы не дёргать API при каждом + переходе между экранами. Мутации (create/comment) сбрасывают нужные ключи. """ from __future__ import annotations import json import logging import re +import time +from typing import Any import httpx @@ -17,6 +24,8 @@ import config logger = logging.getLogger(__name__) +# ── HTML-очистка ────────────────────────────────────────────────────────────── + _TAG_RE = re.compile(r"<[^>]+>") @@ -25,25 +34,84 @@ def _strip_html(text: str) -> str: if not text: return text cleaned = _TAG_RE.sub("", text) - # Схлопнуть множественные пробелы/переносы cleaned = re.sub(r"\s{2,}", " ", cleaned).strip() return cleaned +# ── TTL-кэш ─────────────────────────────────────────────────────────────────── + +# Структура: ключ → (expires_at: float, данные: Any) +_cache: dict[str, tuple[float, Any]] = {} + +# TTL по типу данных +_TTL_COUNT = 60.0 # count_client_open_tasks +_TTL_LAST_COMMENT = 60.0 # get_client_last_comment +_TTL_OPEN_TASKS = 30.0 # get_client_open_tasks (список страниц) +_TTL_TASK_COMMENT = 30.0 # get_task_last_comment (история одной задачи) + + +def _cache_get(key: str) -> tuple[bool, Any]: + """Вернуть (True, значение) если ключ свежий, иначе (False, None).""" + entry = _cache.get(key) + if entry is None: + return False, None + expires_at, data = entry + if time.monotonic() > expires_at: + del _cache[key] + return False, None + return True, data + + +def _cache_set(key: str, value: Any, ttl: float) -> None: + _cache[key] = (time.monotonic() + ttl, value) + + +def _cache_delete_prefix(prefix: str) -> None: + """Удалить все ключи начинающиеся с prefix.""" + for k in [k for k in _cache if k.startswith(prefix)]: + del _cache[k] + + +def invalidate_client_cache(client_id: int) -> None: + """ + Сбросить кэш клиента после мутации (создание заявки и т.п.). + + Очищает: счётчик, последний комментарий, все страницы списка заявок. + """ + _cache_delete_prefix(f"count:{client_id}") + _cache_delete_prefix(f"last_comment:{client_id}") + _cache_delete_prefix(f"open_tasks:{client_id}:") + logger.debug("cache invalidated for client_id=%s", client_id) + + +def invalidate_task_comment(task_id: int) -> None: + """Сбросить кэш истории конкретной задачи после добавления комментария.""" + _cache.pop(f"task_comment:{task_id}", None) + logger.debug("cache invalidated for task_id=%s", task_id) + + +# ── API-функции ─────────────────────────────────────────────────────────────── + async def get_task_last_comment(task_id: int) -> dict | None: """ Получить последний комментарий к заявке из Intradesk. Возвращает dict: {text, author, date} или None если нет комментариев. + Результат кэшируется на _TTL_TASK_COMMENT секунд. """ - key = config.INTRADESK_TASKS_API_KEY - if not key: + ck = f"task_comment:{task_id}" + hit, cached = _cache_get(ck) + if hit: + return cached + + api_key = config.INTRADESK_TASKS_API_KEY + if not api_key: return None url = ( f"{config.INTRADESK_BASE_URL.rstrip('/')}" f"/taskhistory/api/v2.0/lifetime/{task_id}/full" - f"?ApiKey={key}&sortDirection=Desc&top=10&events=50" + f"?ApiKey={api_key}&sortDirection=Desc&top=10&events=50" ) try: async with httpx.AsyncClient() as client: @@ -58,33 +126,43 @@ async def get_task_last_comment(task_id: int) -> dict | None: logger.warning("get_task_last_comment error: %s", exc) return None - # Ищем последнее событие с blockname="comment" (type=50) + result: dict | None = None for entry in data.get("data") or []: for ev in (entry.get("events") or {}).get("data") or []: if ev.get("blockname") == "comment" and ev.get("type") == 50: raw_date = entry.get("eventat") or "" - # "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54" - short_date = raw_date[:16].replace("T", " ") - return { + result = { "text": _strip_html(ev.get("stringvalue") or ""), "author": entry.get("username") or "—", - "date": short_date, + "date": raw_date[:16].replace("T", " "), } - return None + break + if result: + break + + _cache_set(ck, result, _TTL_TASK_COMMENT) + return result async def count_client_open_tasks(client_id: int) -> int: - """Количество незакрытых заявок клиента через API. - - Intradesk не возвращает @odata.count при $top=0, поэтому берём $top=1 - — count в ответе отражает полный итог, а не размер страницы. """ - key = config.INTRADESK_TASKS_API_KEY - if not key: + Количество незакрытых заявок клиента. + + Intradesk не возвращает @odata.count при $top=0, поэтому берём $top=1. + Результат кэшируется на _TTL_COUNT секунд. + """ + ck = f"count:{client_id}" + hit, cached = _cache_get(ck) + if hit: + return cached + + api_key = config.INTRADESK_TASKS_API_KEY + if not api_key: return 0 + url = ( f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks" - f"?ApiKey={key}" + f"?ApiKey={api_key}" f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)" f"&$top=1&$count=true" ) @@ -94,31 +172,35 @@ async def count_client_open_tasks(client_id: int) -> int: if resp.status_code != 200: return 0 data = resp.json() - # @odata.count — полный итог по фильтру, не зависит от $top count = data.get("@odata.count") - if count is not None: - return int(count) - # Fallback: если count не вернулся — считаем по размеру value - return len(data.get("value") or []) + result = int(count) if count is not None else len(data.get("value") or []) except Exception: return 0 + _cache_set(ck, result, _TTL_COUNT) + return result + async def get_client_last_comment(client_id: int) -> dict | None: """ Найти последний комментарий по любой незакрытой заявке клиента. - Берём задачу с самым свежим updatedat → ищем последний комментарий в её истории. + Берём задачу с самым свежим updatedat → ищем комментарий в её истории. Возвращает {text, author, date, task_number} или None. + Результат кэшируется на _TTL_LAST_COMMENT секунд. """ - key = config.INTRADESK_TASKS_API_KEY - if not key: + ck = f"last_comment:{client_id}" + hit, cached = _cache_get(ck) + if hit: + return cached + + api_key = config.INTRADESK_TASKS_API_KEY + if not api_key: return None - # Шаг 1: самая свежая задача клиента url = ( f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks" - f"?ApiKey={key}" + f"?ApiKey={api_key}" f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)" f"&$orderby=updatedat desc&$top=1" ) @@ -133,18 +215,22 @@ async def get_client_last_comment(client_id: int) -> dict | None: tasks = data.get("value") or [] if not tasks: + _cache_set(ck, None, _TTL_LAST_COMMENT) return None task = tasks[0] task_id = task.get("id") task_number = task.get("tasknumber") if not task_id: + _cache_set(ck, None, _TTL_LAST_COMMENT) return None - # Шаг 2: последний комментарий этой задачи + # get_task_last_comment имеет собственный кэш — двойного запроса не будет comment = await get_task_last_comment(task_id) if comment: - comment["task_number"] = task_number + comment = {**comment, "task_number": task_number} + + _cache_set(ck, comment, _TTL_LAST_COMMENT) return comment @@ -157,18 +243,24 @@ async def get_client_open_tasks( Получить незакрытые заявки клиента из Intradesk API. Возвращает {"tasks": [...], "total": int}. - Последний комментарий загружается параллельно через history API для каждой задачи. + Последний комментарий загружается параллельно через history API. + Результат кэшируется на _TTL_OPEN_TASKS секунд. """ import asyncio - key = config.INTRADESK_TASKS_API_KEY - if not key: + ck = f"open_tasks:{client_id}:{page}:{page_size}" + hit, cached = _cache_get(ck) + if hit: + return cached + + api_key = config.INTRADESK_TASKS_API_KEY + if not api_key: return {"tasks": [], "total": 0} skip = page * page_size url = ( f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks" - f"?ApiKey={key}" + f"?ApiKey={api_key}" f"&$filter=(clientid eq {client_id}) and (closedat eq null) and (isarchived eq false)" f"&$orderby=updatedat desc" f"&$top={page_size}&$skip={skip}" @@ -188,7 +280,7 @@ async def get_client_open_tasks( total = data.get("@odata.count") or 0 raw_tasks = data.get("value") or [] - # Словари для разрешения имён + # Словари для разрешения имён статусов dicts_map: dict[int, str] = {} for d in data.get("dictionaries") or []: did = d.get("id") @@ -198,7 +290,6 @@ async def get_client_open_tasks( if did: dicts_map[did] = dname - # Базовые данные задач base_tasks = [] for t in raw_tasks: status_id = t.get("status") @@ -211,7 +302,7 @@ async def get_client_open_tasks( "updated_at": t.get("updatedat") or "", }) - # Параллельно загружаем последний комментарий для каждой задачи + # Параллельно загружаем последний комментарий (используют собственный кэш) task_ids = [t["id"] for t in base_tasks if t["id"]] comments = await asyncio.gather( *[get_task_last_comment(tid) for tid in task_ids], @@ -223,22 +314,23 @@ async def get_client_open_tasks( task["last_comment"] = comment if isinstance(comment, dict) else None tasks.append(task) - return {"tasks": tasks, "total": total} + result = {"tasks": tasks, "total": total} + _cache_set(ck, result, _TTL_OPEN_TASKS) + return result def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None: - """Извлечь последний комментарий из поля lifetime задачи (если последнее событие — комментарий).""" + """Извлечь последний комментарий из поля lifetime задачи.""" if not lifetime: return None for entry in (lifetime.get("data") or []): for ev in (entry.get("events") or {}).get("data") or []: if ev.get("blockname") == "comment" and ev.get("type") == 50: raw_date = entry.get("eventat") or "" - short_date = raw_date[:16].replace("T", " ") # "2026-03-26 11:54" return { "text": _strip_html(ev.get("stringvalue") or ""), "author": entry.get("username") or "—", - "date": short_date, + "date": raw_date[:16].replace("T", " "), } return None @@ -249,9 +341,10 @@ async def add_task_comment( contact_person_id: int | None = None, ) -> bool: """ - Добавить комментарий к заявке через Intradesk API (PUT /changes/v3/tasks). + Добавить комментарий к заявке (PUT /changes/v3/tasks). Возвращает True при успехе, False при ошибке. + Кэш задачи сбрасывается вызывающей стороной через invalidate_task_comment. """ key = config.INTRADESK_CHANGES_API_KEY if not key: @@ -307,15 +400,13 @@ async def create_intradesk_task( Создать заявку в Intradesk. Возвращает ответ API (dict с полями id, tasknumber и др.) или None при ошибке. - Вложения — пока добавляются как ссылки в конец описания (до появления upload API). - В dry-run режиме запрос печатается в консоль и возвращается None. + Кэш клиента сбрасывается вызывающей стороной через invalidate_client_cache. """ key = config.INTRADESK_CHANGES_API_KEY if not key: logger.warning("create_intradesk_task: INTRADESK_CHANGES_API_KEY не задан") return None - # Вложения — ссылки в конце описания full_desc = description if attachment_urls: full_desc += "\n\nВложения:\n" + "\n".join(attachment_urls) @@ -324,15 +415,12 @@ async def create_intradesk_task( "name": json.dumps({"value": name}, ensure_ascii=False), "description": json.dumps({"value": full_desc}, ensure_ascii=False), } - # initiator: вариант 3 из документации — клиент + пользователь клиента. - # clientid заполняется автоматически из initiatorclient, отдельно не нужен. if client_intradesk_id is not None and contact_person_id is not None: blocks["initiator"] = json.dumps( {"value": {"groupid": client_intradesk_id, "userid": contact_person_id}}, ensure_ascii=False, ) elif client_intradesk_id is not None: - # только клиент (нет конкретного контактного лица) blocks["initiator"] = json.dumps( {"value": {"groupid": client_intradesk_id}}, ensure_ascii=False ) @@ -374,7 +462,6 @@ async def create_intradesk_task( ) return None result = resp.json() - # API может вернуть как "Number"/"Id" (PascalCase), так и "number"/"id" task_id = result.get("id") or result.get("Id") or result.get("ID") task_number = ( result.get("tasknumber")