init
This commit is contained in:
commit
fe02b5139a
20 changed files with 2486 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
TELEGRAM_TOKEN=7586393409:AAFBhpdkjWU8-Fn0B6vv-VLR5kUQ6iXKXss
|
||||
INTRADESK_API_KEY=26221B839B6643E68345FD26BF
|
||||
CHANNEL_ID=-1003708972637
|
||||
BASE_TASK_URL=https://cleverparking.intradesk.ru/task/list/
|
||||
WEBHOOK_SECRET=capsspac
|
||||
PORT=8080
|
||||
DB_FILE=/app/data/intradbot.db
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "intradbot.py"]
|
||||
0
bot/__init__.py
Normal file
0
bot/__init__.py
Normal file
186
bot/telegram.py
Normal file
186
bot/telegram.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import logging
|
||||
|
||||
import telebot
|
||||
from telebot import types
|
||||
from telebot.apihelper import ApiTelegramException
|
||||
|
||||
import config
|
||||
import database
|
||||
from intradesk import reference
|
||||
from intradesk import api as intradesk_api
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
bot = telebot.TeleBot(config.TOKEN, parse_mode="Markdown")
|
||||
|
||||
|
||||
# ── Кнопки под постом ─────────────────────────────────────────────────────────
|
||||
|
||||
def make_task_buttons(task_number: str, closed: bool = False) -> types.InlineKeyboardMarkup:
|
||||
markup = types.InlineKeyboardMarkup(row_width=2)
|
||||
if not closed:
|
||||
markup.add(
|
||||
types.InlineKeyboardButton("📋 Статус", callback_data=f"st:{task_number}"),
|
||||
types.InlineKeyboardButton("👤 Исполнитель", callback_data=f"ex:{task_number}"),
|
||||
)
|
||||
return markup
|
||||
|
||||
|
||||
# ── Отправка / редактирование поста ───────────────────────────────────────────
|
||||
|
||||
def send_or_edit(
|
||||
task_id: int,
|
||||
task_number: str,
|
||||
full_text: str,
|
||||
update_summary: str = None,
|
||||
closed: bool = False,
|
||||
):
|
||||
markup = make_task_buttons(task_number, closed=closed)
|
||||
msg_id = database.get_message_id(task_id)
|
||||
|
||||
if msg_id:
|
||||
try:
|
||||
bot.edit_message_text(
|
||||
full_text,
|
||||
config.CHANNEL_ID,
|
||||
msg_id,
|
||||
parse_mode="Markdown",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=markup if not closed else None,
|
||||
)
|
||||
except ApiTelegramException as e:
|
||||
if "message is not modified" not in str(e):
|
||||
log.warning(f"edit_message failed for task {task_id}: {e}")
|
||||
|
||||
if update_summary:
|
||||
try:
|
||||
bot.send_message(
|
||||
config.CHANNEL_ID,
|
||||
update_summary,
|
||||
reply_to_message_id=msg_id,
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
except ApiTelegramException as e:
|
||||
log.warning(f"send reply failed for task {task_id}: {e}")
|
||||
else:
|
||||
try:
|
||||
msg = bot.send_message(
|
||||
config.CHANNEL_ID,
|
||||
full_text,
|
||||
parse_mode="Markdown",
|
||||
disable_web_page_preview=True,
|
||||
reply_markup=markup,
|
||||
)
|
||||
database.save_message_id(task_id, msg.message_id, task_number)
|
||||
except ApiTelegramException as e:
|
||||
log.error(f"send_message failed for task {task_id}: {e}")
|
||||
|
||||
|
||||
# ── Callbacks: смена статуса ──────────────────────────────────────────────────
|
||||
|
||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("st:"))
|
||||
def cb_show_statuses(call):
|
||||
"""Показывает список статусов для выбора."""
|
||||
task_number = call.data[3:]
|
||||
markup = types.InlineKeyboardMarkup(row_width=1)
|
||||
for sid, name in reference.STATUSES.items():
|
||||
markup.add(types.InlineKeyboardButton(name, callback_data=f"ss:{task_number}:{sid}"))
|
||||
markup.add(types.InlineKeyboardButton("✖ Отмена", callback_data="del"))
|
||||
|
||||
try:
|
||||
bot.send_message(
|
||||
call.message.chat.id,
|
||||
f"Заявка *\\#{task_number}* — выберите новый статус:",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=markup,
|
||||
reply_to_message_id=call.message.message_id,
|
||||
)
|
||||
bot.answer_callback_query(call.id)
|
||||
except Exception as e:
|
||||
bot.answer_callback_query(call.id, str(e), show_alert=True)
|
||||
|
||||
|
||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("ss:"))
|
||||
def cb_set_status(call):
|
||||
"""Применяет выбранный статус через API."""
|
||||
_, task_number, status_id = call.data.split(":")
|
||||
status_name = reference.STATUSES.get(int(status_id), f"#{status_id}")
|
||||
|
||||
success = intradesk_api.change_status(int(task_number), int(status_id))
|
||||
if success:
|
||||
bot.answer_callback_query(call.id, f"✅ Статус → {status_name}")
|
||||
_delete_message(call.message.chat.id, call.message.message_id)
|
||||
else:
|
||||
bot.answer_callback_query(call.id, "❌ Ошибка при изменении статуса", show_alert=True)
|
||||
|
||||
|
||||
# ── Callbacks: назначение исполнителя ─────────────────────────────────────────
|
||||
|
||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("ex:"))
|
||||
def cb_show_executors(call):
|
||||
"""Показывает список сотрудников для назначения исполнителем."""
|
||||
task_number = call.data[3:]
|
||||
employees = database.get_all_ref("employees")
|
||||
|
||||
if not employees:
|
||||
bot.answer_callback_query(
|
||||
call.id,
|
||||
"Список сотрудников не загружен. Обновите справочники.",
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
markup = types.InlineKeyboardMarkup(row_width=1)
|
||||
for emp in employees[:20]: # Telegram ограничивает кол-во кнопок
|
||||
markup.add(types.InlineKeyboardButton(
|
||||
emp["name"],
|
||||
callback_data=f"se:{task_number}:{emp['id']}",
|
||||
))
|
||||
markup.add(types.InlineKeyboardButton("✖ Отмена", callback_data="del"))
|
||||
|
||||
try:
|
||||
bot.send_message(
|
||||
call.message.chat.id,
|
||||
f"Заявка *\\#{task_number}* — выберите исполнителя:",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=markup,
|
||||
reply_to_message_id=call.message.message_id,
|
||||
)
|
||||
bot.answer_callback_query(call.id)
|
||||
except Exception as e:
|
||||
bot.answer_callback_query(call.id, str(e), show_alert=True)
|
||||
|
||||
|
||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("se:"))
|
||||
def cb_set_executor(call):
|
||||
"""Назначает выбранного сотрудника исполнителем через API."""
|
||||
_, task_number, emp_id = call.data.split(":")
|
||||
emp_name = database.resolve("employees", int(emp_id)) or f"#{emp_id}"
|
||||
|
||||
success = intradesk_api.assign_executor(int(task_number), user_id=int(emp_id))
|
||||
if success:
|
||||
bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}")
|
||||
_delete_message(call.message.chat.id, call.message.message_id)
|
||||
else:
|
||||
bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True)
|
||||
|
||||
|
||||
# ── Общий callback: удалить меню ──────────────────────────────────────────────
|
||||
|
||||
@bot.callback_query_handler(func=lambda c: c.data == "del")
|
||||
def cb_delete(call):
|
||||
bot.answer_callback_query(call.id)
|
||||
_delete_message(call.message.chat.id, call.message.message_id)
|
||||
|
||||
|
||||
def _delete_message(chat_id, message_id):
|
||||
try:
|
||||
bot.delete_message(chat_id, message_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Запуск бота ───────────────────────────────────────────────────────────────
|
||||
|
||||
def start_bot():
|
||||
log.info("Telegram bot started (polling)...")
|
||||
bot.infinity_polling()
|
||||
9
config.py
Normal file
9
config.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import os
|
||||
|
||||
TOKEN = os.environ.get("TELEGRAM_TOKEN", "")
|
||||
API_KEY = os.environ.get("INTRADESK_API_KEY", "")
|
||||
BASE_TASK_URL = os.environ.get("BASE_TASK_URL", "https://cleverparking.intradesk.ru/task/list/")
|
||||
CHANNEL_ID = int(os.environ.get("CHANNEL_ID", "0"))
|
||||
DB_FILE = os.environ.get("DB_FILE", "/app/data/intradbot.db")
|
||||
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "")
|
||||
PORT = int(os.environ.get("PORT", "8080"))
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
84
database.py
Normal file
84
database.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import sqlite3
|
||||
import config
|
||||
|
||||
|
||||
def _connect():
|
||||
conn = sqlite3.connect(config.DB_FILE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
with _connect() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
task_id INTEGER PRIMARY KEY,
|
||||
message_id INTEGER NOT NULL,
|
||||
task_number TEXT
|
||||
)
|
||||
""")
|
||||
# Универсальная таблица справочников: entity = 'status'|'priority'|'service'|...
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ref_data (
|
||||
entity TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (entity, id)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_message_id(task_id: int):
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT message_id FROM messages WHERE task_id = ?", (task_id,)
|
||||
).fetchone()
|
||||
return row["message_id"] if row else None
|
||||
|
||||
|
||||
def save_message_id(task_id: int, message_id: int, task_number: str = None):
|
||||
with _connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO messages (task_id, message_id, task_number) VALUES (?, ?, ?)",
|
||||
(task_id, message_id, task_number),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── ref_data ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def save_ref_data(entity: str, items: list[tuple[int, str]]):
|
||||
"""Перезаписывает весь справочник entity списком пар (id, name)."""
|
||||
with _connect() as conn:
|
||||
conn.execute("DELETE FROM ref_data WHERE entity = ?", (entity,))
|
||||
conn.executemany(
|
||||
"INSERT INTO ref_data (entity, id, name) VALUES (?, ?, ?)",
|
||||
[(entity, item_id, name) for item_id, name in items],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def resolve(entity: str, item_id: int | None) -> str | None:
|
||||
"""Возвращает name по entity + id. None если не найдено."""
|
||||
if item_id is None:
|
||||
return None
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM ref_data WHERE entity = ? AND id = ?",
|
||||
(entity, item_id),
|
||||
).fetchone()
|
||||
return row["name"] if row else None
|
||||
|
||||
|
||||
def get_all_ref(entity: str) -> list[dict]:
|
||||
"""Возвращает все записи справочника как список {id, name}."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM ref_data WHERE entity = ? ORDER BY name",
|
||||
(entity,),
|
||||
).fetchall()
|
||||
return [{"id": r["id"], "name": r["name"]} for r in rows]
|
||||
|
||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
services:
|
||||
intradbot:
|
||||
build: .
|
||||
restart: always
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/app/data # БД и логи хранятся вне контейнера
|
||||
50
events.py
Normal file
50
events.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""
|
||||
Шина SSE-событий.
|
||||
Позволяет webhook_handler.py публиковать события,
|
||||
а webhook_server.py — раздавать их браузеру через /stream.
|
||||
"""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from collections import deque
|
||||
|
||||
_history: deque = deque(maxlen=100)
|
||||
_clients: list[queue.Queue] = []
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def broadcast(data: dict):
|
||||
"""Публикует событие всем подключённым SSE-клиентам и в историю."""
|
||||
_history.append(data)
|
||||
with _lock:
|
||||
for q in list(_clients):
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def subscribe() -> queue.Queue:
|
||||
"""Подписывает нового SSE-клиента. Возвращает очередь с историей."""
|
||||
q: queue.Queue = queue.Queue(maxsize=200)
|
||||
with _lock:
|
||||
_clients.append(q)
|
||||
for item in list(_history):
|
||||
try:
|
||||
q.put_nowait(item)
|
||||
except queue.Full:
|
||||
break
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe(q: queue.Queue):
|
||||
with _lock:
|
||||
try:
|
||||
_clients.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def get_history() -> list:
|
||||
return list(_history)
|
||||
46
intradbot.py
Normal file
46
intradbot.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import schedule
|
||||
|
||||
import config
|
||||
import database
|
||||
from intradesk import reference
|
||||
from bot.telegram import start_bot
|
||||
from web.server import start_server
|
||||
|
||||
os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.FileHandler(os.path.join(os.path.dirname(config.DB_FILE), "bot.log")),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
log.info("Starting IntradBot...")
|
||||
|
||||
database.init_db()
|
||||
reference.seed_static()
|
||||
reference.load_all()
|
||||
|
||||
threading.Thread(target=start_bot, daemon=True).start()
|
||||
threading.Thread(target=start_server, daemon=True).start()
|
||||
|
||||
schedule.every().monday.at("10:00").do(reference.load_all)
|
||||
|
||||
log.info("IntradBot is running. Waiting for webhooks...")
|
||||
while True:
|
||||
schedule.run_pending()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
intradesk/__init__.py
Normal file
0
intradesk/__init__.py
Normal file
262
intradesk/api.py
Normal file
262
intradesk/api.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"""
|
||||
Клиент Intradesk API.
|
||||
|
||||
Базовые URL:
|
||||
GET задачи: https://apigw.intradesk.ru/tasklist/odata/v3/tasks
|
||||
PUT задачи: https://apigw.intradesk.ru/changes/v3/tasks/
|
||||
GET история: https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime/{task_id}/full
|
||||
|
||||
Аутентификация: ?ApiKey=<ключ> в строке запроса.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
import config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TASKS_URL = "https://apigw.intradesk.ru/tasklist/odata/v3/tasks"
|
||||
_CHANGES_URL = "https://apigw.intradesk.ru/changes/v3/tasks/"
|
||||
_HISTORY_URL = "https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime"
|
||||
|
||||
|
||||
# ── вспомогательные ────────────────────────────────────────────────────────────
|
||||
|
||||
def _get(url: str, params: dict = None, timeout: int = 30) -> dict | None:
|
||||
params = {**(params or {}), "ApiKey": config.API_KEY}
|
||||
try:
|
||||
r = requests.get(url, params=params, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.error(f"GET {url} failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _put(url: str, body: dict, timeout: int = 30) -> bool:
|
||||
params = {"ApiKey": config.API_KEY}
|
||||
try:
|
||||
r = requests.put(url, json=body, params=params, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.error(f"PUT {url} failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _jv(value: Any) -> str:
|
||||
"""Сериализует значение в строку-JSON (формат, которого ожидает API изменений)."""
|
||||
return json.dumps({"value": value}, ensure_ascii=False)
|
||||
|
||||
|
||||
# ── задачи ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_task(task_number: int) -> dict | None:
|
||||
"""
|
||||
Возвращает полный объект задачи по её номеру или None при ошибке.
|
||||
|
||||
GET /tasklist/odata/v3/tasks?$filter=tasknumber eq {number}
|
||||
"""
|
||||
data = _get(_TASKS_URL, {"$filter": f"tasknumber eq {task_number}"})
|
||||
if not data:
|
||||
return None
|
||||
items = data.get("value", [])
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def get_tasks(filter_expr: str = "", top: int = 150, order_by: str = "updatedat desc") -> list[dict]:
|
||||
"""
|
||||
Возвращает список задач с поддержкой пагинации.
|
||||
|
||||
Примеры filter_expr:
|
||||
"(status ne 68045) and (executorgroup eq 243025)"
|
||||
"tasknumber eq 31"
|
||||
"""
|
||||
params: dict | None = {"$filter": filter_expr, "$top": top, "$orderby": order_by}
|
||||
url: str | None = _TASKS_URL
|
||||
all_tasks: list[dict] = []
|
||||
|
||||
while url:
|
||||
data = _get(url, params)
|
||||
if not data:
|
||||
break
|
||||
all_tasks.extend(data.get("value", []))
|
||||
url = data.get("@odata.nextLink")
|
||||
params = None # nextLink уже содержит все параметры
|
||||
|
||||
return all_tasks
|
||||
|
||||
|
||||
# ── история (lifetime) ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_task_lifetime(
|
||||
task_id: int,
|
||||
events: list[int] | None = None,
|
||||
top: int = 50,
|
||||
sort_desc: bool = True,
|
||||
date_start: datetime | None = None,
|
||||
date_end: datetime | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Возвращает историю событий задачи.
|
||||
|
||||
GET /taskhistory/api/v2.0/lifetime/{task_id}/full
|
||||
|
||||
Параметры:
|
||||
events — список кодов событий для фильтрации (None = все)
|
||||
top — максимум записей
|
||||
sort_desc — по убыванию даты
|
||||
date_start — фильтр «с» (UTC datetime)
|
||||
date_end — фильтр «по» (UTC datetime)
|
||||
|
||||
Коды событий: см. reference.EVENT_TYPES
|
||||
"""
|
||||
url = f"{_HISTORY_URL}/{task_id}/full"
|
||||
params: dict = {
|
||||
"sortDirection": "Desc" if sort_desc else "Asc",
|
||||
"top": top,
|
||||
}
|
||||
|
||||
if date_start:
|
||||
params["dateStart"] = date_start.strftime("%Y-%m-%dT%H:%M:%S.0000000Z")
|
||||
if date_end:
|
||||
params["dateEnd"] = date_end.strftime("%Y-%m-%dT%H:%M:%S.0000000Z")
|
||||
|
||||
# events передаются как повторяющийся параметр: &events=10&events=50&events=30
|
||||
base_params = {**params, "ApiKey": config.API_KEY}
|
||||
try:
|
||||
r = requests.get(
|
||||
url,
|
||||
params=base_params if not events else {**base_params},
|
||||
timeout=30,
|
||||
)
|
||||
if events:
|
||||
# requests не поддерживает дублирующиеся ключи напрямую — строим вручную
|
||||
from urllib.parse import urlencode
|
||||
qs = urlencode({**base_params}) + "".join(f"&events={e}" for e in events)
|
||||
r = requests.get(f"{url}?{qs}", timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json().get("data", [])
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.error(f"GET lifetime/{task_id} failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ── изменение задачи ───────────────────────────────────────────────────────────
|
||||
|
||||
def update_task(task_number: int, blocks: dict, channel: str = "api") -> bool:
|
||||
"""
|
||||
Изменяет поля задачи.
|
||||
|
||||
PUT /changes/v3/tasks/
|
||||
|
||||
blocks — словарь {поле: уже_сериализованное_значение_json_строкой}.
|
||||
Для удобства используй вспомогательные функции ниже.
|
||||
|
||||
Пример:
|
||||
update_task(272, {
|
||||
"status": '{"value": 68051}',
|
||||
"comment": '{"value":"Принято в работу"}',
|
||||
})
|
||||
"""
|
||||
body = {"number": task_number, "blocks": blocks, "Channel": channel}
|
||||
return _put(_CHANGES_URL, body)
|
||||
|
||||
|
||||
# ── удобные обёртки ────────────────────────────────────────────────────────────
|
||||
|
||||
def change_status(task_number: int, status_id: int) -> bool:
|
||||
"""Меняет статус задачи. status_id из reference.STATUSES."""
|
||||
return update_task(task_number, {"status": _jv(status_id)})
|
||||
|
||||
|
||||
def add_comment(task_number: int, text: str) -> bool:
|
||||
"""Добавляет публичный комментарий к задаче."""
|
||||
return update_task(task_number, {"comment": _jv(text)})
|
||||
|
||||
|
||||
def add_private_comment(task_number: int, text: str) -> bool:
|
||||
"""Добавляет приватный (скрытый) комментарий к задаче."""
|
||||
return update_task(task_number, {"privatecomment": _jv(text)})
|
||||
|
||||
|
||||
def assign_executor(
|
||||
task_number: int,
|
||||
user_id: int | None = None,
|
||||
group_id: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Назначает исполнителя и/или группу исполнителей.
|
||||
|
||||
assign_executor(272, user_id=109747) # только сотрудник
|
||||
assign_executor(272, group_id=243025) # только группа
|
||||
assign_executor(272, group_id=243025, user_id=109747) # группа + сотрудник
|
||||
"""
|
||||
value: dict = {}
|
||||
if user_id:
|
||||
value["userid"] = user_id
|
||||
if group_id:
|
||||
value["groupid"] = group_id
|
||||
if not value:
|
||||
log.warning("assign_executor: нужно указать user_id или group_id")
|
||||
return False
|
||||
return update_task(task_number, {"executor": _jv(value)})
|
||||
|
||||
|
||||
def set_resolution_date(task_number: int, dt: datetime) -> bool:
|
||||
"""
|
||||
Устанавливает плановый срок выполнения.
|
||||
dt должен быть UTC-datetime.
|
||||
|
||||
Пример: set_resolution_date(272, datetime(2025, 3, 15, 15, 0, tzinfo=timezone.utc))
|
||||
"""
|
||||
iso = dt.strftime("%Y-%m-%dT%H:%M:%S.0000000Z")
|
||||
return update_task(task_number, {"resolutiondateplan": _jv(iso)})
|
||||
|
||||
|
||||
def set_service(task_number: int, service_path: str) -> bool:
|
||||
"""
|
||||
Устанавливает сервис задачи.
|
||||
service_path — путь сервиса вида "84681/84683/" (из карточки сервиса).
|
||||
"""
|
||||
return update_task(task_number, {"service": _jv(service_path)})
|
||||
|
||||
|
||||
def set_task_type(task_number: int, tasktype_id: int) -> bool:
|
||||
"""Устанавливает тип задачи."""
|
||||
return update_task(task_number, {"tasktype": _jv(tasktype_id)})
|
||||
|
||||
|
||||
def set_tags(task_number: int, tag_ids: list[int]) -> bool:
|
||||
"""Устанавливает теги задачи (заменяет все существующие)."""
|
||||
return update_task(task_number, {"tags": _jv(tag_ids)})
|
||||
|
||||
|
||||
def set_priority(task_number: int, priority_id: int) -> bool:
|
||||
"""Устанавливает приоритет задачи. priority_id из reference.PRIORITIES."""
|
||||
return update_task(task_number, {"priority": _jv(priority_id)})
|
||||
|
||||
|
||||
def set_initiator(
|
||||
task_number: int,
|
||||
user_id: int | None = None,
|
||||
group_id: int | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Устанавливает инициатора (заявителя).
|
||||
|
||||
set_initiator(272, user_id=109747) # сотрудник
|
||||
set_initiator(272, group_id=109747) # клиент
|
||||
set_initiator(272, group_id=109747, user_id=31974) # клиент + пользователь
|
||||
"""
|
||||
value: dict = {}
|
||||
if user_id:
|
||||
value["userid"] = user_id
|
||||
if group_id:
|
||||
value["groupid"] = group_id
|
||||
return update_task(task_number, {"initiator": _jv(value)})
|
||||
275
intradesk/formatter.py
Normal file
275
intradesk/formatter.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
import config
|
||||
import database
|
||||
from intradesk import reference
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Маппинг blockname → entity в БД справочников
|
||||
BLOCK_TO_ENTITY: dict[str, str] = {
|
||||
"status": "statuses",
|
||||
"priority": "priorities",
|
||||
"executorgroup": "groups",
|
||||
"executor": "employees",
|
||||
"service": "services",
|
||||
"tasktype": "tasktypes",
|
||||
"initiator": "employees",
|
||||
"workflow": "workflows",
|
||||
"sla": "sla",
|
||||
}
|
||||
|
||||
# Человекочитаемые названия полей
|
||||
FIELD_LABELS = {
|
||||
"status": "Статус",
|
||||
"priority": "Приоритет",
|
||||
"executor": "Исполнитель",
|
||||
"executorgroup": "Группа",
|
||||
"service": "Услуга",
|
||||
"tasktype": "Тип заявки",
|
||||
"initiator": "Инициатор",
|
||||
"resolutiondateplan": "Плановый срок",
|
||||
"closedat": "Закрыта",
|
||||
"description": "Описание",
|
||||
"name": "Название",
|
||||
"workflow": "Бизнес-процесс",
|
||||
"sla": "SLA",
|
||||
"tags": "Теги",
|
||||
}
|
||||
|
||||
# Поля, изменение которых не выводится в уведомлении
|
||||
IGNORED_FIELDS = {
|
||||
"lifetime", "updatedat", "updatedby", "updatedchannel",
|
||||
"reactiondatefact", "resolutiondatefact", "resolutionleftminutes",
|
||||
"reactiondateplan", "ismassincidentactive",
|
||||
}
|
||||
|
||||
|
||||
def _parse_json_field(raw):
|
||||
"""Парсит JSON-строку или возвращает как есть."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (dict, list)):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return raw
|
||||
return raw
|
||||
|
||||
|
||||
def _scalar(raw):
|
||||
"""Возвращает скалярное значение поля Fields (Value или Id)."""
|
||||
parsed = _parse_json_field(raw)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed.get("Value") or parsed.get("Id")
|
||||
return parsed
|
||||
|
||||
|
||||
def _extract_name(v: dict | None, blockname: str = "") -> str | None:
|
||||
"""
|
||||
Достаёт человекочитаемое имя из объекта valuenew/valueold.
|
||||
|
||||
Обрабатывает три варианта поля name:
|
||||
1. строка: {"id": 8452, "name": "Иванов"}
|
||||
2. объект {ru, en}: {"id": 96, "name": {"ru": "Выполнена", "en": null}}
|
||||
3. отсутствует/null: {"id": 68045, "name": null} → резольвинг через БД
|
||||
"""
|
||||
if not v:
|
||||
return None
|
||||
|
||||
raw_name = v.get("currentname") or v.get("name")
|
||||
|
||||
# name — объект {"ru": ..., "en": ...}
|
||||
if isinstance(raw_name, dict):
|
||||
raw_name = raw_name.get("ru") or raw_name.get("en") or None
|
||||
|
||||
if raw_name:
|
||||
return str(raw_name).strip() or None
|
||||
|
||||
# name отсутствует или null — резольвинг через БД/статик
|
||||
item_id = v.get("id")
|
||||
entity = BLOCK_TO_ENTITY.get(blockname)
|
||||
if item_id and entity:
|
||||
return reference.resolve(entity, item_id) or str(item_id)
|
||||
|
||||
return str(item_id) if item_id else None
|
||||
|
||||
|
||||
def get_executor_group(fields: dict) -> str | None:
|
||||
"""Группа исполнителей (Type=10 в taskusergroups)."""
|
||||
raw = _parse_json_field(fields.get("taskusergroups", {}))
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
for group in raw.get("TaskUserGroups", []):
|
||||
if group.get("Type") == 10:
|
||||
return group.get("Name", "").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def get_initiator(fields: dict) -> str | None:
|
||||
"""Инициатор заявки (Type=10 в taskusers)."""
|
||||
raw = _parse_json_field(fields.get("taskusers", {}))
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
for user in raw.get("TaskUsers", []):
|
||||
if user.get("Type") == 10:
|
||||
return user.get("Name", "").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def is_final(fields: dict) -> bool:
|
||||
"""Возвращает True если статус задачи финальный (закрыта)."""
|
||||
raw = _parse_json_field(fields.get("status", {}))
|
||||
if isinstance(raw, dict):
|
||||
return bool(raw.get("IsFinal", False))
|
||||
return False
|
||||
|
||||
|
||||
def parse_lifetime(fields: dict) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Разбирает lifetime-поле вебхука.
|
||||
|
||||
Возвращает:
|
||||
changes — строки вида «• Поле: было → стало»
|
||||
comments — строки вида «*Автор*: текст»
|
||||
"""
|
||||
changes: list[str] = []
|
||||
comments: list[str] = []
|
||||
|
||||
lifetime = _parse_json_field(fields.get("lifetime", "{}"))
|
||||
if not isinstance(lifetime, dict):
|
||||
return changes, comments
|
||||
|
||||
for entry in lifetime.get("Data", []):
|
||||
username = entry.get("username", "Неизвестный")
|
||||
|
||||
for event in entry.get("events", {}).get("Data", []):
|
||||
etype = event.get("type")
|
||||
blockname = event.get("blockname", "")
|
||||
|
||||
# ── Комментарии ────────────────────────────────────────────────
|
||||
if etype == 50:
|
||||
text = event.get("stringvalue")
|
||||
if text:
|
||||
comments.append(f"*{username}*: {text}")
|
||||
continue
|
||||
|
||||
# Приватный комментарий (необязательно показывать, но пусть будет)
|
||||
if etype == 55:
|
||||
text = event.get("stringvalue")
|
||||
if text:
|
||||
comments.append(f"*{username}* 🔒: {text}")
|
||||
continue
|
||||
|
||||
if blockname in IGNORED_FIELDS:
|
||||
continue
|
||||
|
||||
label = FIELD_LABELS.get(blockname, reference.event_name(etype))
|
||||
|
||||
# ── Изменения с entityvalue / entitylanguagevalue ──────────────
|
||||
entity_data = event.get("entityvalue") or event.get("entitylanguagevalue")
|
||||
if entity_data:
|
||||
val_new = entity_data.get("valuenew") or {}
|
||||
val_old = entity_data.get("valueold")
|
||||
|
||||
new_str = _extract_name(val_new, blockname)
|
||||
old_str = _extract_name(val_old, blockname) if val_old else None
|
||||
|
||||
if not new_str:
|
||||
continue
|
||||
|
||||
if old_str:
|
||||
changes.append(f"• {label}: {old_str} → *{new_str}*")
|
||||
else:
|
||||
changes.append(f"• {label}: *{new_str}*")
|
||||
continue
|
||||
|
||||
# ── Строковые изменения (название, описание) ───────────────────
|
||||
str_val = event.get("stringvalue")
|
||||
if str_val:
|
||||
changes.append(f"• {label}: *{str_val[:80]}{'…' if len(str_val) > 80 else ''}*")
|
||||
continue
|
||||
|
||||
# ── Событие без значения (теги, файлы, эскалация) ─────────────
|
||||
if etype not in (185, 250): # пропускаем служебные
|
||||
changes.append(f"• {label}")
|
||||
|
||||
return changes, comments
|
||||
|
||||
|
||||
def _resolve_field(fields: dict, field: str, entity: str) -> str | None:
|
||||
"""Резолвит ID поля верхнего уровня через справочники."""
|
||||
raw = _parse_json_field(fields.get(field, {}))
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
item_id = raw.get("Id") or raw.get("Value")
|
||||
if not item_id:
|
||||
return None
|
||||
return reference.resolve(entity, item_id) or str(item_id)
|
||||
|
||||
|
||||
def format_notification(payload: dict) -> str:
|
||||
"""Полная карточка задачи для основного поста в канале."""
|
||||
op_type = payload.get("Event", {}).get("OperationType", "")
|
||||
fields = payload.get("Fields", {})
|
||||
|
||||
number = fields.get("Number", "?")
|
||||
name = _scalar(fields.get("name")) or "—"
|
||||
executor_grp = get_executor_group(fields)
|
||||
initiator = get_initiator(fields)
|
||||
|
||||
status = _resolve_field(fields, "status", "statuses")
|
||||
priority = _resolve_field(fields, "priority", "priorities")
|
||||
service = _resolve_field(fields, "service", "services")
|
||||
tasktype = _resolve_field(fields, "tasktype", "tasktypes")
|
||||
|
||||
changes, comments = parse_lifetime(fields)
|
||||
|
||||
header = f"🆕 *Новая заявка \\#{number}*" if op_type == "Created" \
|
||||
else f"🔄 *Заявка \\#{number} обновлена*"
|
||||
|
||||
lines = [header, name, ""]
|
||||
|
||||
if status:
|
||||
lines.append(f"📌 Статус: {status}")
|
||||
if priority:
|
||||
lines.append(f"🔺 Приоритет: {priority}")
|
||||
if service:
|
||||
lines.append(f"🗂 Услуга: {service}")
|
||||
if tasktype:
|
||||
lines.append(f"📋 Тип: {tasktype}")
|
||||
if executor_grp:
|
||||
lines.append(f"👥 Группа: {executor_grp}")
|
||||
if initiator:
|
||||
lines.append(f"👤 Инициатор: {initiator}")
|
||||
if any([status, priority, service, tasktype, executor_grp, initiator]):
|
||||
lines.append("")
|
||||
|
||||
if changes:
|
||||
lines.extend(changes)
|
||||
lines.append("")
|
||||
|
||||
if comments:
|
||||
lines.append("💬 *Комментарии:*")
|
||||
lines.extend(comments)
|
||||
lines.append("")
|
||||
|
||||
task_url = config.BASE_TASK_URL + str(number)
|
||||
lines.append(f"[Открыть заявку]({task_url})")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_update_summary(changes: list[str], comments: list[str]) -> str | None:
|
||||
"""Короткий reply-пост при обновлении (триггерит пуш подписчикам канала)."""
|
||||
if not changes and not comments:
|
||||
return None
|
||||
lines = ["🔄 *Изменено:*"]
|
||||
lines.extend(changes)
|
||||
if comments:
|
||||
lines.append("💬 *Комментарии:*")
|
||||
lines.extend(comments)
|
||||
return "\n".join(lines)
|
||||
52
intradesk/handler.py
Normal file
52
intradesk/handler.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import events
|
||||
from bot import telegram as bot
|
||||
from intradesk import formatter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_webhook(payload: dict):
|
||||
try:
|
||||
task_id = payload.get("Id")
|
||||
op_type = payload.get("Event", {}).get("OperationType", "")
|
||||
fields = payload.get("Fields", {})
|
||||
number = fields.get("Number", "?")
|
||||
|
||||
if not task_id:
|
||||
log.warning("Webhook payload missing Id — skipped")
|
||||
return
|
||||
|
||||
if op_type not in ("Created", "Updated"):
|
||||
log.debug(f"OperationType '{op_type}' — skipped")
|
||||
return
|
||||
|
||||
changes, comments = formatter.parse_lifetime(fields)
|
||||
full_text = formatter.format_notification(payload)
|
||||
|
||||
events.broadcast({
|
||||
"type": "webhook",
|
||||
"timestamp": datetime.utcnow().strftime("%d.%m.%Y %H:%M:%S"),
|
||||
"task_id": task_id,
|
||||
"task_number": number,
|
||||
"operation": op_type,
|
||||
"raw": payload,
|
||||
"parsed": {
|
||||
"changes": changes,
|
||||
"comments": comments,
|
||||
},
|
||||
"formatted": full_text,
|
||||
})
|
||||
|
||||
closed = formatter.is_final(fields)
|
||||
|
||||
if op_type == "Created":
|
||||
bot.send_or_edit(task_id, number, full_text, closed=closed)
|
||||
else:
|
||||
summary = formatter.format_update_summary(changes, comments)
|
||||
bot.send_or_edit(task_id, number, full_text, summary, closed=closed)
|
||||
|
||||
except Exception:
|
||||
log.exception("Error while handling webhook")
|
||||
278
intradesk/reference.py
Normal file
278
intradesk/reference.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""
|
||||
Справочники Intradesk.
|
||||
|
||||
Содержит:
|
||||
1. Статические константы (известные ID → имена)
|
||||
2. Загрузку справочников из API Intradesk в локальную БД
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
import config
|
||||
import database
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Статические справочники ────────────────────────────────────────────────────
|
||||
# Используются как fallback если данные ещё не загружены из API,
|
||||
# а также сидируются в БД при старте.
|
||||
|
||||
STATUSES: dict[int, str] = {
|
||||
68046: "Открыта",
|
||||
68048: "Требует уточнения",
|
||||
68051: "В работе",
|
||||
68045: "Выполнена",
|
||||
}
|
||||
|
||||
PRIORITIES: dict[int, str] = {
|
||||
29749: "Наивысший",
|
||||
29598: "Критический",
|
||||
29597: "Высокий",
|
||||
29596: "Низкий",
|
||||
}
|
||||
|
||||
USER_TYPES: dict[int, str] = {
|
||||
10: "Клиент",
|
||||
20: "Сотрудник",
|
||||
}
|
||||
|
||||
# Все типы событий из lifecycle заявки
|
||||
EVENT_TYPES: dict[int, str] = {
|
||||
10: "Смена статуса",
|
||||
20: "Смена приоритета",
|
||||
25: "Смена оценки",
|
||||
30: "Смена исполнителя",
|
||||
40: "Смена группы исполнителей",
|
||||
45: "Изменение доп. данных",
|
||||
50: "Комментарий",
|
||||
55: "Приватный комментарий",
|
||||
60: "Добавлен наблюдатель",
|
||||
61: "Добавлен координатор",
|
||||
65: "Добавлена группа наблюдателей",
|
||||
70: "Удалён наблюдатель",
|
||||
75: "Удалена группа наблюдателей",
|
||||
80: "Добавлен файл",
|
||||
85: "Добавлен скрытый файл",
|
||||
90: "Удалён файл",
|
||||
95: "Удалён скрытый файл",
|
||||
100: "Добавлен тег",
|
||||
105: "Добавлен актив",
|
||||
110: "Удалён тег",
|
||||
115: "Удалён актив",
|
||||
120: "Изменён плановый срок решения",
|
||||
125: "Изменён фактический срок решения",
|
||||
126: "Изменено оставшееся время",
|
||||
130: "Изменён плановый срок реакции",
|
||||
135: "Изменён фактический срок реакции",
|
||||
136: "Истёк срок решения",
|
||||
137: "Истёк срок реакции",
|
||||
138: "Изменена дата закрытия",
|
||||
140: "Изменён сервис",
|
||||
150: "Изменён тип заявки",
|
||||
160: "Изменён инициатор",
|
||||
161: "Изменён клиент",
|
||||
165: "Изменена группа инициатора",
|
||||
170: "Изменено доп. поле",
|
||||
180: "Изменена дата создания",
|
||||
185: "Изменена дата обновления",
|
||||
190: "Изменён создатель",
|
||||
195: "Изменён последний редактор",
|
||||
200: "Изменено название",
|
||||
205: "Изменено описание",
|
||||
210: "Изменён бизнес-процесс",
|
||||
220: "Добавлен не ответственный исполнитель",
|
||||
225: "Добавлена не ответственная группа исполнителей",
|
||||
230: "Удалён не ответственный исполнитель",
|
||||
235: "Удалена не ответственная группа исполнителей",
|
||||
240: "Изменён SLA",
|
||||
250: "Изменена история",
|
||||
260: "Изменён канал создания",
|
||||
270: "Изменён канал обновления",
|
||||
280: "Архивировано",
|
||||
290: "Добавлены координаты",
|
||||
300: "Выполнен макрос",
|
||||
310: "Изменён признак массового инцидента",
|
||||
320: "Изменён массовый инцидент",
|
||||
330: "Изменена координация",
|
||||
340: "Изменена критичность приоритета",
|
||||
350: "Изменено влияние приоритета",
|
||||
400: "Эскалация заявки",
|
||||
}
|
||||
|
||||
# ── API-справочники (загружаются из Intradesk) ─────────────────────────────────
|
||||
#
|
||||
# Статус эндпоинта:
|
||||
# [OK] — проверено, работает
|
||||
# [?] — нужно проверить, путь предположительный
|
||||
|
||||
SETTINGS_URL = "https://apigw.intradesk.ru/settings/odata"
|
||||
|
||||
ENTITIES = [
|
||||
{
|
||||
"key": "employees",
|
||||
"url": f"{SETTINGS_URL}/Employees",
|
||||
"params": {"$filter": "isArchived eq false"},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "OK",
|
||||
},
|
||||
{
|
||||
"key": "groups",
|
||||
"url": f"{SETTINGS_URL}/UserGroups",
|
||||
"params": {"$filter": "isArchived eq false"},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "?",
|
||||
},
|
||||
{
|
||||
"key": "statuses",
|
||||
"url": f"{SETTINGS_URL}/Statuses",
|
||||
"params": {},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "?",
|
||||
},
|
||||
{
|
||||
"key": "priorities",
|
||||
"url": f"{SETTINGS_URL}/Priorities",
|
||||
"params": {},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "?",
|
||||
},
|
||||
{
|
||||
"key": "services",
|
||||
"url": f"{SETTINGS_URL}/ServiceCatalog",
|
||||
"params": {"$filter": "isArchived eq false"},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "?",
|
||||
},
|
||||
{
|
||||
"key": "tasktypes",
|
||||
"url": f"{SETTINGS_URL}/TaskTypes",
|
||||
"params": {"$filter": "isArchived eq false"},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "?",
|
||||
},
|
||||
{
|
||||
"key": "tags",
|
||||
"url": f"{SETTINGS_URL}/Tags",
|
||||
"params": {},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "?",
|
||||
},
|
||||
{
|
||||
"key": "workflows",
|
||||
"url": f"{SETTINGS_URL}/Workflows",
|
||||
"params": {"$filter": "isArchived eq false"},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "?",
|
||||
},
|
||||
{
|
||||
"key": "sla",
|
||||
"url": f"{SETTINGS_URL}/Sla",
|
||||
"params": {},
|
||||
"id_field": "id",
|
||||
"name_field": "name",
|
||||
"status": "?",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ── вспомогательные ────────────────────────────────────────────────────────────
|
||||
|
||||
def resolve(entity: str, item_id: int | None) -> str | None:
|
||||
"""
|
||||
Резолвит ID в имя.
|
||||
Сначала смотрит в БД (актуальные данные из API),
|
||||
затем в статические словари-fallback.
|
||||
"""
|
||||
if item_id is None:
|
||||
return None
|
||||
|
||||
# БД — приоритет
|
||||
name = database.resolve(entity, item_id)
|
||||
if name:
|
||||
return name
|
||||
|
||||
# Fallback на статические константы
|
||||
_fallbacks = {
|
||||
"statuses": STATUSES,
|
||||
"priorities": PRIORITIES,
|
||||
}
|
||||
fb = _fallbacks.get(entity, {})
|
||||
return fb.get(item_id)
|
||||
|
||||
|
||||
def event_name(event_type: int) -> str:
|
||||
"""Возвращает человекочитаемое имя события по его числовому коду."""
|
||||
return EVENT_TYPES.get(event_type, f"Событие {event_type}")
|
||||
|
||||
|
||||
def user_type_name(user_type: int) -> str:
|
||||
"""Возвращает тип пользователя (Клиент / Сотрудник)."""
|
||||
return USER_TYPES.get(user_type, str(user_type))
|
||||
|
||||
|
||||
# ── загрузка из API ────────────────────────────────────────────────────────────
|
||||
|
||||
def _fetch_all_pages(url: str, params: dict) -> list[dict]:
|
||||
params = {**params, "ApiKey": config.API_KEY}
|
||||
items = []
|
||||
current_url = url
|
||||
|
||||
while current_url:
|
||||
try:
|
||||
resp = requests.get(current_url, params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.error(f"Request failed [{current_url}]: {e}")
|
||||
break
|
||||
|
||||
data = resp.json()
|
||||
items.extend(data.get("value", []))
|
||||
current_url = data.get("@odata.nextLink")
|
||||
params = None
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _load_entity(entity: dict) -> int:
|
||||
key = entity["key"]
|
||||
id_field = entity["id_field"]
|
||||
name_field = entity["name_field"]
|
||||
|
||||
items = _fetch_all_pages(entity["url"], entity.get("params", {}))
|
||||
if not items:
|
||||
log.warning(f"[{key}] empty or failed — skipped")
|
||||
return 0
|
||||
|
||||
pairs = [
|
||||
(item[id_field], str(item.get(name_field) or "").strip())
|
||||
for item in items
|
||||
if id_field in item
|
||||
]
|
||||
database.save_ref_data(key, pairs)
|
||||
log.info(f"[{key}] loaded {len(pairs)} records")
|
||||
return len(pairs)
|
||||
|
||||
|
||||
def seed_static():
|
||||
"""Сидирует в БД известные статические данные (статусы, приоритеты)."""
|
||||
database.save_ref_data("statuses", list(STATUSES.items()))
|
||||
database.save_ref_data("priorities", list(PRIORITIES.items()))
|
||||
log.info("Static reference data seeded (statuses, priorities)")
|
||||
|
||||
|
||||
def load_all():
|
||||
"""Загружает все справочники из API. Неудачные — логирует и пропускает."""
|
||||
log.info("Loading reference data from Intradesk API...")
|
||||
for entity in ENTITIES:
|
||||
_load_entity(entity)
|
||||
log.info("Reference data loading complete.")
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
requests
|
||||
pytelegrambotapi
|
||||
flask
|
||||
schedule
|
||||
370
tests/webhook.json
Normal file
370
tests/webhook.json
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
{
|
||||
"Id": 5430092,
|
||||
"Event": {
|
||||
"EventType": "TaskEvents",
|
||||
"OperationType": "Updated"
|
||||
},
|
||||
"Fields": {
|
||||
"aiemotionalassessment": {
|
||||
"Value": null
|
||||
},
|
||||
"aihistory": {
|
||||
"Value": null
|
||||
},
|
||||
"assets": [],
|
||||
"attachments": [],
|
||||
"commentemailmessageid": {
|
||||
"Value": null
|
||||
},
|
||||
"createdchannel": {
|
||||
"Value": "web"
|
||||
},
|
||||
"emailmessageid": {
|
||||
"Value": null
|
||||
},
|
||||
"evaluation": "null",
|
||||
"massincident": null,
|
||||
"prioritycriticality": {
|
||||
"Id": null
|
||||
},
|
||||
"priority": {
|
||||
"Id": 29599
|
||||
},
|
||||
"priorityinfluence": {
|
||||
"Id": null
|
||||
},
|
||||
"privateattachments": [],
|
||||
"service": {
|
||||
"Id": 84683,
|
||||
"Path": "84681/84683/"
|
||||
},
|
||||
"tags": [],
|
||||
"taskbranch": null,
|
||||
"tasktype": {
|
||||
"Id": 21026,
|
||||
"Name": null
|
||||
},
|
||||
"updatedchannel": {
|
||||
"Value": "web"
|
||||
},
|
||||
"workflow": {
|
||||
"Value": 18090
|
||||
},
|
||||
"additionaldata": {},
|
||||
"clientid": {
|
||||
"Value": null
|
||||
},
|
||||
"olareactiondateplan": {
|
||||
"Value": null
|
||||
},
|
||||
"olaresolutiondateplan": {
|
||||
"Value": null
|
||||
},
|
||||
"sla": {
|
||||
"Id": null
|
||||
},
|
||||
"status": {
|
||||
"Id": 68045,
|
||||
"IsStartTimeTracking": false,
|
||||
"IsStopTimeTracking": true,
|
||||
"IsFinal": true,
|
||||
"IsCompletion": true,
|
||||
"IsCoordination": false
|
||||
},
|
||||
"closedat": {
|
||||
"Value": "2026-02-24T09:04:05.0756847Z"
|
||||
},
|
||||
"ismassincidentactive": {
|
||||
"Value": false
|
||||
},
|
||||
"reactiondatefact": {
|
||||
"Value": "2026-02-24T09:04:00.0000000Z"
|
||||
},
|
||||
"reactiondateplan": {
|
||||
"Value": null
|
||||
},
|
||||
"resolutiondatefact": {
|
||||
"Value": "2026-02-24T09:04:00.0000000Z"
|
||||
},
|
||||
"isreactionexpired": {
|
||||
"Value": null
|
||||
},
|
||||
"resolutiondateplan": {
|
||||
"Value": null
|
||||
},
|
||||
"coordination": {
|
||||
"Value": null
|
||||
},
|
||||
"resolutionleftminutes": {
|
||||
"Value": null
|
||||
},
|
||||
"isresolutionexpired": {
|
||||
"Value": false
|
||||
},
|
||||
"prevstatus": {
|
||||
"Id": 68051,
|
||||
"IsStartTimeTracking": true,
|
||||
"IsStopTimeTracking": false,
|
||||
"IsFinal": false,
|
||||
"IsCompletion": false,
|
||||
"IsCoordination": false
|
||||
},
|
||||
"taskusers": {
|
||||
"TaskUsers": [
|
||||
{
|
||||
"Id": 2227907,
|
||||
"Name": "Высоких Дмитрий ",
|
||||
"Type": 10
|
||||
},
|
||||
{
|
||||
"Id": 2227907,
|
||||
"Name": "Высоких Дмитрий ",
|
||||
"Type": 20
|
||||
},
|
||||
{
|
||||
"Id": 512585,
|
||||
"Name": "Кравченко Дмитрий ",
|
||||
"Type": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
"taskusergroups": {
|
||||
"TaskUserGroups": [
|
||||
{
|
||||
"Id": 243025,
|
||||
"Name": "Поддержка, 1-ая линия",
|
||||
"Type": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
"addfield_a20b51bvid_remontaSingleSelect": {
|
||||
"TaskFieldType": 3,
|
||||
"ValueType": 4,
|
||||
"Value": null,
|
||||
"IsAdditionalField": true
|
||||
},
|
||||
"addfield_8d27394diagnostirovannaia_neispravnostMultiSelect": {
|
||||
"TaskFieldType": 8,
|
||||
"ValueType": 13,
|
||||
"Value": null,
|
||||
"IsAdditionalField": true
|
||||
},
|
||||
"addfield_3d36364vid_remontaSingleSelect": {
|
||||
"TaskFieldType": 3,
|
||||
"ValueType": 4,
|
||||
"Value": null,
|
||||
"IsAdditionalField": true
|
||||
},
|
||||
"addfield_21282a5testString": {
|
||||
"TaskFieldType": 2,
|
||||
"ValueType": 4,
|
||||
"Value": null,
|
||||
"IsAdditionalField": true
|
||||
},
|
||||
"addfield_ea5d305sozvon_pered_vyezdomBoolean": {
|
||||
"TaskFieldType": 5,
|
||||
"ValueType": 12,
|
||||
"Value": null,
|
||||
"IsAdditionalField": true
|
||||
},
|
||||
"addfield_677b45atoBoolean": {
|
||||
"TaskFieldType": 5,
|
||||
"ValueType": 12,
|
||||
"Value": null,
|
||||
"IsAdditionalField": true
|
||||
},
|
||||
"addfield_71c50d0obieektSingleSelect": {
|
||||
"TaskFieldType": 3,
|
||||
"ValueType": 4,
|
||||
"Value": null,
|
||||
"IsAdditionalField": true
|
||||
},
|
||||
"addfield_2684bdczamena_oborudovaniiaBoolean": {
|
||||
"TaskFieldType": 5,
|
||||
"ValueType": 12,
|
||||
"Value": null,
|
||||
"IsAdditionalField": true
|
||||
},
|
||||
"addfield_764142dzameniaemoe_oborudovanieMultiSelect": {
|
||||
"TaskFieldType": 8,
|
||||
"ValueType": 13,
|
||||
"Value": null,
|
||||
"IsAdditionalField": true
|
||||
},
|
||||
"lifetime": {
|
||||
"Data": [
|
||||
{
|
||||
"lifetimetype": 20,
|
||||
"userid": 2227907,
|
||||
"username": "Высоких Дмитрий ",
|
||||
"authortaskusertypes": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"usertype": 10,
|
||||
"eventat": "2026-02-24T09:04:05.1774367Z",
|
||||
"events": {
|
||||
"Data": [
|
||||
{
|
||||
"isautomatic": false,
|
||||
"blockname": "comment",
|
||||
"type": 50,
|
||||
"commentupdatedate": null,
|
||||
"stringvalue": "2",
|
||||
"datevalue": null,
|
||||
"entityvalue": null,
|
||||
"entitylanguagevalue": null,
|
||||
"additionalfieldvalue": null
|
||||
},
|
||||
{
|
||||
"isautomatic": false,
|
||||
"blockname": "status",
|
||||
"type": 10,
|
||||
"commentupdatedate": null,
|
||||
"stringvalue": null,
|
||||
"datevalue": null,
|
||||
"entityvalue": null,
|
||||
"entitylanguagevalue": {
|
||||
"valuenew": {
|
||||
"id": 68045,
|
||||
"name": null,
|
||||
"currentname": null
|
||||
},
|
||||
"valueold": {
|
||||
"id": 68051,
|
||||
"name": null,
|
||||
"currentname": null
|
||||
}
|
||||
},
|
||||
"additionalfieldvalue": null
|
||||
}
|
||||
],
|
||||
"Count": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"Count": 1
|
||||
},
|
||||
"createdat": {
|
||||
"Value": "2026-02-24T09:03:21.8840000Z"
|
||||
},
|
||||
"createdby": {
|
||||
"Value": "Высоких Дмитрий "
|
||||
},
|
||||
"description": {
|
||||
"Value": "123"
|
||||
},
|
||||
"name": {
|
||||
"Value": "test"
|
||||
},
|
||||
"updatedat": {
|
||||
"Value": "2026-02-24T09:04:05.1780000Z"
|
||||
},
|
||||
"updatedby": {
|
||||
"Value": "Высоких Дмитрий "
|
||||
},
|
||||
"Events": [
|
||||
{
|
||||
"EventType": 50,
|
||||
"Block": "comment",
|
||||
"ValueType": 4,
|
||||
"OldValue": "null",
|
||||
"NewValue": "\"2\""
|
||||
},
|
||||
{
|
||||
"EventType": 10,
|
||||
"Block": "status",
|
||||
"ValueType": 10,
|
||||
"OldValue": {
|
||||
"Id": 68051
|
||||
},
|
||||
"NewValue": {
|
||||
"Id": 68045
|
||||
}
|
||||
},
|
||||
{
|
||||
"EventType": 138,
|
||||
"Block": "closedat",
|
||||
"ValueType": 6,
|
||||
"OldValue": "null",
|
||||
"NewValue": "\"2026-02-24T09:04:05.0756847Z\""
|
||||
},
|
||||
{
|
||||
"EventType": 125,
|
||||
"Block": "resolutiondatefact",
|
||||
"ValueType": 6,
|
||||
"OldValue": "null",
|
||||
"NewValue": "\"2026-02-24T09:04:00.0000000Z\""
|
||||
},
|
||||
{
|
||||
"EventType": 250,
|
||||
"Block": "lifetime",
|
||||
"ValueType": 16,
|
||||
"OldValue": null,
|
||||
"NewValue": {
|
||||
"Data": [
|
||||
{
|
||||
"lifetimetype": 20,
|
||||
"userid": 2227907,
|
||||
"username": "Высоких Дмитрий ",
|
||||
"authortaskusertypes": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"usertype": 10,
|
||||
"eventat": "2026-02-24T09:04:05.1774367Z",
|
||||
"events": {
|
||||
"Data": [
|
||||
{
|
||||
"isautomatic": false,
|
||||
"blockname": "comment",
|
||||
"type": 50,
|
||||
"commentupdatedate": null,
|
||||
"stringvalue": "2",
|
||||
"datevalue": null,
|
||||
"entityvalue": null,
|
||||
"entitylanguagevalue": null,
|
||||
"additionalfieldvalue": null
|
||||
},
|
||||
{
|
||||
"isautomatic": false,
|
||||
"blockname": "status",
|
||||
"type": 10,
|
||||
"commentupdatedate": null,
|
||||
"stringvalue": null,
|
||||
"datevalue": null,
|
||||
"entityvalue": null,
|
||||
"entitylanguagevalue": {
|
||||
"valuenew": {
|
||||
"id": 68045,
|
||||
"name": null,
|
||||
"currentname": null
|
||||
},
|
||||
"valueold": {
|
||||
"id": 68051,
|
||||
"name": null,
|
||||
"currentname": null
|
||||
}
|
||||
},
|
||||
"additionalfieldvalue": null
|
||||
}
|
||||
],
|
||||
"Count": 2
|
||||
}
|
||||
}
|
||||
],
|
||||
"Count": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"EventType": 185,
|
||||
"Block": "updatedat",
|
||||
"ValueType": 6,
|
||||
"OldValue": "\"2026-02-24T09:04:00.8840000Z\"",
|
||||
"NewValue": "\"2026-02-24T09:04:05.1780000Z\""
|
||||
}
|
||||
],
|
||||
"Number": "4687"
|
||||
},
|
||||
"AuthorId": 2227907
|
||||
}
|
||||
0
web/__init__.py
Normal file
0
web/__init__.py
Normal file
153
web/server.py
Normal file
153
web/server.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
||||
|
||||
import config
|
||||
import database
|
||||
import events
|
||||
from intradesk import reference
|
||||
from intradesk import api as intradesk_api
|
||||
from intradesk.handler import handle_webhook
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"))
|
||||
|
||||
|
||||
# ── Webhook от Intradesk ───────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/webhook", methods=["POST"])
|
||||
def webhook():
|
||||
if config.WEBHOOK_SECRET:
|
||||
secret = request.headers.get("X-Webhook-Secret") or request.args.get("secret", "")
|
||||
if secret != config.WEBHOOK_SECRET:
|
||||
log.warning("Webhook: unauthorized request")
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
|
||||
threading.Thread(target=handle_webhook, args=(data,), daemon=True).start()
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
# ── Дашборд ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/")
|
||||
def dashboard():
|
||||
return render_template("dashboard.html")
|
||||
|
||||
|
||||
@app.route("/stream")
|
||||
def stream():
|
||||
q = events.subscribe()
|
||||
|
||||
def generate():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = q.get(timeout=25)
|
||||
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
except Exception:
|
||||
yield 'data: {"type":"ping"}\n\n'
|
||||
finally:
|
||||
events.unsubscribe(q)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ── API заявок ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/tasks/search")
|
||||
def api_tasks_search():
|
||||
try:
|
||||
number = int(request.args.get("number", 0))
|
||||
task = intradesk_api.get_task(number)
|
||||
if task is None:
|
||||
return jsonify({"error": "Заявка не найдена"}), 404
|
||||
return jsonify(task)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.route("/api/tasks/active")
|
||||
def api_tasks_active():
|
||||
filter_expr = request.args.get(
|
||||
"filter", "(status ne 68045) and (executorgroup eq 243025)"
|
||||
)
|
||||
tasks = intradesk_api.get_tasks(filter_expr, top=50)
|
||||
return jsonify(tasks)
|
||||
|
||||
|
||||
@app.route("/api/task/lifetime")
|
||||
def api_task_lifetime():
|
||||
try:
|
||||
task_id = int(request.args.get("task_id", 0))
|
||||
top = int(request.args.get("top", 20))
|
||||
data = intradesk_api.get_task_lifetime(task_id, top=top)
|
||||
return jsonify(data)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
# ── Справочники ────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/ref/<entity>")
|
||||
def api_ref(entity):
|
||||
items = database.get_all_ref(entity)
|
||||
if not items:
|
||||
fallback = {"statuses": reference.STATUSES, "priorities": reference.PRIORITIES}
|
||||
d = fallback.get(entity, {})
|
||||
items = [{"id": k, "name": v} for k, v in d.items()]
|
||||
return jsonify(items)
|
||||
|
||||
|
||||
# ── Изменение заявки ───────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/task/preview", methods=["POST"])
|
||||
def api_task_preview():
|
||||
data = request.get_json() or {}
|
||||
number = data.get("number")
|
||||
blocks = data.get("blocks", {})
|
||||
body = {"number": number, "blocks": blocks, "Channel": "api"}
|
||||
return jsonify({
|
||||
"method": "PUT",
|
||||
"url": "https://apigw.intradesk.ru/changes/v3/tasks/?ApiKey=***",
|
||||
"body": body,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/task/execute", methods=["POST"])
|
||||
def api_task_execute():
|
||||
data = request.get_json() or {}
|
||||
number = data.get("number")
|
||||
blocks = data.get("blocks", {})
|
||||
if not number:
|
||||
return jsonify({"success": False, "error": "Не указан номер заявки"}), 400
|
||||
success = intradesk_api.update_task(int(number), blocks)
|
||||
return jsonify({"success": success})
|
||||
|
||||
|
||||
def start_server():
|
||||
ssl_cert = os.path.join("/app/ssl", "cert.pem")
|
||||
ssl_key = os.path.join("/app/ssl", "key.pem")
|
||||
|
||||
if os.path.exists(ssl_cert) and os.path.exists(ssl_key):
|
||||
log.info(f"Web server + dashboard → https://0.0.0.0:{config.PORT} (SSL)")
|
||||
app.run(host="0.0.0.0", port=config.PORT,
|
||||
ssl_context=(ssl_cert, ssl_key), use_reloader=False)
|
||||
else:
|
||||
log.info(f"Web server + dashboard → http://0.0.0.0:{config.PORT} (no SSL)")
|
||||
app.run(host="0.0.0.0", port=config.PORT, use_reloader=False)
|
||||
689
web/templates/dashboard.html
Normal file
689
web/templates/dashboard.html
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IntradBot Dashboard</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0d1117;--surface:#161b22;--surface2:#21262d;--border:#30363d;
|
||||
--accent:#388bfd;--accent-h:#58a6ff;--success:#3fb950;--warn:#d29922;
|
||||
--danger:#da3633;--text:#c9d1d9;--muted:#8b949e;--r:6px;
|
||||
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
--mono:'SFMono-Regular',Consolas,monospace;
|
||||
}
|
||||
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:14px;display:flex;height:100vh;overflow:hidden}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar{width:220px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
||||
.brand{padding:18px 16px 16px;display:flex;align-items:center;gap:8px;font-size:15px;font-weight:700;border-bottom:1px solid var(--border)}
|
||||
.nav{padding:8px 0;flex:1}
|
||||
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 16px;cursor:pointer;color:var(--muted);border-left:3px solid transparent;font-size:13px;transition:.15s}
|
||||
.nav-item:hover{background:var(--surface2);color:var(--text)}
|
||||
.nav-item.active{color:var(--accent-h);border-left-color:var(--accent);background:rgba(56,139,253,.1)}
|
||||
.nav-icon{width:18px;text-align:center}
|
||||
.sidebar-foot{padding:14px 16px;border-top:1px solid var(--border)}
|
||||
.status-row{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
|
||||
.dot.on{background:var(--success);box-shadow:0 0 6px var(--success)}
|
||||
.dot.off{background:var(--danger)}
|
||||
.counter{margin-left:auto;background:var(--accent);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;font-weight:600;display:none}
|
||||
.counter.show{display:inline}
|
||||
|
||||
/* ── Main ── */
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
.tab{display:none;flex:1;flex-direction:column;overflow:hidden}
|
||||
.tab.active{display:flex}
|
||||
.tab-head{padding:14px 24px;border-bottom:1px solid var(--border);background:var(--surface);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.tab-head h2{font-size:15px;font-weight:600}
|
||||
.tab-body{flex:1;overflow-y:auto;padding:16px 24px}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn{padding:6px 14px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:var(--r);cursor:pointer;font-size:13px;font-family:var(--font);transition:.15s;display:inline-flex;align-items:center;gap:6px}
|
||||
.btn:hover{border-color:var(--accent);color:var(--accent-h)}
|
||||
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||||
.btn-primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
.btn-primary:hover{background:var(--accent-h);border-color:var(--accent-h);color:#fff}
|
||||
.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff}
|
||||
.btn-danger:hover{opacity:.85;color:#fff}
|
||||
.btn-sm{padding:4px 10px;font-size:12px}
|
||||
.btn-group{display:flex;gap:8px}
|
||||
|
||||
/* ── Inputs ── */
|
||||
input,select,textarea{background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:var(--r);font-family:var(--font);font-size:13px;width:100%;transition:.15s}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}
|
||||
textarea{resize:vertical;min-height:80px}
|
||||
select option{background:var(--surface2)}
|
||||
.form-row{display:flex;gap:8px;margin-bottom:12px}
|
||||
.form-row input,.form-row select{flex:1}
|
||||
.form-group{margin-bottom:14px}
|
||||
.form-label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:5px}
|
||||
|
||||
/* ── Webhook cards ── */
|
||||
.feed{display:flex;flex-direction:column;gap:10px}
|
||||
.wcard{border:1px solid var(--border);border-radius:var(--r);overflow:hidden;background:var(--surface)}
|
||||
.wcard.new{border-left:3px solid var(--success)}
|
||||
.wcard.updated{border-left:3px solid var(--accent)}
|
||||
.wcard-head{padding:12px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;user-select:none}
|
||||
.wcard-head:hover{background:var(--surface2)}
|
||||
.badge{padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;text-transform:uppercase}
|
||||
.badge-new{background:rgba(63,185,80,.15);color:var(--success)}
|
||||
.badge-upd{background:rgba(56,139,253,.15);color:var(--accent-h)}
|
||||
.wcard-num{font-weight:700;font-size:14px}
|
||||
.wcard-name{color:var(--muted);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.wcard-time{color:var(--muted);font-size:12px;flex-shrink:0}
|
||||
.chevron{color:var(--muted);font-size:11px;transition:.2s;flex-shrink:0}
|
||||
.wcard.open .chevron{transform:rotate(180deg)}
|
||||
.wcard-body{display:none;border-top:1px solid var(--border)}
|
||||
.wcard.open .wcard-body{display:block}
|
||||
.wsec{border-bottom:1px solid var(--border)}
|
||||
.wsec:last-child{border-bottom:none}
|
||||
.wsec-head{padding:8px 16px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:6px;background:rgba(255,255,255,.02)}
|
||||
.wsec-head:hover{color:var(--text)}
|
||||
.wsec-body{padding:12px 16px;display:none}
|
||||
.wsec.open .wsec-body{display:block}
|
||||
.wsec.open .wsec-chevron{transform:rotate(90deg)}
|
||||
.wsec-chevron{transition:.15s}
|
||||
.change-item{padding:4px 0;border-bottom:1px solid rgba(255,255,255,.04);font-size:13px}
|
||||
.change-item:last-child{border-bottom:none}
|
||||
.comment-item{background:rgba(56,139,253,.06);border-left:2px solid var(--accent);padding:6px 10px;margin:3px 0;border-radius:0 var(--r) var(--r) 0;font-size:13px}
|
||||
.formatted-msg{font-family:var(--mono);font-size:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:12px;white-space:pre-wrap;word-break:break-all;max-height:300px;overflow-y:auto}
|
||||
|
||||
/* ── Tasks ── */
|
||||
.task-card{border:1px solid var(--border);border-radius:var(--r);padding:20px;background:var(--surface);margin-bottom:12px;cursor:pointer;transition:.15s}
|
||||
.task-card:hover{border-color:var(--accent)}
|
||||
.task-card.expanded{border-color:var(--accent)}
|
||||
.task-title{font-size:15px;font-weight:600;margin-bottom:10px;display:flex;align-items:flex-start;gap:10px}
|
||||
.task-num{color:var(--muted);font-size:13px;font-weight:400;flex-shrink:0}
|
||||
.task-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;margin-bottom:12px}
|
||||
.meta-item{background:var(--surface2);border-radius:var(--r);padding:10px 12px}
|
||||
.meta-label{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:3px}
|
||||
.meta-val{font-size:13px;font-weight:500}
|
||||
.tasks-list{display:flex;flex-direction:column;gap:8px}
|
||||
.status-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px}
|
||||
.s-open{background:rgba(63,185,80,.15);color:var(--success)}
|
||||
.s-work{background:rgba(56,139,253,.15);color:var(--accent-h)}
|
||||
.s-done{background:rgba(139,148,158,.15);color:var(--muted)}
|
||||
.s-wait{background:rgba(210,153,34,.15);color:var(--warn)}
|
||||
.task-desc{font-size:13px;color:var(--muted);padding:10px;background:var(--bg);border-radius:var(--r);margin-top:8px;white-space:pre-wrap}
|
||||
|
||||
/* ── API form ── */
|
||||
.api-layout{display:grid;grid-template-columns:360px 1fr;gap:20px;align-items:start}
|
||||
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:20px}
|
||||
.panel-title{font-size:13px;font-weight:600;margin-bottom:16px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
|
||||
.preview-url{font-family:var(--mono);font-size:12px;padding:8px 10px;background:var(--bg);border-radius:var(--r);margin-bottom:10px;color:var(--warn);word-break:break-all}
|
||||
pre{font-family:var(--mono);font-size:12px;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:12px;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
|
||||
.result-block{margin-top:12px;padding:10px 14px;border-radius:var(--r);font-size:13px}
|
||||
.result-ok{background:rgba(63,185,80,.1);border:1px solid var(--success);color:var(--success)}
|
||||
.result-err{background:rgba(218,54,51,.1);border:1px solid var(--danger);color:var(--danger)}
|
||||
.result-info{background:rgba(56,139,253,.1);border:1px solid var(--accent);color:var(--accent-h)}
|
||||
.hidden{display:none!important}
|
||||
.empty{text-align:center;color:var(--muted);padding:60px 20px;font-size:14px}
|
||||
.spin{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||
::-webkit-scrollbar-thumb:hover{background:var(--muted)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="brand">🤖 IntradBot</div>
|
||||
<nav class="nav">
|
||||
<div class="nav-item active" data-tab="webhooks" onclick="switchTab('webhooks',this)">
|
||||
<span class="nav-icon">🔔</span> Вебхуки
|
||||
<span class="counter" id="wh-counter">0</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="tasks" onclick="switchTab('tasks',this)">
|
||||
<span class="nav-icon">📋</span> Заявки
|
||||
</div>
|
||||
<div class="nav-item" data-tab="api" onclick="switchTab('api',this)">
|
||||
<span class="nav-icon">⚙️</span> API
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<div class="status-row">
|
||||
<div class="dot" id="dot"></div>
|
||||
<span id="dot-label">Подключение...</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<!-- ── Вебхуки ── -->
|
||||
<section class="tab active" id="tab-webhooks">
|
||||
<div class="tab-head">
|
||||
<h2>Вебхуки в реальном времени</h2>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm" onclick="clearFeed()">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-body">
|
||||
<div class="feed" id="feed">
|
||||
<div class="empty" id="feed-empty">Ожидание вебхуков…</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Заявки ── -->
|
||||
<section class="tab" id="tab-tasks">
|
||||
<div class="tab-head">
|
||||
<h2>Заявки</h2>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-primary" onclick="loadActive()">Загрузить активные</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-body">
|
||||
<div class="form-row" style="margin-bottom:16px">
|
||||
<input type="number" id="search-num" placeholder="Номер заявки" onkeydown="if(event.key==='Enter')searchTask()">
|
||||
<button class="btn btn-primary" onclick="searchTask()" style="width:auto;flex-shrink:0">Найти</button>
|
||||
</div>
|
||||
<div id="tasks-result">
|
||||
<div class="empty">Введите номер заявки или загрузите активные</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── API ── -->
|
||||
<section class="tab" id="tab-api">
|
||||
<div class="tab-head">
|
||||
<h2>API — изменение заявки</h2>
|
||||
</div>
|
||||
<div class="tab-body">
|
||||
<div class="api-layout">
|
||||
|
||||
<!-- Форма -->
|
||||
<div class="panel">
|
||||
<div class="panel-title">Параметры</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Номер заявки</label>
|
||||
<input type="number" id="api-num" placeholder="272">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Действие</label>
|
||||
<select id="api-action" onchange="renderActionFields()">
|
||||
<option value="status">Сменить статус</option>
|
||||
<option value="comment">Добавить комментарий</option>
|
||||
<option value="executor">Назначить исполнителя</option>
|
||||
<option value="priority">Изменить приоритет</option>
|
||||
<option value="resolution_date">Плановый срок</option>
|
||||
<option value="tags">Теги (через запятую)</option>
|
||||
<option value="custom">Произвольный блок JSON</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="action-fields"></div>
|
||||
|
||||
<button class="btn btn-primary" style="width:100%" onclick="previewRequest()">
|
||||
Предпросмотр запроса →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Предпросмотр + результат -->
|
||||
<div>
|
||||
<div class="panel" id="preview-panel" style="margin-bottom:16px">
|
||||
<div class="panel-title">Запрос будет отправлен</div>
|
||||
<div class="preview-url" id="preview-url">—</div>
|
||||
<pre id="preview-json">Нажмите «Предпросмотр запроса»</pre>
|
||||
<div class="btn-group" style="margin-top:14px">
|
||||
<button class="btn btn-danger" id="exec-btn" onclick="executeRequest()" disabled>
|
||||
✓ Выполнить
|
||||
</button>
|
||||
<button class="btn" onclick="resetPreview()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="api-result"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// GLOBALS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
let whCount = 0;
|
||||
let _pendingBlocks = null;
|
||||
|
||||
const STATUSES = {
|
||||
68046:'Открыта', 68048:'Требует уточнения',
|
||||
68051:'В работе', 68045:'Выполнена'
|
||||
};
|
||||
const PRIORITIES = {
|
||||
29749:'Наивысший', 29598:'Критический',
|
||||
29597:'Высокий', 29596:'Низкий'
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TABS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function switchTab(id, el) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
document.getElementById('tab-' + id).classList.add('active');
|
||||
el.classList.add('active');
|
||||
if (id === 'webhooks') {
|
||||
whCount = 0;
|
||||
const c = document.getElementById('wh-counter');
|
||||
c.textContent = '0'; c.classList.remove('show');
|
||||
}
|
||||
if (id === 'api') renderActionFields();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SSE — вебхуки в реальном времени
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const es = new EventSource('/stream');
|
||||
es.onopen = () => setStatus(true);
|
||||
es.onerror = () => setStatus(false);
|
||||
es.onmessage = e => {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.type === 'ping') return;
|
||||
if (d.type === 'webhook') addWebhookCard(d);
|
||||
};
|
||||
|
||||
function setStatus(on) {
|
||||
document.getElementById('dot').className = 'dot ' + (on ? 'on' : 'off');
|
||||
document.getElementById('dot-label').textContent = on ? 'Подключено' : 'Нет связи';
|
||||
}
|
||||
|
||||
function addWebhookCard(d) {
|
||||
const feed = document.getElementById('feed');
|
||||
document.getElementById('feed-empty')?.remove();
|
||||
|
||||
const isNew = d.operation === 'Created';
|
||||
const cls = isNew ? 'new' : 'updated';
|
||||
const badge = isNew
|
||||
? '<span class="badge badge-new">Новая</span>'
|
||||
: '<span class="badge badge-upd">Обновлена</span>';
|
||||
|
||||
const changes = (d.parsed?.changes || []);
|
||||
const comments = (d.parsed?.comments || []);
|
||||
|
||||
// Секция: Изменения
|
||||
let changesHtml = '';
|
||||
if (changes.length || comments.length) {
|
||||
const items = [
|
||||
...changes.map(c => `<div class="change-item">${esc(c)}</div>`),
|
||||
...comments.map(c => `<div class="comment-item">${esc(c)}</div>`),
|
||||
].join('');
|
||||
changesHtml = makeSec('📝 Изменения / комментарии', items, true);
|
||||
}
|
||||
|
||||
// Секция: Форматированное сообщение
|
||||
const fmtHtml = makeSec('💬 Сообщение в канале',
|
||||
`<div class="formatted-msg">${esc(d.formatted || '')}</div>`);
|
||||
|
||||
// Секция: Сырые данные
|
||||
const rawHtml = makeSec('{ } Сырые данные',
|
||||
`<pre>${esc(JSON.stringify(d.raw, null, 2))}</pre>`);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = `wcard ${cls}`;
|
||||
card.innerHTML = `
|
||||
<div class="wcard-head" onclick="toggleCard(this)">
|
||||
${badge}
|
||||
<span class="wcard-num">#${esc(d.task_number)}</span>
|
||||
<span class="wcard-name">${esc(getTaskName(d.raw))}</span>
|
||||
<span class="wcard-time">${esc(d.timestamp)}</span>
|
||||
<span class="chevron">▼</span>
|
||||
</div>
|
||||
<div class="wcard-body">
|
||||
${changesHtml}
|
||||
${fmtHtml}
|
||||
${rawHtml}
|
||||
</div>`;
|
||||
|
||||
feed.insertBefore(card, feed.firstChild);
|
||||
|
||||
// Счётчик на вкладке
|
||||
if (!document.querySelector('.nav-item[data-tab="webhooks"]').classList.contains('active')) {
|
||||
whCount++;
|
||||
const c = document.getElementById('wh-counter');
|
||||
c.textContent = whCount; c.classList.add('show');
|
||||
}
|
||||
}
|
||||
|
||||
function makeSec(title, body, openByDefault = false) {
|
||||
const cls = openByDefault ? 'wsec open' : 'wsec';
|
||||
return `
|
||||
<div class="${cls}">
|
||||
<div class="wsec-head" onclick="toggleSec(this)">
|
||||
<span class="wsec-chevron">▶</span> ${title}
|
||||
</div>
|
||||
<div class="wsec-body">${body}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleCard(head) {
|
||||
head.closest('.wcard').classList.toggle('open');
|
||||
}
|
||||
function toggleSec(head) {
|
||||
head.closest('.wsec').classList.toggle('open');
|
||||
}
|
||||
|
||||
function getTaskName(raw) {
|
||||
try {
|
||||
const n = raw.Fields?.name;
|
||||
if (typeof n === 'object') return n.Value || '';
|
||||
if (typeof n === 'string') return JSON.parse(n).Value || '';
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
|
||||
function clearFeed() {
|
||||
const feed = document.getElementById('feed');
|
||||
feed.innerHTML = '<div class="empty" id="feed-empty">Ожидание вебхуков…</div>';
|
||||
whCount = 0;
|
||||
const c = document.getElementById('wh-counter');
|
||||
c.textContent = '0'; c.classList.remove('show');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ЗАЯВКИ
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
async function searchTask() {
|
||||
const num = document.getElementById('search-num').value.trim();
|
||||
if (!num) return;
|
||||
const el = document.getElementById('tasks-result');
|
||||
el.innerHTML = '<div class="empty"><span class="spin"></span> Загрузка…</div>';
|
||||
try {
|
||||
const r = await fetch(`/api/tasks/search?number=${num}`);
|
||||
const data = await r.json();
|
||||
if (data.error) { el.innerHTML = `<div class="empty">${esc(data.error)}</div>`; return; }
|
||||
el.innerHTML = renderTaskCard(data);
|
||||
} catch(e) {
|
||||
el.innerHTML = `<div class="empty">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActive() {
|
||||
const el = document.getElementById('tasks-result');
|
||||
el.innerHTML = '<div class="empty"><span class="spin"></span> Загрузка активных заявок…</div>';
|
||||
try {
|
||||
const r = await fetch('/api/tasks/active');
|
||||
const tasks = await r.json();
|
||||
if (!tasks.length) { el.innerHTML = '<div class="empty">Активных заявок нет</div>'; return; }
|
||||
el.innerHTML = `<div style="margin-bottom:8px;color:var(--muted);font-size:12px">Найдено: ${tasks.length}</div>` +
|
||||
'<div class="tasks-list">' + tasks.map(t => renderTaskCard(t)).join('') + '</div>';
|
||||
} catch(e) {
|
||||
el.innerHTML = `<div class="empty">Ошибка: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTaskCard(t) {
|
||||
const statusId = t.status;
|
||||
const statusName= resolveId(STATUSES, statusId, 'statuses');
|
||||
const priId = t.priority;
|
||||
const priName = resolveId(PRIORITIES, priId, 'priorities');
|
||||
const sCls = statusCls(statusId);
|
||||
|
||||
const meta = [
|
||||
['Статус', `<span class="status-badge ${sCls}">${esc(statusName)}</span>`],
|
||||
['Приоритет', esc(priName)],
|
||||
['Исполнитель',esc(t.executorname || t.executor || '—')],
|
||||
['Группа', esc(t.executorgroupname || t.executorgroup || '—')],
|
||||
['Сервис', esc(t.servicename || t.service || '—')],
|
||||
['Тип', esc(t.tasktypename || t.tasktype || '—')],
|
||||
['Создана', esc(fmtDate(t.createdat))],
|
||||
['Обновлена', esc(fmtDate(t.updatedat))],
|
||||
['Создатель', esc(t.createdby || '—')],
|
||||
['Плановый срок', esc(fmtDate(t.resolutiondateplan))],
|
||||
].map(([l,v]) => `<div class="meta-item"><div class="meta-label">${l}</div><div class="meta-val">${v}</div></div>`).join('');
|
||||
|
||||
const desc = t.description
|
||||
? `<div class="task-desc">${esc(t.description)}</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="task-card">
|
||||
<div class="task-title">
|
||||
<span class="task-num">#${t.tasknumber}</span>
|
||||
${esc(t.name || '—')}
|
||||
</div>
|
||||
<div class="task-grid">${meta}</div>
|
||||
${desc}
|
||||
<div style="margin-top:10px">
|
||||
<button class="btn btn-sm" onclick="fillApiForm(${t.tasknumber})">⚙️ Изменить через API</button>
|
||||
<a href="https://cleverparking.intradesk.ru/task/list/${t.tasknumber}" target="_blank">
|
||||
<button class="btn btn-sm" style="margin-left:6px">🔗 Открыть в системе</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fillApiForm(num) {
|
||||
switchTab('api', document.querySelector('.nav-item[data-tab="api"]'));
|
||||
document.getElementById('api-num').value = num;
|
||||
}
|
||||
|
||||
function statusCls(id) {
|
||||
if (id === 68046) return 's-open';
|
||||
if (id === 68051) return 's-work';
|
||||
if (id === 68045) return 's-done';
|
||||
if (id === 68048) return 's-wait';
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveId(dict, id, entity) {
|
||||
if (dict[id]) return dict[id];
|
||||
return id ? String(id) : '—';
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—';
|
||||
try {
|
||||
return new Date(s).toLocaleString('ru-RU', {
|
||||
day:'2-digit', month:'2-digit', year:'numeric',
|
||||
hour:'2-digit', minute:'2-digit'
|
||||
});
|
||||
} catch { return s; }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// API — форма изменения заявки
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
async function renderActionFields() {
|
||||
const action = document.getElementById('api-action').value;
|
||||
const el = document.getElementById('action-fields');
|
||||
resetPreview();
|
||||
|
||||
if (action === 'status') {
|
||||
let opts = Object.entries(STATUSES).map(([id,n]) =>
|
||||
`<option value="${id}">${n}</option>`).join('');
|
||||
try {
|
||||
const r = await fetch('/api/ref/statuses');
|
||||
const items = await r.json();
|
||||
if (items.length) opts = items.map(i => `<option value="${i.id}">${esc(i.name)}</option>`).join('');
|
||||
} catch {}
|
||||
el.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Новый статус</label>
|
||||
<select id="f-status">${opts}</select>
|
||||
</div>`;
|
||||
}
|
||||
else if (action === 'comment') {
|
||||
el.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Текст комментария</label>
|
||||
<textarea id="f-comment" placeholder="Принято в работу…"></textarea>
|
||||
</div>`;
|
||||
}
|
||||
else if (action === 'executor') {
|
||||
el.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">ID группы (groupid)</label>
|
||||
<input type="number" id="f-groupid" placeholder="243025">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">ID сотрудника (userid, необязательно)</label>
|
||||
<input type="number" id="f-userid" placeholder="109747">
|
||||
</div>`;
|
||||
}
|
||||
else if (action === 'priority') {
|
||||
let opts = Object.entries(PRIORITIES).map(([id,n]) =>
|
||||
`<option value="${id}">${n}</option>`).join('');
|
||||
try {
|
||||
const r = await fetch('/api/ref/priorities');
|
||||
const items = await r.json();
|
||||
if (items.length) opts = items.map(i => `<option value="${i.id}">${esc(i.name)}</option>`).join('');
|
||||
} catch {}
|
||||
el.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Приоритет</label>
|
||||
<select id="f-priority">${opts}</select>
|
||||
</div>`;
|
||||
}
|
||||
else if (action === 'resolution_date') {
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() + 1);
|
||||
const iso = now.toISOString().slice(0,16);
|
||||
el.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Дата и время (локальное)</label>
|
||||
<input type="datetime-local" id="f-date" value="${iso}">
|
||||
</div>`;
|
||||
}
|
||||
else if (action === 'tags') {
|
||||
el.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">ID тегов через запятую</label>
|
||||
<input id="f-tags" placeholder="32887, 32893">
|
||||
</div>`;
|
||||
}
|
||||
else if (action === 'custom') {
|
||||
el.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Название блока</label>
|
||||
<input id="f-block-name" placeholder="status">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Значение (JSON)</label>
|
||||
<textarea id="f-block-val" placeholder='{"value": 68051}'></textarea>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildBlocks() {
|
||||
const action = document.getElementById('api-action').value;
|
||||
const jv = v => JSON.stringify({value: v});
|
||||
|
||||
if (action === 'status')
|
||||
return {status: jv(parseInt(document.getElementById('f-status').value))};
|
||||
if (action === 'comment')
|
||||
return {comment: jv(document.getElementById('f-comment').value)};
|
||||
if (action === 'executor') {
|
||||
const v = {};
|
||||
const gid = document.getElementById('f-groupid').value;
|
||||
const uid = document.getElementById('f-userid').value;
|
||||
if (gid) v.groupid = parseInt(gid);
|
||||
if (uid) v.userid = parseInt(uid);
|
||||
return {executor: jv(v)};
|
||||
}
|
||||
if (action === 'priority')
|
||||
return {priority: jv(parseInt(document.getElementById('f-priority').value))};
|
||||
if (action === 'resolution_date') {
|
||||
const dt = new Date(document.getElementById('f-date').value);
|
||||
return {resolutiondateplan: jv(dt.toISOString())};
|
||||
}
|
||||
if (action === 'tags') {
|
||||
const ids = document.getElementById('f-tags').value
|
||||
.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n));
|
||||
return {tags: jv(ids)};
|
||||
}
|
||||
if (action === 'custom') {
|
||||
const name = document.getElementById('f-block-name').value.trim();
|
||||
const val = document.getElementById('f-block-val').value.trim();
|
||||
if (!name) return null;
|
||||
return {[name]: val};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function previewRequest() {
|
||||
const num = parseInt(document.getElementById('api-num').value);
|
||||
if (!num) { showResult('error', 'Укажите номер заявки'); return; }
|
||||
|
||||
const blocks = buildBlocks();
|
||||
if (!blocks) { showResult('error', 'Заполните поля действия'); return; }
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/task/preview', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({number: num, blocks}),
|
||||
});
|
||||
const d = await r.json();
|
||||
document.getElementById('preview-url').textContent = `${d.method} ${d.url}`;
|
||||
document.getElementById('preview-json').textContent = JSON.stringify(d.body, null, 2);
|
||||
document.getElementById('exec-btn').disabled = false;
|
||||
document.getElementById('api-result').innerHTML = '';
|
||||
_pendingBlocks = blocks;
|
||||
} catch(e) {
|
||||
showResult('error', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeRequest() {
|
||||
if (!_pendingBlocks) return;
|
||||
const num = parseInt(document.getElementById('api-num').value);
|
||||
const btn = document.getElementById('exec-btn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spin"></span> Выполняется…';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/task/execute', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({number: num, blocks: _pendingBlocks}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
showResult('ok', `✓ Заявка #${num} успешно изменена`);
|
||||
} else {
|
||||
showResult('error', `Ошибка: ${d.error || 'неизвестная ошибка'}`);
|
||||
}
|
||||
} catch(e) {
|
||||
showResult('error', e.message);
|
||||
}
|
||||
|
||||
btn.innerHTML = '✓ Выполнить';
|
||||
btn.disabled = true;
|
||||
_pendingBlocks = null;
|
||||
}
|
||||
|
||||
function resetPreview() {
|
||||
document.getElementById('preview-url').textContent = '—';
|
||||
document.getElementById('preview-json').textContent = 'Нажмите «Предпросмотр запроса»';
|
||||
document.getElementById('exec-btn').disabled = true;
|
||||
document.getElementById('api-result').innerHTML = '';
|
||||
_pendingBlocks = null;
|
||||
}
|
||||
|
||||
function showResult(type, msg) {
|
||||
const cls = type === 'ok' ? 'result-ok' : type === 'error' ? 'result-err' : 'result-info';
|
||||
document.getElementById('api-result').innerHTML =
|
||||
`<div class="result-block ${cls}">${esc(msg)}</div>`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// UTILS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Инициализация формы API при загрузке
|
||||
renderActionFields();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue