From 7fab7118860a2d24dcf5884858009c437d749752 Mon Sep 17 00:00:00 2001 From: dv Date: Thu, 26 Mar 2026 15:39:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=BE=D1=80=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- bot/handlers/create_ticket.py | 72 ++++++++++- bot/handlers/menu.py | 2 + bot/handlers/view_tickets.py | 234 +++++++++++++++++++++++----------- bot/send_helper.py | 12 +- data/support.db-shm | Bin 32768 -> 32768 bytes data/support.db-wal | Bin 5800992 -> 5800992 bytes external/intradesk_api.py | 82 ++++++++++++ 8 files changed, 322 insertions(+), 84 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 96843cb..15bae42 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,9 @@ "Bash(python -c \"import maxapi; import inspect; members = [m for m in dir\\(maxapi\\) if not m.startswith\\(''_''\\)]; print\\(members\\)\")", "Bash(python -c \"from maxapi import Bot; methods = [m for m in dir\\(Bot\\) if not m.startswith\\(''_''\\)]; print\\(methods\\)\")", "Bash(python -c \"from maxapi import Bot; import inspect; print\\(inspect.signature\\(Bot.edit_message\\)\\)\")", - "Bash(python -c \"from maxapi.types.updates.message_callback import MessageCallback; print\\([m for m in dir\\(MessageCallback\\) if not m.startswith\\(''_''\\)]\\)\")" + "Bash(python -c \"from maxapi.types.updates.message_callback import MessageCallback; print\\([m for m in dir\\(MessageCallback\\) if not m.startswith\\(''_''\\)]\\)\")", + "Bash(python -c \"from maxapi.types import SendedMessage; print\\(SendedMessage.__fields__ if hasattr\\(SendedMessage, ''__fields__''\\) else dir\\(SendedMessage\\)\\)\")", + "Bash(python -c \"from maxapi.context import State; s = State\\(\\); print\\(type\\(s\\), dir\\(s\\)\\)\")" ] } } diff --git a/bot/handlers/create_ticket.py b/bot/handlers/create_ticket.py index a41825c..b966c58 100644 --- a/bot/handlers/create_ticket.py +++ b/bot/handlers/create_ticket.py @@ -37,6 +37,54 @@ logger = get_logger("bot.handlers.create_ticket") router = Router(router_id="create_ticket") + +async def cleanup_ticket_flow(bot, context: BaseContext) -> None: + """ + Удалить висящие сообщения незавершённого потока создания заявки. + Вызывается из любого обработчика, который прерывает этот поток + (главное меню, список заявок, повторное нажатие «Создать заявку»). + """ + from bot.states import TicketStates # avoid circular at module level + state = await context.get_state() + creation_states = [ + TicketStates.waiting_description, + TicketStates.collecting_attachments, + TicketStates.confirming, + ] + if state not in creation_states: + return + data = await context.get_data() + for mid in (data.get("flow_message_ids") or []): + try: + await bot.delete_message(message_id=mid) + except Exception: + pass + await context.clear() + + +async def _track_mid(context: BaseContext, mid: str | None) -> None: + """Добавить message_id в список сообщений потока создания.""" + if not mid: + return + data = await context.get_data() + ids: list[str] = data.get("flow_message_ids") or [] + ids.append(mid) + data["flow_message_ids"] = ids + await context.set_data(data) + + +async def _delete_flow_messages(bot, context: BaseContext, extra_mid: str | None = None) -> None: + """Удалить все сообщения, накопленные за время потока создания.""" + data = await context.get_data() + ids: list[str] = list(data.get("flow_message_ids") or []) + if extra_mid: + ids.append(extra_mid) + for mid in ids: + try: + await bot.delete_message(message_id=mid) + except Exception: + pass # сообщение уже удалено или слишком старое + _MEDIA_TYPES = { AttachmentType.IMAGE, AttachmentType.VIDEO, @@ -120,8 +168,11 @@ def _preview_text(org_name: str | None, description: str, pending_atts: list[dic async def cb_create_ticket( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: + # Зачищаем предыдущий незавершённый поток, если был + await cleanup_ticket_flow(event.bot, context) + await context.set_state(TicketStates.waiting_description) - await context.set_data({"pending_attachments": []}) + await context.set_data({"pending_attachments": [], "flow_message_ids": []}) logger.info("📝 create_ticket started | user_id=%s", bot_user["max_user_id"]) await event.answer(notification="") @@ -129,7 +180,7 @@ async def cb_create_ticket( if chat_id is None: return - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], @@ -140,6 +191,7 @@ async def cb_create_ticket( ), attachments=[done_ticket_kb()], ) + await _track_mid(context, mid) # ── Шаг 2: первое сообщение с описанием ────────────────────────────────────── @@ -159,7 +211,7 @@ async def on_ticket_description( await context.update_data(description=text, pending_attachments=attachments) await context.set_state(TicketStates.collecting_attachments) - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=user_id, @@ -169,6 +221,7 @@ async def on_ticket_description( ), attachments=[done_ticket_kb()], ) + await _track_mid(context, mid) # ── Шаг 3: дополнительные вложения/текст ───────────────────────────────────── @@ -227,13 +280,14 @@ async def cb_done_ticket( await context.set_state(TicketStates.confirming) - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], text=_preview_text(org_name, description, pending_atts), attachments=[confirm_ticket_kb()], ) + await _track_mid(context, mid) # ── Шаг 5б: "Продолжить редактирование" ────────────────────────────────────── @@ -252,7 +306,7 @@ async def cb_edit_ticket( if chat_id is None: return - await bot_send( + mid = await bot_send( event.bot, chat_id=chat_id, user_id=bot_user["max_user_id"], @@ -262,6 +316,7 @@ async def cb_edit_ticket( ), attachments=[done_ticket_kb()], ) + await _track_mid(context, mid) # ── Шаг 5в: "Отменить создание" ────────────────────────────────────────────── @@ -274,6 +329,10 @@ async def cb_cancel_ticket( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: await event.answer(notification="Создание отменено") + + # Удаляем все сообщения потока + текущее сообщение с кнопками + callback_mid = event.message.body.mid if (event.message and event.message.body) else None + await _delete_flow_messages(event.bot, context, extra_mid=callback_mid) await context.clear() chat_id = event.message.recipient.chat_id if event.message else None @@ -301,6 +360,9 @@ async def cb_confirm_ticket( # Защита от двойного нажатия: читаем данные и сбрасываем состояние атомарно. # Повторный tap уже не пройдёт фильтр states=confirming. data = await context.get_data() + # Удаляем все сообщения потока + текущее (предпросмотр с кнопками) + callback_mid = event.message.body.mid if (event.message and event.message.body) else None + await _delete_flow_messages(event.bot, context, extra_mid=callback_mid) await context.clear() description = (data.get("description") or "").strip() diff --git a/bot/handlers/menu.py b/bot/handlers/menu.py index 3489297..132758e 100644 --- a/bot/handlers/menu.py +++ b/bot/handlers/menu.py @@ -49,6 +49,8 @@ async def build_main_menu_text(bot_user: dict) -> str: async def cb_main_menu( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: + from bot.handlers.create_ticket import cleanup_ticket_flow + await cleanup_ticket_flow(event.bot, context) await context.set_state(None) await event.answer(notification="") diff --git a/bot/handlers/view_tickets.py b/bot/handlers/view_tickets.py index 8056411..efbb693 100644 --- a/bot/handlers/view_tickets.py +++ b/bot/handlers/view_tickets.py @@ -1,13 +1,17 @@ """ -Обработчики просмотра заявок (Intradesk). +Обработчики просмотра заявок (Intradesk API). -Навигация через callback payload — все переходы редактируют текущее сообщение: +Заявки загружаются из Intradesk API по нажатию кнопки — только незакрытые. +Навигация через callback payload: "view_tickets" → список, страница 0 "itickets_page:{n}" → список, страница n - "iticket_detail:{id}" → карточка задачи + "iticket_detail:{id}" → карточка задачи (из кэша в context) "main_menu" → в menu.py """ +from __future__ import annotations + import re +import time from maxapi.context import BaseContext from maxapi.dispatcher import Router @@ -15,24 +19,20 @@ from maxapi.filters import F from maxapi.types.updates.message_callback import MessageCallback from bot.filters import PayloadStartsWith -from bot.keyboards import intradesk_tasks_list_kb, iticket_detail_kb, menu_only_kb +from bot.keyboards import iticket_detail_kb, menu_only_kb from bot.send_helper import bot_reply from database.connection import get_db -from database.queries.intradesk import ( - count_tasks_for_local_org, - get_task_for_local_org, - get_tasks_for_local_org, -) -from external.intradesk_api import get_task_last_comment +from database.queries.organizations import get_organization +from external.intradesk_api import get_client_open_tasks, get_task_last_comment router = Router(router_id="view_tickets") -_PAGE_SIZE = 8 +_PAGE_SIZE = 5 def _status_icon(status_name: str) -> str: sn = status_name.lower() - if "выполнен" in sn: + if "выполнен" in sn or "закрыт" in sn: return "🟢" if "работ" in sn: return "🟡" @@ -41,43 +41,144 @@ def _status_icon(status_name: str) -> str: return "⚪" -def _priority_icon(priority_name: str) -> str: - pn = priority_name.lower() - if "критич" in pn: - return "🔴" - if "высок" in pn: - return "🟠" - if "средн" in pn: - return "🟡" - return "⚪" +def _fmt_date(iso: str) -> str: + """'2026-03-26T09:57:11Z' → '26.03.2026'""" + if not iso or len(iso) < 10: + return iso or "—" + y, m, d = iso[:10].split("-") + return f"{d}.{m}.{y}" -async def _tasks_page_text(org_id: int, page: int) -> tuple[str, list[dict], int]: - """Возвращает (текст, задачи, всего).""" - db = get_db() - tasks = await get_tasks_for_local_org(db, org_id, page=page, page_size=_PAGE_SIZE) - total = await count_tasks_for_local_org(db, org_id) - if not tasks: - return "📭 У вашей организации пока нет заявок.", [], 0 +def _fmt_date_short(iso: str) -> str: + """'2026-03-26' → '26.03'""" + if not iso or len(iso) < 10: + return iso or "" + y, m, d = iso[:10].split("-") + return f"{d}.{m}" + + +def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str: pages = max(1, -(-total // _PAGE_SIZE)) - text = f"📋 Заявки — стр. {page + 1} из {pages} (всего: {total}):" - return text, tasks, total + lines = [f"📋 Активные заявки (всего: {total}) — стр. {page + 1}/{pages}"] + + for t in tasks: + icon = _status_icon(t.get("status_name") or "") + num = t.get("task_number") or t.get("id") or "?" + name = (t.get("name") or "—")[:60] + created = _fmt_date(t.get("created_at") or "") + status = t.get("status_name") or "—" + + lines += [ + "", + "─" * 20, + f"📌 №{num} · {created}", + f"{name}", + f"{icon} {status}", + ] + + comment = t.get("last_comment") + if comment and comment.get("text"): + ctext = comment["text"][:80] + ("…" if len(comment["text"]) > 80 else "") + author_short = (comment.get("author") or "").split()[0] # только фамилия + cdate = _fmt_date_short(comment.get("date") or "") + lines.append(f'💬 "{ctext}" — {author_short} ({cdate})') + + return "\n".join(lines) + + +async def _load_and_show( + event: MessageCallback, + bot_user: dict, + page: int, + context: BaseContext, +) -> None: + """ + Загрузить страницу заявок из API и показать. + + Сразу редактирует сообщение на «⏳ Загрузка...», затем делает запрос к API. + Перед рендером проверяет load_token — если пользователь успел уйти + в другой флоу, контекст изменится и мы тихо выходим. + """ + org_id = bot_user.get("org_id") + intradesk_client_id: int | None = None + if org_id: + org = await get_organization(org_id) + intradesk_client_id = org.get("intradesk_id") if org else None + + # Уникальный токен для этого запроса + token = str(time.monotonic_ns()) + mid = event.message.body.mid if (event.message and event.message.body) else None + chat_id = event.message.recipient.chat_id if event.message else None + + # Записываем токен в контекст и сразу показываем загрузку + await context.set_data({"load_token": token}) + await bot_reply( + event, + text="⏳ Загружаем список заявок...", + attachments=None, + user_id=bot_user["max_user_id"], + chat_id=chat_id, + ) + + if not intradesk_client_id: + # Проверяем токен перед рендером + data = await context.get_data() + if data.get("load_token") != token: + return + await _edit_or_send(event.bot, mid, chat_id, + text="⚠️ Ваша организация не привязана к Intradesk. Обратитесь к администратору.", + attachments=[menu_only_kb()]) + return + + # Запрос к API + result = await get_client_open_tasks(intradesk_client_id, page=page, page_size=_PAGE_SIZE) + tasks = result["tasks"] + total = result["total"] + + # Проверяем токен — не ушёл ли пользователь пока грузили + data = await context.get_data() + if data.get("load_token") != token: + return # Пользователь уже в другом флоу — не трогаем его экран + + # Сохраняем задачи в контексте для детального просмотра + await context.set_data({"tasks": tasks, "page": page, "total": total}) + + if not tasks: + await _edit_or_send(event.bot, mid, chat_id, + text="📭 Активных заявок нет.", + attachments=[menu_only_kb()]) + return + + from bot.keyboards import intradesk_tasks_list_kb + text = _build_tasks_text(tasks, page, total) + kb = intradesk_tasks_list_kb(tasks, page=page, total=total, page_size=_PAGE_SIZE) + await _edit_or_send(event.bot, mid, chat_id, text=text, attachments=[kb]) + + +async def _edit_or_send(bot, mid: str | None, chat_id: int | None, *, text: str, attachments) -> None: + """Редактировать сообщение по mid или отправить новое если mid недоступен.""" + if mid: + try: + await bot.edit_message( + message_id=mid, + text=text, + attachments=attachments if attachments is not None else [], + ) + return + except Exception: + pass + if chat_id: + await bot.send_message(chat_id=chat_id, text=text, attachments=attachments or []) @router.message_callback(F.callback.payload == "view_tickets") async def cb_view_tickets( event: MessageCallback, context: BaseContext, bot_user: dict ) -> None: + from bot.handlers.create_ticket import cleanup_ticket_flow + await cleanup_ticket_flow(event.bot, context) await event.answer(notification="") - text, tasks, total = await _tasks_page_text(bot_user["org_id"], 0) - kb = intradesk_tasks_list_kb(tasks, page=0, total=total) if tasks else menu_only_kb() - await bot_reply( - event, - text=text, - attachments=[kb], - user_id=bot_user["max_user_id"], - chat_id=event.message.recipient.chat_id if event.message else None, - ) + await _load_and_show(event, bot_user, page=0, context=context) @router.message_callback(PayloadStartsWith("itickets_page:")) @@ -89,15 +190,7 @@ async def cb_itickets_page( page = int(event.callback.payload.split(":")[1]) except (IndexError, ValueError): page = 0 - text, tasks, total = await _tasks_page_text(bot_user["org_id"], page) - kb = intradesk_tasks_list_kb(tasks, page=page, total=total) if tasks else menu_only_kb() - await bot_reply( - event, - text=text, - attachments=[kb], - user_id=bot_user["max_user_id"], - chat_id=event.message.recipient.chat_id if event.message else None, - ) + await _load_and_show(event, bot_user, page=page, context=context) @router.message_callback(PayloadStartsWith("iticket_detail:")) @@ -110,42 +203,35 @@ async def cb_iticket_detail( await event.answer(notification="Ошибка") return - db = get_db() - task = await get_task_for_local_org(db, task_id, bot_user["org_id"]) - - if task is None: - await event.answer(notification="Нет доступа") - return - await event.answer(notification="") - sicon = _status_icon(task.get("status_name") or "") - picon = _priority_icon(task.get("priority_name") or "") + # Ищем задачу в кэше контекста (загружена при показе списка) + data = await context.get_data() + task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None) + if task is None: + await bot_reply( + event, + text="⚠️ Заявка не найдена. Вернитесь к списку.", + attachments=[iticket_detail_kb()], + user_id=bot_user["max_user_id"], + chat_id=event.message.recipient.chat_id if event.message else None, + ) + return + + icon = _status_icon(task.get("status_name") or "") num = task.get("task_number") or task["id"] + lines = [ f"📌 Заявка #{num}", - f"Статус: {sicon} {task.get('status_name') or '—'}", - f"Приоритет: {picon} {task.get('priority_name') or '—'}", + f"Статус: {icon} {task.get('status_name') or '—'}", + f"Создана: {_fmt_date(task.get('created_at') or '')}", + f"Обновлена: {_fmt_date(task.get('updated_at') or '')}", ] - if task.get("service_name"): - lines.append(f"Сервис: {task['service_name']}") - if task.get("executor_name"): - lines.append(f"Исполнитель: {task['executor_name']}") - lines.append(f"Создана: {(task.get('created_at') or '')[:10] or '—'}") - lines.append(f"Обновлена: {(task.get('updated_at') or '')[:10] or '—'}") - if task.get("name"): lines += ["", f"📄 {task['name']}"] - if task.get("description"): - desc = re.sub(r"<[^>]+>", "", task["description"]).strip() - if desc: - if len(desc) > 800: - desc = desc[:800] + "…" - lines += ["", f"💬 {desc}"] - - # Последний комментарий — запрашиваем из Intradesk API + # Последний комментарий — отдельный запрос к history API comment = await get_task_last_comment(task["id"]) if comment and comment.get("text"): ctext = comment["text"] diff --git a/bot/send_helper.py b/bot/send_helper.py index e7e262e..d1c58e5 100644 --- a/bot/send_helper.py +++ b/bot/send_helper.py @@ -9,19 +9,23 @@ async def bot_send( user_id: int | None, text: str, attachments=None, -) -> None: - """Отправить новое сообщение.""" +) -> str | None: + """Отправить новое сообщение. Возвращает message_id (mid) или None.""" kw: dict = {"chat_id": chat_id, "text": text} if attachments is not None: kw["attachments"] = attachments - await bot.send_message(**kw) + result = await bot.send_message(**kw) + mid: str | None = None + if result and result.message and result.message.body: + mid = result.message.body.mid await log_message( direction="out", max_user_id=user_id, max_chat_id=chat_id, - message_id=None, + message_id=mid, text=text, ) + return mid async def bot_edit( diff --git a/data/support.db-shm b/data/support.db-shm index 1031b03204388772a562fa20a5eab55436041f95..f99e54882b0bea063c36196a4c311439d7512f30 100644 GIT binary patch delta 502 zcmZo@U}|V!s+V}A%K!pwK+MR%ATS3=FJfX~kbM-MurrTOeYf?6!b3(u$!d+u?laF& zk|R|;$ZQap1;qc609cTLfr(+l#>VB0^~^y2LZGz+MEk=n28IEc%>|@E;Q(?F>GWnp zCJqNy1BPUVZXokJ>*QY^NrssW*$lZ1`3!{&#SEnkVB0o0*w^+fNo?zRhgFki40Z`6uh-Umi)DnV8e~ V79R>{o6N+l#cafo3X~C40sz6vJI4S3 diff --git a/data/support.db-wal b/data/support.db-wal index 91b124bb4191f61504e789f44b0be68902fb4227..eddd421ab6bda99dcf0bd8e42cb0b703e2ca529a 100644 GIT binary patch delta 22003 zcmeI43s4mI9mlylIF5VDLxS%Ux)F=kVwfwZ@jh^I)jgqbjzNvig6k(d&ATrxwE9e4N4Q|Gu#e#l=k zq$78AT1skjFuJ6-BJ18I686WRSWW5m)+LmvNvAYU94tzv2~6D_Bi`C z`&UOXTcc)c*(ygVysTr7t0URB9X7UxJ<7gkR%@rJo%!Wzhs{yo$d6TPkroEI(khh?M)Z2BKpb^Cz{e`?sXv4b;xBX9cge?MUDFde8*L8ceW z>U)@8R?pCfWk}hJMwGIb4ZX4{3QLR}q<6PaPY1V>>(+A;`3wwNSYb?6ew0;dtyZ8XUh=h zXK*&H7W?G`6zbS72k)2H$4EMr_Fu4^U$VSkPQA51c1_-zN+0BxQb~L-ekqA(=&2G^ z)Fk&+)M!yrgZ&og)1D1B_S?bx?U!$zPP!J_`Yz|U4&HCWmlK`agP+=>Kz=Lpz1*wc z`d((}6TUD)qTNSG1VK<5lGK^FGSpQA5@y$WVHd|G^T20l*u@9$;=YP0WrEqHjC1kx zyo*OJQie`>@MNZfkVgeZ%C$A2*)0aeWr^G9sl4%$Mk~{ z&pl&}d87n|4y8|YKcT}XnxTDt&=fA!V+ses<6b%OQ|$i^`4o;a>j)eod(Jld|9;39 z0FOZ*@Vo-9t_ELSRToZ7x&D5}X)Z)wK%f4u)PKM7_h(5!E{kb(MmWo&o1Xvko^R~CHrNxr?ZLpToI+$P*-KiMIiBTyXc6jrGuucckxm%O&| z_gn(LY+t!ROoq?ie7i|2uHWhdKgdsY4!#QasqUz5soGWFs;;V9R86Xjs&ln%K_W&a zhBx=x((N14#j%t2p-Wn^QNJIZgLemYBzJeZodBNgu2X9WGihm zCIKt@o&=cwKW$oSJ=pS?`21$hrp+#!hPvA{aB8u>$Z-|fRHW!aHqB7jCpQyo8OsX_ zxL0JQ$Gsv3ilj6eldi`NVMZtru=#tD^qd^Yy1&eg+Q6CrbC>zU+{|CHqA~c>Z$;tA z{NC~!&iq~S8iw94M`ijd&obRWWoMZva!odWFdxis!0R;79pT_*LZMChoGmFGAt1^jh|8SNbRkL=siaiPs zmfFr9gYRnC6Yy{=_I>s^e0`6t;~#|uFJfa|?KS`8DIF}V32UG{e^}Db^P3Vot&d*# zkki1IE)7g`(?CbUhHobCYS@7^AeL41(15Ihp$lCP^U1iEg1S}UI@BY9r6dU~l85=+ zw_y>5j(e5I*wB9YdX%?OB=FqRFLWe<5S*dL8>o+RI0@tn9AR8TIR;;R@YKT89syhAr)~Y=(wLQzJs>P+M>29jB zEOkzZQ0BaYR3-7d;I67vKfA*@`7C2;lRzTMd?<;Cq_AMSZ)@s4s7a2zwj%Xmv}!M$ zJnIKY0U00%6o3*?0iytaAOHvif`HLLFz^Tv0*nF10^@-3zyx3-FbPltlYvlR3NRH2 z1Hyp_AQG4cL;=yjbRY(Z1>yh=palp(2ao^-=m7&@1WbS#m;uZLW&yK-IY2z{D3Acm v1s(%_2FwHI13w2A0ExgtU=i>*kOVvdBm*>%0xSknfiz%A?a8xCEj9lI!24Mx delta 5348 zcmeH~dr(tX8o+aJZf=s38>! z*J`QWBD%$H)wbF#Dbl+xYb|Qstz{Lgk810xSZnR>Ry$K2$Jg$6LQ!uTr``OwGiBzN z33Kkb-|u|q`)&5|pU0LowdskoFpLcB}x zmAkUy2(m1l^J$SJNiC)tW2#Qi4LmOiQ0OdHy~%3OHwByc@b^_}LJI%t%?)Xsa{`?r ztI^e@r5kJYoK_>wm%>Q8gG}YZMvEUB+Nu#3(J+*nW8~^4&>1Xz*E<>vNHmI~rZp|* z7Hh|MmxvuUJ$K<{@y7_e8my_7i8`)sYGzWB#Z*~quC7c=ORd%$j0T;-l&mu* z8%)}f3!BEK3`?wh+9e^g{7Ppw=`3kGzS|-rM(&s1Fvhi5`N(J$7p@WK(Gbeiko>5j z)P^KaLsA1cI>+zO)s}ZKfc$ns+hcT0*AzHXa_t)=fiwuw0A{oAy-eHS+ut$2U!BQ4EQf!v0T4VKt zV#9+DDuVO%Lyg$Oklr*lT(C=A>1eT%i+W3b$A~u)F405PiviI{JJ{RJ@3ghwQmEMN zBARU!v5oA%*e}>Vb~nrSWyP;jIPI6k+@f#gxqf1n5=RmXYU=ANXV=V{zi76Zv*sXO zsj$DE+!32RN*XnY`%5Eg{ond)rl@tj%?5R9evkv)Q@=FaFAX=``v3P0cl})Xm$4HT z&G{J(M|o^GeYEW1-?JIJMf|$emqT#BQOrFN+rutnGnso%+qVpvNpZGgL^S7fjG)@u z&z}h(9`5~X=uVw(jp#Jj`8^rrOT%3|@bC|RO0NHV(n!#JR~YJIgOU?ZX2=&z(s<)- z!4Hb;(4c=j6>8miJWT=-NcIN)>|k%RH<;&{3e_Dx`=)h}<=UU1*(AI`Gkmmb@imx{ z=hZ-4eZ=~kZ&l#s5^S7^jdR*US(ZSb+p^I!H^KdHPol@KM zm#z&;m8?f3qY6ZHHgirji+KaRqDrC)_`vJr2gG`NHQ$*?D@n9+nN+F{3L;h)^0V%e z$BCFE?c9Y+msD2Q)c<&OM7&zy{x4V7t&+c2mCxh6 z$!n%7uhGK19x0D`|Am6I+Tl*3hx1y*aD+zpy3;;Q#b<}0yM(aHh}D*@376vAy^+Xr zNyG>xoR!@>p8jZXEtZg|93v7m*A*V#=d^bz#SStHA4JA*{eeiuomZnmJ~IUU$4#tB zrTt^r*C+OPBbMzFOAw0rud3;pJ!ioMEJiD;?u)4$iYlkARKafwL|Z9gc4Vh@{qkEC z&v+w|>-;G#AF1z|85!qFz?Szb+nCm+E^V2lwD>tPTSL$BSwN3S^bn;wEAws3^@>=%g zt!U6bd zj_9$ELi!E%Q9Et@QnDc*AOBsQrR1MdqqA-%FBYySyM{Wx_Qs^ZlgU2r%DtUK+FWd+ zq(8wXk2!5SX>n6se>4LL!yhzXMv{Y@r~i!b68FQDE4)4yAExe~E+SW5JiPnFu{IoC!tB3) zm}2%jZCwm$m2ku$5y|g6D~~6FE(a{%H|3oxm%IsH{$$ z(jTR`sca*6oJx2xYoRwPC7x9J)|tvuozyc}#aBEN=OL1>+Le_kUT>E{Ym^MP2Di=+ zQGE9()Z!*Ic}Dj(%N67Ay%BoclhDyO<`=gI*Qv3PA9)fBMLTV8lRV>xl2yWuxpcUu z=F>m*w|WzNyrF8v8t86@y+6~W^Pcq^aadMoFj^F_UGVYei|(t=LjO8LpK z&8rU_kpl=&07^gwFaQhq0KR}9FbePo0)Wwg8qffb0f9ge5DbI>p+Fd*1;T*{AQFfI zqJbD77Kj7lfdn8CNCJ|9F+d8S1N49aFaoJS8ejs_0W)9$tUv~k31k7;Kn^e#$OZC% ue4qd*1jYeHKrv7PJPwQpCIAzGQeYBL2H1efz!YFAFbyc@4;-03v**95`SscW diff --git a/external/intradesk_api.py b/external/intradesk_api.py index 206a1ee..06b41d9 100644 --- a/external/intradesk_api.py +++ b/external/intradesk_api.py @@ -60,6 +60,88 @@ async def get_task_last_comment(task_id: int) -> dict | None: return None +async def get_client_open_tasks( + client_id: int, + page: int = 0, + page_size: int = 5, +) -> dict: + """ + Получить незакрытые заявки клиента из Intradesk API. + + Возвращает {"tasks": [...], "total": int}. + Каждая задача содержит поля из OData + "last_comment" (dict или None). + """ + key = config.INTRADESK_TASKS_API_KEY + if not key: + return {"tasks": [], "total": 0} + + skip = page * page_size + url = ( + f"{config.INTRADESK_BASE_URL.rstrip('/')}/tasklist/odata/v3/tasks" + f"?ApiKey={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}" + f"&$count=true" + ) + try: + async with httpx.AsyncClient() as client: + resp = await client.get(url, timeout=20.0) + if resp.status_code != 200: + logger.warning("get_client_open_tasks client=%s → HTTP %s", client_id, resp.status_code) + return {"tasks": [], "total": 0} + data = resp.json() + except Exception as exc: + logger.warning("get_client_open_tasks error: %s", exc) + return {"tasks": [], "total": 0} + + 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") + dname = d.get("name") or "" + if isinstance(dname, dict): + dname = dname.get("ru") or dname.get("en") or "" + if did: + dicts_map[did] = dname + + tasks = [] + for t in raw_tasks: + status_id = t.get("status") + last_comment = _extract_last_comment_from_lifetime(t.get("lifetime")) + tasks.append({ + "id": t.get("id"), + "task_number": t.get("tasknumber"), + "name": t.get("name") or "", + "status_name": dicts_map.get(status_id, "") if status_id else "", + "created_at": t.get("createdat") or "", + "updated_at": t.get("updatedat") or "", + "last_comment": last_comment, + }) + + return {"tasks": tasks, "total": total} + + +def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None: + """Извлечь последний комментарий из поля 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[:10] # "2026-03-26" + return { + "text": ev.get("stringvalue") or "", + "author": entry.get("username") or "—", + "date": short_date, + } + return None + + async def create_intradesk_task( name: str, description: str,