From c783fcb92bdd10f3ab7a1040097629de13f32284 Mon Sep 17 00:00:00 2001 From: dv Date: Thu, 26 Mar 2026 16:21:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=B5=D1=88=20=D0=BD=D0=BE=D1=80=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/handlers/create_ticket.py | 4 +- bot/handlers/view_tickets.py | 23 ++++- data/support.db-shm | Bin 32768 -> 32768 bytes data/support.db-wal | Bin 5800992 -> 5800992 bytes external/intradesk_api.py | 183 +++++++++++++++++++++++++--------- 5 files changed, 157 insertions(+), 53 deletions(-) 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 f39aba22b2ef56597ab5d542637d5e2f7b90d53f..36a1946e834f96b69c6512376c4f29b7af7ac4b8 100644 GIT binary patch delta 287 zcmZo@U}|V!s+V}A%K!pgK+MR%ARq@MmN79fc>GD{t-iShKYNvHy(JwJUM~IKn=ua24awlKrR8XG3mt{S?nhN zbK+qVW!$`xjh_6^iDxqt>j&1!zdU|z bW@J6byZKPK85>aO2-D_02?CrzF%vNWwKYKQ diff --git a/data/support.db-wal b/data/support.db-wal index adba682362bc96ba59b3b6bb0ec24928825dd7a2..dd101ba816560fc01e3ce250d3e7ca13c5c482eb 100644 GIT binary patch delta 2687 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

-PhAUPFLPsYwbC)`D|KF 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 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")