перед кешем
This commit is contained in:
parent
7fab711886
commit
6f0e66b4ae
9 changed files with 369 additions and 46 deletions
|
|
@ -44,12 +44,13 @@ async def cleanup_ticket_flow(bot, context: BaseContext) -> None:
|
||||||
Вызывается из любого обработчика, который прерывает этот поток
|
Вызывается из любого обработчика, который прерывает этот поток
|
||||||
(главное меню, список заявок, повторное нажатие «Создать заявку»).
|
(главное меню, список заявок, повторное нажатие «Создать заявку»).
|
||||||
"""
|
"""
|
||||||
from bot.states import TicketStates # avoid circular at module level
|
from bot.states import TicketStates, CommentStates # avoid circular at module level
|
||||||
state = await context.get_state()
|
state = await context.get_state()
|
||||||
creation_states = [
|
creation_states = [
|
||||||
TicketStates.waiting_description,
|
TicketStates.waiting_description,
|
||||||
TicketStates.collecting_attachments,
|
TicketStates.collecting_attachments,
|
||||||
TicketStates.confirming,
|
TicketStates.confirming,
|
||||||
|
CommentStates.waiting_text,
|
||||||
]
|
]
|
||||||
if state not in creation_states:
|
if state not in creation_states:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,79 @@
|
||||||
"""Обработчик главного меню."""
|
"""Обработчик главного меню."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from maxapi.context import BaseContext
|
||||||
from maxapi.dispatcher import Router
|
from maxapi.dispatcher import Router
|
||||||
from maxapi.filters import F
|
from maxapi.filters import F
|
||||||
from maxapi.types.updates.message_callback import MessageCallback
|
from maxapi.types.updates.message_callback import MessageCallback
|
||||||
from maxapi.context import BaseContext
|
|
||||||
|
|
||||||
from bot.keyboards import main_menu_kb
|
from bot.keyboards import main_menu_kb
|
||||||
from bot.send_helper import bot_reply
|
from bot.send_helper import bot_reply
|
||||||
from database.connection import get_db
|
|
||||||
from database.queries.intradesk import (
|
|
||||||
count_active_tasks_for_local_org,
|
|
||||||
count_tasks_for_local_org,
|
|
||||||
)
|
|
||||||
from database.queries.organizations import get_organization
|
from database.queries.organizations import get_organization
|
||||||
|
from external.intradesk_api import count_client_open_tasks, get_client_last_comment
|
||||||
|
|
||||||
router = Router(router_id="menu")
|
router = Router(router_id="menu")
|
||||||
|
|
||||||
|
|
||||||
async def build_main_menu_text(bot_user: dict) -> str:
|
async def build_main_menu_text(bot_user: dict) -> str:
|
||||||
"""Формирует текст главного меню с именем объекта и статистикой заявок."""
|
"""Формирует текст главного меню — данные берутся из Intradesk API."""
|
||||||
org_id = bot_user.get("org_id")
|
org_id = bot_user.get("org_id")
|
||||||
org_name: str | None = None
|
org_name: str | None = None
|
||||||
|
intradesk_id: int | None = None
|
||||||
|
|
||||||
if org_id:
|
if org_id:
|
||||||
org = await get_organization(org_id)
|
org = await get_organization(org_id)
|
||||||
org_name = org["name"] if org else None
|
org_name = org["name"] if org else None
|
||||||
|
intradesk_id = org.get("intradesk_id") if org else None
|
||||||
|
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
if org_name:
|
lines.append(f"🏢 {org_name}" if org_name else "🏠 Поддержка")
|
||||||
lines.append(f"🏢 {org_name}")
|
lines.append("━" * 16)
|
||||||
|
|
||||||
|
if intradesk_id:
|
||||||
|
# Счётчик активных и последний комментарий — параллельно
|
||||||
|
active, last_comment = await asyncio.gather(
|
||||||
|
count_client_open_tasks(intradesk_id),
|
||||||
|
get_client_last_comment(intradesk_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
if active == 0:
|
||||||
|
lines += [
|
||||||
|
"📭 Активных заявок нет",
|
||||||
|
"",
|
||||||
|
"Нажмите «Создать заявку» и мы",
|
||||||
|
"свяжемся с вами в ближайшее время.",
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
lines.append("🏠 Главное меню")
|
lines.append(f"📋 Активных заявок: {active}")
|
||||||
|
|
||||||
if org_id:
|
if last_comment and last_comment.get("text"):
|
||||||
db = get_db()
|
ctext = last_comment["text"]
|
||||||
total = await count_tasks_for_local_org(db, org_id)
|
if len(ctext) > 120:
|
||||||
active = await count_active_tasks_for_local_org(db, org_id)
|
ctext = ctext[:120] + "…"
|
||||||
|
raw_date = last_comment.get("date") or ""
|
||||||
if total == 0:
|
# raw_date приходит как "2026-03-26 11:54"
|
||||||
lines.append("Заявок пока нет.")
|
date_part = raw_date[:10]
|
||||||
|
time_part = raw_date[11:16] if len(raw_date) >= 16 else ""
|
||||||
|
if len(date_part) == 10:
|
||||||
|
y, m, d = date_part.split("-")
|
||||||
|
short_date = f"{d}.{m}" + (f" {time_part}" if time_part else "")
|
||||||
else:
|
else:
|
||||||
lines.append(f"Заявок: {total} (активных: {active})")
|
short_date = raw_date
|
||||||
|
author = (last_comment.get("author") or "").strip()
|
||||||
|
task_num = last_comment.get("task_number")
|
||||||
|
num_str = f" (заявка #{task_num})" if task_num else ""
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"💬 Последний ответ{num_str}:",
|
||||||
|
f"«{ctext}»",
|
||||||
|
f"— {author}, {short_date}",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
lines += ["", "💬 Новых ответов нет"]
|
||||||
|
else:
|
||||||
|
lines.append("📭 Нет данных об организации")
|
||||||
|
|
||||||
lines.append("\nВыберите действие:")
|
lines += ["", "━" * 16, "Выберите действие 👇"]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,13 @@ async def on_bot_started(event: BotStarted, context: BaseContext) -> None:
|
||||||
if bot_user["is_authorized"]:
|
if bot_user["is_authorized"]:
|
||||||
logger.info("✅ bot_started returning user | user_id=%s", user.user_id)
|
logger.info("✅ bot_started returning user | user_id=%s", user.user_id)
|
||||||
await context.set_state(None)
|
await context.set_state(None)
|
||||||
|
from bot.handlers.menu import build_main_menu_text
|
||||||
|
menu_text = "👋 С возвращением!\n\n" + await build_main_menu_text(bot_user)
|
||||||
await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=event.chat_id,
|
chat_id=event.chat_id,
|
||||||
user_id=user.user_id,
|
user_id=user.user_id,
|
||||||
text="👋 С возвращением! Выберите действие:",
|
text=menu_text,
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -141,11 +143,13 @@ async def on_start_command(event: MessageCreated, context: BaseContext) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
await context.set_state(None)
|
await context.set_state(None)
|
||||||
|
from bot.handlers.menu import build_main_menu_text
|
||||||
|
menu_text = await build_main_menu_text(bot_user)
|
||||||
await bot_send(
|
await bot_send(
|
||||||
event.bot,
|
event.bot,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
text="Главное меню:",
|
text=menu_text,
|
||||||
attachments=[main_menu_kb()],
|
attachments=[main_menu_kb()],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,13 @@ from maxapi.filters import F
|
||||||
from maxapi.types.updates.message_callback import MessageCallback
|
from maxapi.types.updates.message_callback import MessageCallback
|
||||||
|
|
||||||
from bot.filters import PayloadStartsWith
|
from bot.filters import PayloadStartsWith
|
||||||
from bot.keyboards import iticket_detail_kb, menu_only_kb
|
from bot.keyboards import iticket_detail_kb, main_menu_kb, menu_only_kb
|
||||||
from bot.send_helper import bot_reply
|
from bot.send_helper import bot_reply, bot_send
|
||||||
|
from bot.states import CommentStates
|
||||||
from database.connection import get_db
|
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 database.queries.organizations import get_organization
|
||||||
from external.intradesk_api import get_client_open_tasks, get_task_last_comment
|
from external.intradesk_api import add_task_comment, get_client_open_tasks, get_task_last_comment
|
||||||
|
|
||||||
router = Router(router_id="view_tickets")
|
router = Router(router_id="view_tickets")
|
||||||
|
|
||||||
|
|
@ -42,19 +44,22 @@ def _status_icon(status_name: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _fmt_date(iso: str) -> str:
|
def _fmt_date(iso: str) -> str:
|
||||||
"""'2026-03-26T09:57:11Z' → '26.03.2026'"""
|
"""'2026-03-26T09:57:11Z' → '26.03.2026 09:57'"""
|
||||||
if not iso or len(iso) < 10:
|
if not iso or len(iso) < 10:
|
||||||
return iso or "—"
|
return iso or "—"
|
||||||
y, m, d = iso[:10].split("-")
|
y, m, d = iso[:10].split("-")
|
||||||
return f"{d}.{m}.{y}"
|
# iso может быть "2026-03-26T09:57:11Z" или "2026-03-26 09:57"
|
||||||
|
time_part = iso[11:16] if len(iso) >= 16 else ""
|
||||||
|
return f"{d}.{m}.{y}" + (f" {time_part}" if time_part else "")
|
||||||
|
|
||||||
|
|
||||||
def _fmt_date_short(iso: str) -> str:
|
def _fmt_date_short(iso: str) -> str:
|
||||||
"""'2026-03-26' → '26.03'"""
|
"""'2026-03-26 11:54' → '26.03 11:54'"""
|
||||||
if not iso or len(iso) < 10:
|
if not iso or len(iso) < 10:
|
||||||
return iso or ""
|
return iso or ""
|
||||||
y, m, d = iso[:10].split("-")
|
y, m, d = iso[:10].split("-")
|
||||||
return f"{d}.{m}"
|
time_part = iso[11:16] if len(iso) >= 16 else ""
|
||||||
|
return f"{d}.{m}" + (f" {time_part}" if time_part else "")
|
||||||
|
|
||||||
|
|
||||||
def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
|
def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
|
||||||
|
|
@ -79,9 +84,11 @@ def _build_tasks_text(tasks: list[dict], page: int, total: int) -> str:
|
||||||
comment = t.get("last_comment")
|
comment = t.get("last_comment")
|
||||||
if comment and comment.get("text"):
|
if comment and comment.get("text"):
|
||||||
ctext = comment["text"][:80] + ("…" if len(comment["text"]) > 80 else "")
|
ctext = comment["text"][:80] + ("…" if len(comment["text"]) > 80 else "")
|
||||||
author_short = (comment.get("author") or "").split()[0] # только фамилия
|
author_short = (comment.get("author") or "—").split()[0]
|
||||||
cdate = _fmt_date_short(comment.get("date") or "")
|
cdate = _fmt_date_short(comment.get("date") or "")
|
||||||
lines.append(f'💬 "{ctext}" — {author_short} ({cdate})')
|
lines.append(f'💬 "{ctext}" — {author_short} ({cdate})')
|
||||||
|
else:
|
||||||
|
lines.append("💬 Нет комментариев")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
@ -247,7 +254,125 @@ async def cb_iticket_detail(
|
||||||
await bot_reply(
|
await bot_reply(
|
||||||
event,
|
event,
|
||||||
text="\n".join(lines),
|
text="\n".join(lines),
|
||||||
attachments=[iticket_detail_kb()],
|
attachments=[iticket_detail_kb(task_id=task["id"])],
|
||||||
user_id=bot_user["max_user_id"],
|
user_id=bot_user["max_user_id"],
|
||||||
chat_id=event.message.recipient.chat_id if event.message else None,
|
chat_id=event.message.recipient.chat_id if event.message else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Оставить комментарий ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.message_callback(PayloadStartsWith("iticket_comment:"))
|
||||||
|
async def cb_add_comment(
|
||||||
|
event: MessageCallback, context: BaseContext, bot_user: dict
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
task_id = int(event.callback.payload.split(":")[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
await event.answer(notification="Ошибка")
|
||||||
|
return
|
||||||
|
|
||||||
|
await event.answer(notification="")
|
||||||
|
|
||||||
|
# Ищем task_number в кэше контекста
|
||||||
|
data = await context.get_data()
|
||||||
|
task = next((t for t in (data.get("tasks") or []) if t.get("id") == task_id), None)
|
||||||
|
task_number = task.get("task_number") if task else None
|
||||||
|
|
||||||
|
await context.set_state(CommentStates.waiting_text)
|
||||||
|
await context.set_data({
|
||||||
|
"comment_task_id": task_id,
|
||||||
|
"comment_task_number": task_number,
|
||||||
|
"flow_message_ids": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
chat_id = event.message.recipient.chat_id if event.message else None
|
||||||
|
if chat_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
num_str = f" к заявке #{task_number}" if task_number else ""
|
||||||
|
mid = await bot_send(
|
||||||
|
event.bot,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=bot_user["max_user_id"],
|
||||||
|
text=(
|
||||||
|
f"💬 Напишите комментарий{num_str}:\n\n"
|
||||||
|
"Для отмены нажмите «🏠 Главное меню»."
|
||||||
|
),
|
||||||
|
attachments=[menu_only_kb()],
|
||||||
|
)
|
||||||
|
# Сохраняем mid для возможной очистки
|
||||||
|
if mid:
|
||||||
|
d = await context.get_data()
|
||||||
|
d["flow_message_ids"] = [mid]
|
||||||
|
await context.set_data(d)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message_created(states=CommentStates.waiting_text)
|
||||||
|
async def on_comment_text(
|
||||||
|
event: MessageCreated, context: BaseContext, bot_user: dict
|
||||||
|
) -> None:
|
||||||
|
chat_id, user_id = event.get_ids()
|
||||||
|
if chat_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
body = event.message.body
|
||||||
|
text = (body.text or "").strip() if body else ""
|
||||||
|
if not text:
|
||||||
|
await bot_send(
|
||||||
|
event.bot,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
text="❗ Комментарий не может быть пустым. Напишите текст:",
|
||||||
|
attachments=[menu_only_kb()],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await context.get_data()
|
||||||
|
task_number = data.get("comment_task_number")
|
||||||
|
|
||||||
|
# Сбрасываем состояние до отправки API — защита от повторов
|
||||||
|
await context.clear()
|
||||||
|
|
||||||
|
# Получаем contact_person_id
|
||||||
|
contact_person_id: int | None = None
|
||||||
|
if bot_user.get("phone") and 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:
|
||||||
|
db = get_db()
|
||||||
|
contact_person_id = await get_intradesk_contact_id_for_phone(
|
||||||
|
db, intradesk_id, bot_user["phone"]
|
||||||
|
)
|
||||||
|
|
||||||
|
ok = await add_task_comment(
|
||||||
|
task_number=task_number,
|
||||||
|
text=text,
|
||||||
|
contact_person_id=contact_person_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot.handlers.menu import build_main_menu_text
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
num_str = f" к заявке #{task_number}" if task_number else ""
|
||||||
|
await bot_send(
|
||||||
|
event.bot,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
text=f"✅ Комментарий{num_str} успешно отправлен!",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await bot_send(
|
||||||
|
event.bot,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
text="⚠️ Не удалось отправить комментарий. Попробуйте позже.",
|
||||||
|
)
|
||||||
|
|
||||||
|
await bot_send(
|
||||||
|
event.bot,
|
||||||
|
chat_id=chat_id,
|
||||||
|
user_id=user_id,
|
||||||
|
text=await build_main_menu_text(bot_user),
|
||||||
|
attachments=[main_menu_kb()],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -118,14 +118,17 @@ def intradesk_tasks_list_kb(
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def iticket_detail_kb() -> Attachment:
|
def iticket_detail_kb(task_id: int | None = None) -> Attachment:
|
||||||
"""Клавиатура внутри карточки Intradesk-задачи."""
|
"""Клавиатура внутри карточки Intradesk-задачи."""
|
||||||
return (
|
builder = InlineKeyboardBuilder()
|
||||||
InlineKeyboardBuilder()
|
if task_id is not None:
|
||||||
.row(CallbackButton(text="← К списку заявок", payload="itickets_page:0"))
|
builder.row(CallbackButton(
|
||||||
.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
text="💬 Оставить комментарий",
|
||||||
.as_markup()
|
payload=f"iticket_comment:{task_id}",
|
||||||
)
|
))
|
||||||
|
builder.row(CallbackButton(text="← К списку заявок", payload="itickets_page:0"))
|
||||||
|
builder.row(CallbackButton(text="🏠 Главное меню", payload="main_menu"))
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def ticket_detail_kb(page: int = 0) -> Attachment:
|
def ticket_detail_kb(page: int = 0) -> Attachment:
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,7 @@ class TicketStates(StatesGroup):
|
||||||
waiting_description = State() # Ожидаем первое сообщение с описанием
|
waiting_description = State() # Ожидаем первое сообщение с описанием
|
||||||
collecting_attachments = State() # Накапливаем доп. файлы/текст
|
collecting_attachments = State() # Накапливаем доп. файлы/текст
|
||||||
confirming = State() # Предпросмотр — ждём подтверждения
|
confirming = State() # Предпросмотр — ждём подтверждения
|
||||||
|
|
||||||
|
|
||||||
|
class CommentStates(StatesGroup):
|
||||||
|
waiting_text = State() # Ожидаем текст комментария к заявке
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
170
external/intradesk_api.py
vendored
170
external/intradesk_api.py
vendored
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -16,6 +17,18 @@ import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_html(text: str) -> str:
|
||||||
|
"""Убрать HTML-теги и лишние пробелы из текста комментария."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
cleaned = _TAG_RE.sub("", text)
|
||||||
|
# Схлопнуть множественные пробелы/переносы
|
||||||
|
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
async def get_task_last_comment(task_id: int) -> dict | None:
|
async def get_task_last_comment(task_id: int) -> dict | None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -53,13 +66,88 @@ async def get_task_last_comment(task_id: int) -> dict | None:
|
||||||
# "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54"
|
# "2021-03-15T11:54:36.92Z" → "2021-03-15 11:54"
|
||||||
short_date = raw_date[:16].replace("T", " ")
|
short_date = raw_date[:16].replace("T", " ")
|
||||||
return {
|
return {
|
||||||
"text": ev.get("stringvalue") or "",
|
"text": _strip_html(ev.get("stringvalue") or ""),
|
||||||
"author": entry.get("username") or "—",
|
"author": entry.get("username") or "—",
|
||||||
"date": short_date,
|
"date": short_date,
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
return 0
|
||||||
|
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"&$top=1&$count=true"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url, timeout=10.0)
|
||||||
|
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 [])
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_client_last_comment(client_id: int) -> dict | None:
|
||||||
|
"""
|
||||||
|
Найти последний комментарий по любой незакрытой заявке клиента.
|
||||||
|
|
||||||
|
Берём задачу с самым свежим updatedat → ищем последний комментарий в её истории.
|
||||||
|
Возвращает {text, author, date, task_number} или None.
|
||||||
|
"""
|
||||||
|
key = config.INTRADESK_TASKS_API_KEY
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Шаг 1: самая свежая задача клиента
|
||||||
|
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&$top=1"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url, timeout=15.0)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tasks = data.get("value") or []
|
||||||
|
if not tasks:
|
||||||
|
return None
|
||||||
|
|
||||||
|
task = tasks[0]
|
||||||
|
task_id = task.get("id")
|
||||||
|
task_number = task.get("tasknumber")
|
||||||
|
if not task_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Шаг 2: последний комментарий этой задачи
|
||||||
|
comment = await get_task_last_comment(task_id)
|
||||||
|
if comment:
|
||||||
|
comment["task_number"] = task_number
|
||||||
|
return comment
|
||||||
|
|
||||||
|
|
||||||
async def get_client_open_tasks(
|
async def get_client_open_tasks(
|
||||||
client_id: int,
|
client_id: int,
|
||||||
page: int = 0,
|
page: int = 0,
|
||||||
|
|
@ -69,8 +157,10 @@ async def get_client_open_tasks(
|
||||||
Получить незакрытые заявки клиента из Intradesk API.
|
Получить незакрытые заявки клиента из Intradesk API.
|
||||||
|
|
||||||
Возвращает {"tasks": [...], "total": int}.
|
Возвращает {"tasks": [...], "total": int}.
|
||||||
Каждая задача содержит поля из OData + "last_comment" (dict или None).
|
Последний комментарий загружается параллельно через history API для каждой задачи.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
key = config.INTRADESK_TASKS_API_KEY
|
key = config.INTRADESK_TASKS_API_KEY
|
||||||
if not key:
|
if not key:
|
||||||
return {"tasks": [], "total": 0}
|
return {"tasks": [], "total": 0}
|
||||||
|
|
@ -108,20 +198,31 @@ async def get_client_open_tasks(
|
||||||
if did:
|
if did:
|
||||||
dicts_map[did] = dname
|
dicts_map[did] = dname
|
||||||
|
|
||||||
tasks = []
|
# Базовые данные задач
|
||||||
|
base_tasks = []
|
||||||
for t in raw_tasks:
|
for t in raw_tasks:
|
||||||
status_id = t.get("status")
|
status_id = t.get("status")
|
||||||
last_comment = _extract_last_comment_from_lifetime(t.get("lifetime"))
|
base_tasks.append({
|
||||||
tasks.append({
|
|
||||||
"id": t.get("id"),
|
"id": t.get("id"),
|
||||||
"task_number": t.get("tasknumber"),
|
"task_number": t.get("tasknumber"),
|
||||||
"name": t.get("name") or "",
|
"name": t.get("name") or "",
|
||||||
"status_name": dicts_map.get(status_id, "") if status_id else "",
|
"status_name": dicts_map.get(status_id, "") if status_id else "",
|
||||||
"created_at": t.get("createdat") or "",
|
"created_at": t.get("createdat") or "",
|
||||||
"updated_at": t.get("updatedat") or "",
|
"updated_at": t.get("updatedat") or "",
|
||||||
"last_comment": last_comment,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Параллельно загружаем последний комментарий для каждой задачи
|
||||||
|
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],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for task, comment in zip(base_tasks, comments):
|
||||||
|
task["last_comment"] = comment if isinstance(comment, dict) else None
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
return {"tasks": tasks, "total": total}
|
return {"tasks": tasks, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -133,15 +234,68 @@ def _extract_last_comment_from_lifetime(lifetime: dict | None) -> dict | None:
|
||||||
for ev in (entry.get("events") or {}).get("data") or []:
|
for ev in (entry.get("events") or {}).get("data") or []:
|
||||||
if ev.get("blockname") == "comment" and ev.get("type") == 50:
|
if ev.get("blockname") == "comment" and ev.get("type") == 50:
|
||||||
raw_date = entry.get("eventat") or ""
|
raw_date = entry.get("eventat") or ""
|
||||||
short_date = raw_date[:10] # "2026-03-26"
|
short_date = raw_date[:16].replace("T", " ") # "2026-03-26 11:54"
|
||||||
return {
|
return {
|
||||||
"text": ev.get("stringvalue") or "",
|
"text": _strip_html(ev.get("stringvalue") or ""),
|
||||||
"author": entry.get("username") or "—",
|
"author": entry.get("username") or "—",
|
||||||
"date": short_date,
|
"date": short_date,
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def add_task_comment(
|
||||||
|
task_number: int,
|
||||||
|
text: str,
|
||||||
|
contact_person_id: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Добавить комментарий к заявке через Intradesk API (PUT /changes/v3/tasks).
|
||||||
|
|
||||||
|
Возвращает True при успехе, False при ошибке.
|
||||||
|
"""
|
||||||
|
key = config.INTRADESK_CHANGES_API_KEY
|
||||||
|
if not key:
|
||||||
|
logger.warning("add_task_comment: INTRADESK_CHANGES_API_KEY не задан")
|
||||||
|
return False
|
||||||
|
|
||||||
|
blocks: dict[str, str] = {
|
||||||
|
"comment": json.dumps({"value": text}, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
body = {"number": task_number, "blocks": blocks, "Channel": "api"}
|
||||||
|
url = f"{config.INTRADESK_BASE_URL.rstrip('/')}/changes/v3/tasks?ApiKey={key}"
|
||||||
|
if contact_person_id is not None:
|
||||||
|
url += f"&userid={contact_person_id}"
|
||||||
|
|
||||||
|
if config.DRY_RUN:
|
||||||
|
print(
|
||||||
|
"\n[DRY-RUN] add_task_comment — запрос НЕ отправлен\n"
|
||||||
|
f" PUT {url}\n"
|
||||||
|
f" Body: {json.dumps(body, ensure_ascii=False, indent=2)}\n"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.put(
|
||||||
|
url,
|
||||||
|
json=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=20.0,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
logger.error(
|
||||||
|
"add_task_comment → HTTP %s: %s",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:500],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
logger.info("add_task_comment ok: task_number=%s", task_number)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("add_task_comment error: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def create_intradesk_task(
|
async def create_intradesk_task(
|
||||||
name: str,
|
name: str,
|
||||||
description: str,
|
description: str,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue