- Описание
- Требования
- Установка и настройка
- Описание API EvaTeam
- Все сценарии использования (Use Cases)
- UC-1. Подсчёт общего количества задач проекта
- UC-2. Загрузка всех задач проекта (простая пагинация)
- UC-3. Загрузка всех задач проекта (запрос по диапазонам кодов)
- UC-4. Дедупликация задач после загрузки
- UC-5. Поиск задач БЕЗ определённого тега
- UC-6. Поиск задач С определённым тегом
- UC-7. Поиск задач по дате создания (диапазон дат)
- UC-8. Поиск задач по родительскому проекту
- UC-9. Поиск задач по названию спринта/списка
- UC-10. Классификация задач по платформам (iOS / Android / MBE)
- UC-11. Получение информации о текущем пользователе
- UC-12. Списание времени на конкретную задачу
- UC-13. Массовое равномерное распределение часов по задачам
- UC-14. Dry Run — предпросмотр изменений без применения
- UC-15. Генерация URL-ссылок на задачи
- UC-16. Экспорт результатов в текстовый файл
- UC-17. Сортировка задач по числовой части кода
- UC-18. Получение одной задачи по ID
- UC-19. Интерактивный ввод токена (fallback)
- UC-20. Каскадный поиск задач (несколько стратегий)
- Скрипты — подробное описание
- Структура проекта
- Примеры кода
- Возможные проблемы и решения
- Расширение функциональности
- Краткая шпаргалка
Набор Python-скриптов для автоматизации работы с задачами в системе EvaTeam через JSON-RPC API. Позволяет:
- Массово искать и фильтровать задачи проекта по тегам
- Выгружать списки задач в файлы с URL-ссылками
- Классифицировать задачи по платформам (iOS / Android / MBE)
- Автоматически списывать время на задачи с распределением по платформам
- Получать информацию о текущем пользователе
- Подсчитывать количество задач в проекте
- Выполнять аудит задач на наличие/отсутствие обязательных тегов
- Python 3.8+
- Доступ к корпоративной сети (или VPN) для подключения к серверу EvaTeam
- API-токен EvaTeam (см. раздел Получение API-токена)
pip install requests python-dotenv- Авторизоваться в EvaTeam по адресу вашего инстанса (например,
https://eva.your-company.ru) - Перейти в Настройки профиля → API-токены (или Профиль → Интеграции)
- Создать новый токен с правами на чтение/запись задач
- Скопировать сгенерированный токен
Внимание: Никогда не коммитьте токен в репозиторий. Храните его только в
.envфайле, который добавлен в.gitignore.
Создайте файл .env в директории со скриптами:
# ========================================
# НАСТРОЙКИ ПОДКЛЮЧЕНИЯ К EVATEAM
# ========================================
# URL вашего инстанса EvaTeam (без слэша в конце)
EVA_BASE=https://eva.your-company.ru
# <<< ВСТАВЬТЕ ВАШ API-ТОКЕН СЮДА >>>
EVA_TOKEN=your_api_token_hereГде взять значения:
| Переменная | Описание | Пример |
|---|---|---|
EVA_BASE |
URL-адрес вашего инстанса EvaTeam | https://eva.your-company.ru |
EVA_TOKEN |
Персональный API-токен (см. п.2) | eyJhbGciOiJIUzI1Ni... |
Расположение .env файла:
| Скрипт | Где ищет .env |
|---|---|
find_tasks_simple.py |
В той же директории, где лежит скрипт |
find_tasks_v2.py |
В той же директории, где лежит скрипт |
find_tasks_with_tag.py |
В той же директории, где лежит скрипт |
find_tasks_without_service_tag.py |
На 4 уровня выше (в корне проекта) |
add_time_tracking.py |
На 4 уровня выше (в корне проекта) |
Если
.envне найден в ожидаемом месте, скрипт выведет ошибку. Некоторые скрипты (find_tasks_without_service_tag.py) предложат ввести токен интерактивно.
EvaTeam использует JSON-RPC 2.2 протокол. Каждый запрос отправляется как POST на эндпоинт:
POST {EVA_BASE}/api/?m={method_name}
Тело запроса:
{
"jsonrpc": "2.2",
"method": "CmfTask.list",
"callid": "уникальный-uuid",
"kwargs": {
"filter": [["code", "LIKE", "MAPPS-%"]],
"fields": ["code", "name", "tags"],
"slice": [0, 100],
"order_by": ["code"]
}
}Поля запроса:
| Поле | Тип | Описание |
|---|---|---|
jsonrpc |
string | Версия протокола, всегда "2.2" |
method |
string | Название вызываемого метода API |
callid |
string | Уникальный UUID для идентификации запроса |
kwargs |
object | Именованные аргументы метода |
args |
array | Позиционные аргументы метода (используется в CmfTask.get) |
Ответ API:
{
"jsonrpc": "2.2",
"result": [ ... ],
"error": null
}result— данные ответа (массив задач, объект задачи и т.д.)error—nullпри успехе или объект с полемmessageпри ошибке
Все запросы требуют Bearer-токен в заголовке:
Authorization: Bearer <<ВАШ_EVA_TOKEN>>
Content-Type: application/json
Примечание: SSL-верификация отключена (
verify=False) для работы с корпоративными сертификатами. Если ваш инстанс EvaTeam использует публичный SSL-сертификат, можно включить верификацию для повышения безопасности.
| Метод | Описание | Тип аргументов | Параметры |
|---|---|---|---|
CmfTask.list |
Получить список задач | kwargs |
filter, fields, slice, order_by |
CmfTask.get |
Получить одну задачу по ID | args |
args=[task_id], kwargs.fields |
CmfTask.count |
Подсчитать количество задач | kwargs |
filter |
User.getCurrentUser |
Информация о текущем пользователе | kwargs |
{} (пустой) |
CmfTimeTrackerHistory.create |
Добавить запись о времени | kwargs |
task, time_spent, date, comment |
CmfTask.addTime |
Добавить время (альтернативный метод) | kwargs |
task, time_spent, date, comment |
CmfTask.logTime |
Логировать время (альтернативный метод) | kwargs |
task, time_spent, date, comment |
TimeTracker.create |
Создать запись трекера (альтернативный метод) | kwargs |
task, time_spent, date, comment |
TimeEntry.create |
Создать запись времени (альтернативный метод) | kwargs |
task, time_spent, date, comment |
Важно: Для списания времени доступно несколько методов API. Рабочий метод зависит от версии и конфигурации вашего инстанса EvaTeam. Скрипт
add_time_tracking.pyавтоматически перебирает все 5 методов и использует первый работающий.
Фильтры передаются как массив условий. Каждое условие — массив из трёх элементов: [поле, оператор, значение].
| Оператор | Описание | Пример |
|---|---|---|
= |
Точное совпадение | ["code", "=", "MAPPS-100"] |
LIKE |
Поиск по шаблону (% — любые символы) |
["code", "LIKE", "MAPPS-%"] |
>= |
Больше или равно | ["cmf_created_at", ">=", "2025-01-01"] |
<= |
Меньше или равно | ["cmf_created_at", "<=", "2025-12-31"] |
Комбинирование фильтров (логическое И):
filters = [
["code", "LIKE", "MAPPS-%"],
["cmf_created_at", ">=", "2025-12-01"],
["cmf_created_at", "<=", "2025-12-31"]
]Параметр slice принимает массив [offset, limit]:
[0, 100]— первые 100 записей[100, 100]— записи со 101 по 200[0, 1000]— попытка получить до 1000 записей за раз
Ограничение: API может возвращать максимум ~200-1000 записей за один запрос в зависимости от конфигурации сервера. Используйте пагинацию для получения всех данных.
| Поле | Тип | Описание |
|---|---|---|
code |
string | Код задачи (например, MAPPS-123) |
name |
string | Название задачи |
tags |
array | Список тегов (объекты с полями code, name) |
id |
string | Внутренний UUID задачи (формат CmfTask:UUID) |
cmf_created_at |
datetime | Дата создания задачи |
responsible |
object | Ответственный за задачу |
parent |
string | Код родительской задачи/проекта |
lists |
string | Название списка/спринта, к которому относится задача |
Теги могут приходить в двух форматах:
Формат 1 — объект:
{
"tags": [
{"code": "25-026178_сервисы", "name": "25-026178_сервисы"},
{"code": "ios", "name": "iOS"}
]
}Формат 2 — строка (в некоторых версиях API):
{
"tags": ["25-026178_сервисы", "ios"]
}Все скрипты обрабатывают оба формата через проверку
isinstance(tag, dict).
Цель: Узнать, сколько задач существует в проекте, до загрузки данных.
Метод API: CmfTask.count
total_count = call_api("CmfTask.count", {
"filter": [["code", "LIKE", "MAPPS-%"]],
})Применяется в: find_tasks_simple.py
Примечание: Результат может быть числом или списком (зависит от версии API). Скрипт обрабатывает оба случая:
if isinstance(total_count, list):
total_count = len(total_count)Цель: Загрузить все задачи проекта, обходя лимит API на количество записей.
Алгоритм:
- Запросить первую порцию (batch) задач с
slice=[0, batch_size] - Если получено
batch_sizeзаписей — есть ещё данные, увеличить offset - Повторять, пока не получим меньше
batch_sizeзаписей или пустой ответ - Защита от бесконечного цикла: лимит на максимальный offset
all_tasks = []
batch_size = 100
offset = 0
while True:
batch = call_api("CmfTask.list", {
"filter": [["code", "LIKE", "MAPPS-%"]],
"fields": ["code", "name", "tags"],
"slice": [offset, batch_size],
"order_by": ["-cmf_created_at"]
})
if not batch:
break
all_tasks.extend(batch)
if len(batch) < batch_size:
break
offset += batch_size
# Защита от бесконечного цикла
if offset > 100000:
breakПрименяется в: find_tasks_simple.py, find_tasks_without_service_tag.py
Цель: Обойти ограничение пагинации, когда API не возвращает все записи через обычный offset.
Алгоритм:
- Попытка загрузить одним запросом с большим лимитом
slice=[0, 1000] - Если получено меньше ожидаемого — переключиться на стратегию по диапазонам
- Разбить задачи по диапазонам номеров:
MAPPS-1..200,MAPPS-200..400и т.д. - Запросить каждый диапазон отдельно
- Объединить и дедуплицировать результаты
# Стратегия 1: один запрос
batch = call_api("CmfTask.list", {
"filter": [["code", "LIKE", "MAPPS-%"]],
"fields": ["code", "name", "tags"],
"slice": [0, 1000],
"order_by": ["code"]
})
# Стратегия 2 (fallback): по диапазонам
if len(batch) < expected_total:
ranges = [(1, 200), (200, 400), (400, 600), (600, 800), (800, 1000), (1000, 1200)]
for start, end in ranges:
range_batch = call_api("CmfTask.list", {
"filter": [
["code", ">=", f"MAPPS-{start}"],
["code", "<=", f"MAPPS-{end}"]
],
"fields": ["code", "name", "tags"],
"slice": [0, 200]
})
all_tasks.extend(range_batch)Применяется в: find_tasks_v2.py
Когда использовать: Если обычная пагинация (UC-2) возвращает неполные данные.
Цель: Убрать дубликаты задач, которые могут появиться при загрузке по перекрывающимся диапазонам.
seen_codes = set()
unique_tasks = []
for task in all_tasks:
code = task.get('code')
if code not in seen_codes:
seen_codes.add(code)
unique_tasks.append(task)
all_tasks = unique_tasksПрименяется в: find_tasks_v2.py
Цель: Найти все задачи проекта, у которых отсутствует указанный тег. Используется для аудита — убедиться, что все задачи помечены обязательным тегом.
Алгоритм:
- Загрузить все задачи проекта (UC-2 или UC-3)
- Для каждой задачи проверить наличие тега (по полю
codeилиname) - Собрать задачи, где тег не найден
service_tag = "25-026178_сервисы" # <<< Замените на нужный код тега
tasks_without_tag = []
for task in all_tasks:
tags = task.get('tags', [])
has_tag = False
for tag in tags:
if isinstance(tag, dict):
tag_code = tag.get('code')
tag_name = tag.get('name')
if tag_code == service_tag or tag_name == service_tag:
has_tag = True
break
else:
if str(tag) == service_tag:
has_tag = True
break
if not has_tag:
tasks_without_tag.append(task)Применяется в: find_tasks_simple.py, find_tasks_v2.py, find_tasks_without_service_tag.py
Цель: Найти все задачи проекта, которые содержат указанный тег. Используется для инвентаризации — получить список задач определённой категории.
Алгоритм: Аналогичен UC-5, но собираются задачи, где тег найден.
tasks_with_tag = []
for task in all_tasks:
tags = task.get('tags', [])
has_tag = False
for tag in tags:
if isinstance(tag, dict):
tag_code = tag.get('code')
tag_name = tag.get('name')
if tag_code == service_tag or tag_name == service_tag:
has_tag = True
break
else:
if str(tag) == service_tag:
has_tag = True
break
if has_tag:
tasks_with_tag.append(task)Применяется в: find_tasks_with_tag.py
Цель: Получить задачи, созданные в определённый период (например, за конкретный месяц).
tasks = call_api("CmfTask.list", {
"filter": [
["code", "LIKE", "MAPPS-%"],
["cmf_created_at", ">=", "2025-12-01"], # <<< Начало периода
["cmf_created_at", "<=", "2025-12-31"] # <<< Конец периода
],
"fields": ["code", "name", "tags", "id", "cmf_created_at"],
"slice": [0, 500]
})Применяется в: add_time_tracking.py (функция get_december_tasks)
Цель: Получить задачи, принадлежащие определённому проекту по его коду (ID родительского объекта).
tasks = call_api("CmfTask.list", {
"filter": [
["parent", "=", "KBB-000251"], # <<< Код проекта
["cmf_created_at", ">=", "2025-12-01"],
["cmf_created_at", "<=", "2025-12-31"]
],
"fields": ["code", "name", "tags", "id", "cmf_created_at"],
"slice": [0, 500]
})Применяется в: add_time_tracking.py (стратегия 1 внутри get_december_tasks)
Цель: Получить задачи из определённого спринта или списка по его имени.
tasks = call_api("CmfTask.list", {
"filter": [
["lists", "LIKE", "%Sprint 2%"], # <<< Имя спринта
["cmf_created_at", ">=", "2025-12-01"],
["cmf_created_at", "<=", "2025-12-31"]
],
"fields": ["code", "name", "tags", "id", "cmf_created_at"],
"slice": [0, 500]
})Применяется в: add_time_tracking.py (стратегия 2 внутри get_december_tasks)
Цель: Разделить задачи на группы по целевой платформе на основе тегов.
Алгоритм:
- Для каждой задачи извлечь все теги
- Привести названия тегов к нижнему регистру
- Проверить наличие ключевых слов:
ios,android,mbe,backend - Распределить в соответствующие группы
ios_tasks = []
android_tasks = []
mbe_tasks = []
other_tasks = []
for task in all_tasks:
task_tags = task.get("tags", [])
tag_names = []
for tag in task_tags:
if isinstance(tag, dict):
tag_names.append(tag.get("name", "").lower())
else:
tag_names.append(str(tag).lower())
is_ios = any("ios" in t for t in tag_names)
is_android = any("android" in t for t in tag_names)
is_mbe = any("mbe" in t or "backend" in t for t in tag_names)
if is_ios:
ios_tasks.append(task)
elif is_android:
android_tasks.append(task)
elif is_mbe:
mbe_tasks.append(task)
else:
other_tasks.append(task)Применяется в: add_time_tracking.py (функция get_december_tasks)
Настройка: Для добавления новых платформ — добавьте новые условия any(...) и списки.
Цель: Проверить, что токен валиден, и узнать, от чьего имени будут выполняться действия.
result = call_api("User.getCurrentUser", kwargs={})
user_name = result.get("name", "Неизвестно")
print(f"Пользователь: {user_name}")Применяется в: add_time_tracking.py (начало main())
Цель: Добавить запись о затраченном времени к одной задаче.
Особенность: API может поддерживать разные методы для записи времени в зависимости от версии. Скрипт последовательно пробует 5 методов:
methods_to_try = [
"CmfTimeTrackerHistory.create",
"CmfTask.addTime",
"CmfTask.logTime",
"TimeTracker.create",
"TimeEntry.create"
]
kwargs = {
"task": task_id, # ID задачи (UUID)
"time_spent": 2.5, # Часы (float)
"date": "2025-12-15", # Дата в формате YYYY-MM-DD
"comment": "Описание" # Комментарий (опционально)
}
for method in methods_to_try:
try:
result = call_api(method, kwargs=kwargs)
break # Метод сработал
except Exception:
continue # Пробуем следующийПрименяется в: add_time_tracking.py (метод add_time_to_task)
Цель: Распределить общее количество часов (например, 176 за месяц) равномерно между задачами, сгруппированными по платформам.
Алгоритм:
- Общее количество часов делится поровну между платформами:
часов_на_платформу = total / 3 - Часы каждой платформы делятся поровну между задачами:
часов_на_задачу = часов_на_платформу / количество_задач - Для каждой задачи вызывается UC-12
Пример: 176 часов, 3 платформы
├── iOS (20 задач): 58.67 часов → 2.93 часа на задачу
├── Android (15 задач): 58.67 часов → 3.91 часа на задачу
└── MBE (10 задач): 58.67 часов → 5.87 часа на задачу
Применяется в: add_time_tracking.py (функция distribute_hours)
Настройка:
# Изменить количество часов:
distribute_hours(176, tasks_by_platform, dry_run=True) # <<< 176 → ваше значение
# Изменить список платформ:
platforms = ["ios", "android", "mbe"] # <<< Добавьте или уберите платформыЦель: Показать, какие изменения будут выполнены, без фактической записи данных в EvaTeam.
Алгоритм:
- По умолчанию скрипт запускается в режиме dry run
- Выполняется вся логика: загрузка задач, распределение часов, вывод отчёта
- Но вызовы API на запись времени не выполняются
- Для фактического применения — передать флаг
--apply
# Предпросмотр (безопасно):
python add_time_tracking.py
# Применить:
python add_time_tracking.py --applyПрименяется в: add_time_tracking.py
Цель: Сформировать прямые ссылки на задачи в веб-интерфейсе EvaTeam.
Формат URL:
{EVA_BASE}/tasks/{TASK_CODE}
task_code = task.get('code') # например, "MAPPS-14"
url = f"{BASE_URL}/tasks/{task_code}"
# Результат: https://eva.your-company.ru/tasks/MAPPS-14Применяется в: find_tasks_with_tag.py, find_tasks_without_service_tag.py
Цель: Сохранить результаты поиска в файл для дальнейшего использования, отчётности или передачи коллегам.
Формат файла (задачи с тегом):
Задачи С тегом '25-026178_сервисы'
Всего: 32
1. MAPPS-14: iOS. Favorites. Добавить в кнопке новый action для emptyPage
https://eva.your-company.ru/tasks/MAPPS-14
2. MAPPS-56: iOS. Live-Activities. Корректировки в моделях
https://eva.your-company.ru/tasks/MAPPS-56
Формат файла (задачи без тега — расширенный):
Задачи без тега '25-026178_сервисы'
Дата: 2025-12-15 14:30:00
Всего: 330 задач
======================================================================
1. MAPPS-1
Название: iOS. R&D Фикса ошибок Яндекс Карт
Теги: ios, bugs
URL: https://eva.your-company.ru/tasks/MAPPS-1
Применяется в: Все скрипты поиска задач
Выходные файлы:
tasks_with_service_tag.txt— задачи с тегомtasks_without_service_tag.txt— задачи без тега
Цель: Отсортировать задачи по номеру (MAPPS-1, MAPPS-2, ..., MAPPS-1000) вместо лексикографической сортировки.
tasks.sort(key=lambda x: int(x['code'].split('-')[1]) if '-' in x['code'] else 0)Применяется в: find_tasks_v2.py, find_tasks_with_tag.py
Цель: Получить детальную информацию об одной конкретной задаче.
task = call_api("CmfTask.get", args=["MAPPS-123"], kwargs={
"fields": ["code", "name", "tags", "id", "responsible"]
})Метод API: CmfTask.get с позиционным аргументом args=[task_id]
Применяется в: add_time_tracking.py (метод get_task класса EvaTeamClient)
Цель: Если токен не найден в .env, предложить пользователю ввести его вручную в консоли.
if not self.token:
print("API токен не найден в переменных окружения.")
print("Введите токен вручную (или нажмите Ctrl+C для выхода):")
self.token = input("EVA_TOKEN: ").strip()
if not self.token:
raise ValueError("API token не может быть пустым")Применяется в: find_tasks_without_service_tag.py (класс EvaTeamClient)
Цель: Если один способ поиска не дал результатов, автоматически попробовать альтернативные.
Алгоритм (из add_time_tracking.py):
- Стратегия 1: Поиск по коду родительского проекта (
parent = "KBB-000251") - Стратегия 2 (fallback): Поиск по имени спринта (
lists LIKE "%Sprint 2%") - Стратегия 3 (fallback): Поиск по префиксу кода (
code LIKE "MAPPS-%")
all_tasks = []
# Стратегия 1
try:
tasks = client.get_tasks(filters=[["parent", "=", project_code], ...])
all_tasks.extend(tasks)
except Exception:
pass
# Стратегия 2 (если стратегия 1 не дала результатов)
if not all_tasks:
try:
tasks = client.get_tasks(filters=[["lists", "LIKE", "%Sprint 2%"], ...])
all_tasks.extend(tasks)
except Exception:
pass
# Стратегия 3 (если стратегии 1 и 2 не дали результатов)
if not all_tasks:
try:
tasks = client.get_tasks(filters=[["code", "LIKE", "MAPPS-%"], ...])
all_tasks.extend(tasks)
except Exception:
passПрименяется в: add_time_tracking.py (функция get_december_tasks)
Назначение: Поиск всех задач проекта без указанного тега (базовая версия).
Запуск:
python find_tasks_simple.pyАлгоритм:
- Подсчитывает общее количество задач через
CmfTask.count(UC-1) - Загружает все задачи порциями по 100 с пагинацией по offset (UC-2)
- Защита от бесконечного цикла: лимит
offset > 100000 - Фильтрует задачи без целевого тега (UC-5)
- Выводит список в консоль
- Сохраняет в
tasks_without_service_tag.txt(UC-16)
Что настроить:
service_tag = "25-026178_сервисы" # <<< Код тега
["code", "LIKE", "MAPPS-%"] # <<< Префикс проектаГде ищет .env: в директории скрипта
Назначение: Улучшенная версия поиска задач без тега с двумя стратегиями загрузки и дедупликацией.
Запуск:
python find_tasks_v2.pyАлгоритм:
- Стратегия 1: Один запрос с
slice=[0, 1000](UC-3, попытка 1) - Стратегия 2 (fallback): Если загружено менее 330 задач — переключение на запрос по диапазонам кодов:
MAPPS-1..200,MAPPS-200..400, ...,MAPPS-1000..1200(UC-3, попытка 2) - Дедупликация по коду задачи (UC-4)
- Фильтрация без тега (UC-5)
- Сортировка по числовой части кода (UC-17)
- Сохранение в файл (UC-16)
Что настроить:
service_tag = "25-026178_сервисы" # <<< Код тега
["code", "LIKE", "MAPPS-%"] # <<< Префикс проекта
if len(all_tasks) < 330: # <<< Порог для fallback
ranges = [(1, 200), (200, 400), ...] # <<< Диапазоны кодовГде ищет .env: в директории скрипта
Назначение: Поиск всех задач проекта С указанным тегом. Генерирует список с URL-ссылками.
Запуск:
python find_tasks_with_tag.pyАлгоритм:
- Загружает все задачи одним запросом
slice=[0, 1000](UC-3) - Фильтрует задачи с целевым тегом (UC-6)
- Сортирует по числовой части кода (UC-17)
- Генерирует URL-ссылки для каждой задачи (UC-15)
- Выводит список с URL в консоль
- Сохраняет в
tasks_with_service_tag.txt(UC-16)
Формат вывода:
1. MAPPS-14: iOS. Favorites. Добавить в кнопке новый action для emptyPage
🔗 https://eva.your-company.ru/tasks/MAPPS-14
Что настроить:
service_tag = "25-026178_сервисы" # <<< Код тега
["code", "LIKE", "MAPPS-%"] # <<< Префикс проектаГде ищет .env: в директории скрипта
Назначение: Полная OOP-версия поиска задач без тега с классом EvaTeamClient, подробным выводом и интерактивным вводом токена.
Запуск:
python find_tasks_without_service_tag.pyАлгоритм:
- Инициализация клиента
EvaTeamClient— если токен не найден, запрашивает интерактивно (UC-19) - Загрузка всех задач порциями по 100 (до 10000 задач) (UC-2)
- Фильтрация без тега с проверкой по
codeиname(UC-5) - Подробный вывод: код задачи, название, список текущих тегов
- Генерация URL в файле (UC-15)
- Сохранение с меткой времени (UC-16)
Отличия от других версий:
- OOP-архитектура (класс
EvaTeamClient) - Интерактивный ввод токена как fallback
- Расширенные поля:
responsible,cmf_created_at - Подробный вывод текущих тегов задачи (для понимания, какие теги уже есть)
- Дата и время генерации в выходном файле
- URL-ссылки в выходном файле
Что настроить:
service_tag = "25-026178_сервисы" # <<< Код тега
["code", "LIKE", "MAPPS-%"] # <<< Префикс проектаГде ищет .env: на 4 уровня выше от скрипта (в корне проекта)
Назначение: Автоматическое распределение и списание рабочих часов по задачам проекта с разбивкой по платформам.
Запуск:
# Предпросмотр (dry run) — изменения НЕ применяются
python add_time_tracking.py
# Применить списание часов
python add_time_tracking.py --applyПолный алгоритм:
- Проверка наличия
EVA_TOKEN— если нет, вывод инструкции по настройке - Получение информации о текущем пользователе (UC-11)
- Каскадный поиск задач проекта за указанный период (UC-20):
- Стратегия 1: по коду родительского проекта (UC-8)
- Стратегия 2: по имени спринта (UC-9)
- Стратегия 3: по префиксу кода задач (UC-7)
- Классификация задач по платформам на основе тегов (UC-10)
- Равномерное распределение часов (UC-13):
- Общие часы делятся поровну между платформами
- Часы каждой платформы делятся поровну между задачами
- Dry Run (UC-14): показывает, что будет сделано
- Apply (при флаге
--apply): выполняет списание через API с fallback по методам (UC-12) - Итоговая статистика: обработано / ошибки / часы по каждой платформе
Что настроить:
project_code = "KBB-000251" # <<< Код проекта
["cmf_created_at", ">=", "2025-12-01"] # <<< Начало периода
["cmf_created_at", "<=", "2025-12-31"] # <<< Конец периода
distribute_hours(176, ...) # <<< Количество часов
platforms = ["ios", "android", "mbe"] # <<< Список платформ
comment = "Списание времени Sprint 2 weeks" # <<< Комментарий к записиГде ищет .env: на 4 уровня выше от скрипта (в корне проекта)
eva-team/
├── .env # Файл с ключами (НЕ коммитить!)
│ # Содержит EVA_BASE и EVA_TOKEN
├── README.md # Данная инструкция
│
├── find_tasks_simple.py # UC-1, UC-2, UC-5, UC-16
│ # Простой поиск задач без тега
│ # Пагинация по offset, count
│
├── find_tasks_v2.py # UC-3, UC-4, UC-5, UC-16, UC-17
│ # Улучшенный поиск с дедупликацией
│ # Две стратегии загрузки
│
├── find_tasks_with_tag.py # UC-3, UC-6, UC-15, UC-16, UC-17
│ # Поиск задач с тегом + URL-ссылки
│
├── find_tasks_without_service_tag.py # UC-2, UC-5, UC-15, UC-16, UC-18, UC-19
│ # Полная OOP-версия поиска без тега
│ # Интерактивный ввод токена
│
├── add_time_tracking.py # UC-7..UC-14, UC-20
│ # Массовое списание времени
│ # Каскадный поиск + платформы
│ # Dry run + apply
│
├── tasks_with_service_tag.txt # Выходной файл: задачи С тегом
└── tasks_without_service_tag.txt # Выходной файл: задачи БЕЗ тега
Минимальный код для работы с API EvaTeam (функциональный стиль):
import requests
import uuid
import os
from dotenv import load_dotenv
load_dotenv() # загружает .env
BASE_URL = os.getenv('EVA_BASE') # <<< Из .env
TOKEN = os.getenv('EVA_TOKEN') # <<< Из .env
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {TOKEN}", # <<< Токен из .env
"Content-Type": "application/json"
})
def call_api(method, kwargs=None, args=None):
payload = {
"jsonrpc": "2.2",
"method": method,
"callid": str(uuid.uuid4()),
}
if kwargs:
payload["kwargs"] = kwargs
if args:
payload["args"] = args
response = session.post(
f"{BASE_URL}/api/?m={method}",
json=payload,
verify=False # для корпоративных сертификатов
)
result = response.json()
if result.get("error"):
raise Exception(f"API Error: {result['error']}")
return result.get("result", [])Полнофункциональный клиент с обработкой ошибок и fallback-ом для токена:
import requests
import uuid
import os
from typing import List, Dict, Optional
from dotenv import load_dotenv
load_dotenv()
class EvaTeamClient:
def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None):
self.base_url = base_url or os.getenv('EVA_BASE') # <<< Из .env
self.token = token or os.getenv('EVA_TOKEN') # <<< Из .env
if not self.token:
# Fallback: интерактивный ввод
print("API токен не найден. Введите токен вручную:")
self.token = input("EVA_TOKEN: ").strip()
if not self.token:
raise ValueError("API token не может быть пустым")
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
})
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def call_method(self, method: str, kwargs: Optional[Dict] = None,
args: Optional[List] = None) -> Dict:
payload = {
"jsonrpc": "2.2",
"method": method,
"callid": str(uuid.uuid4()),
}
if kwargs:
payload["kwargs"] = kwargs
if args:
payload["args"] = args
response = self.session.post(
f"{self.base_url}/api/?m={method}",
json=payload,
verify=False
)
if response.status_code != 200:
raise Exception(f"API Error: {response.status_code} - {response.text}")
result = response.json()
if result.get("error"):
raise Exception(f"API Error: {result['error'].get('message', result['error'])}")
return result
def get_tasks(self, filters=None, fields=None, slice_params=None, order_by=None):
kwargs = {}
if filters:
kwargs["filter"] = filters
if fields:
kwargs["fields"] = fields
if slice_params:
kwargs["slice"] = slice_params
if order_by:
kwargs["order_by"] = order_by
result = self.call_method("CmfTask.list", kwargs=kwargs)
return result.get("result", [])
def get_task(self, task_id: str, fields=None):
kwargs = {"fields": fields} if fields else {}
result = self.call_method("CmfTask.get", args=[task_id], kwargs=kwargs)
return result.get("result", {})
def get_user_info(self):
result = self.call_method("User.getCurrentUser", kwargs={})
return result.get("result", {})Универсальная функция, обрабатывающая оба формата тегов (объект и строка):
def filter_tasks_by_tag(tasks, tag_code, has_tag=True):
"""
Фильтрация задач по наличию/отсутствию тега.
Args:
tasks: список задач из API
tag_code: код тега для проверки
has_tag: True — задачи С тегом, False — БЕЗ тега
"""
result = []
for task in tasks:
tags = task.get('tags', [])
found = False
for tag in tags:
if isinstance(tag, dict):
# Формат объекта: {"code": "...", "name": "..."}
tag_val_code = tag.get('code')
tag_val_name = tag.get('name')
if tag_val_code == tag_code or tag_val_name == tag_code:
found = True
break
else:
# Формат строки: "tag_code"
if str(tag) == tag_code:
found = True
break
if found == has_tag:
result.append(task)
return resultdef load_all_tasks(project_prefix="MAPPS", batch_size=100, max_offset=100000):
"""Загрузить все задачи проекта с пагинацией и защитой"""
all_tasks = []
offset = 0
while offset < max_offset:
batch = call_api("CmfTask.list", {
"filter": [["code", "LIKE", f"{project_prefix}-%"]],
"fields": ["code", "name", "tags"],
"slice": [offset, batch_size],
"order_by": ["code"]
})
if not batch:
break
all_tasks.extend(batch)
print(f" Загружено: {len(all_tasks)}")
if len(batch) < batch_size:
break
offset += batch_size
return all_tasksАльтернативный метод загрузки, когда стандартная пагинация даёт неполные данные:
def load_tasks_by_ranges(project_prefix="MAPPS", range_step=200, max_code=1200):
"""Загрузить задачи по диапазонам кодов"""
all_tasks = []
ranges = [(i, i + range_step) for i in range(1, max_code, range_step)]
for start, end in ranges:
batch = call_api("CmfTask.list", {
"filter": [
["code", ">=", f"{project_prefix}-{start}"],
["code", "<=", f"{project_prefix}-{end}"]
],
"fields": ["code", "name", "tags"],
"slice": [0, range_step]
})
if batch:
all_tasks.extend(batch)
print(f" Диапазон {project_prefix}-{start}..{end}: +{len(batch)}")
# Дедупликация
seen = set()
unique = []
for task in all_tasks:
code = task.get('code')
if code not in seen:
seen.add(code)
unique.append(task)
return uniquedef classify_by_platform(tasks):
"""Разделить задачи по платформам на основе тегов"""
result = {"ios": [], "android": [], "mbe": [], "other": []}
for task in tasks:
tag_names = []
for tag in task.get("tags", []):
if isinstance(tag, dict):
tag_names.append(tag.get("name", "").lower())
else:
tag_names.append(str(tag).lower())
if any("ios" in t for t in tag_names):
result["ios"].append(task)
elif any("android" in t for t in tag_names):
result["android"].append(task)
elif any("mbe" in t or "backend" in t for t in tag_names):
result["mbe"].append(task)
else:
result["other"].append(task)
return resultdef add_time_to_task(task_id, hours, date=None, comment=None):
"""
Добавить время к задаче, перебирая доступные методы API.
Разные версии EvaTeam поддерживают разные методы.
"""
from datetime import datetime
methods = [
"CmfTimeTrackerHistory.create",
"CmfTask.addTime",
"CmfTask.logTime",
"TimeTracker.create",
"TimeEntry.create"
]
kwargs = {
"task": task_id,
"time_spent": hours,
"date": date or datetime.now().strftime("%Y-%m-%d"),
}
if comment:
kwargs["comment"] = comment
for method in methods:
try:
return call_api(method, kwargs=kwargs)
except Exception:
continue
raise Exception("Ни один метод API для записи времени не сработал")| Проблема | Причина | Решение |
|---|---|---|
Токен не найден |
Нет .env файла или переменной EVA_TOKEN |
Создайте .env по инструкции. Проверьте расположение (у разных скриптов разные пути) |
SSL: CERTIFICATE_VERIFY_FAILED |
Корпоративный SSL-сертификат | Убедитесь, что в коде verify=False. При необходимости установите корневой сертификат |
API Error: 401 |
Невалидный или просроченный токен | Сгенерируйте новый токен в настройках EvaTeam |
API Error: 403 |
Недостаточно прав | Запросите права на чтение/запись задач у администратора |
| Загружается мало задач | Ограничение пагинации API | Используйте find_tasks_v2.py (загрузка по диапазонам кодов) |
| Дубликаты в результатах | Перекрывающиеся диапазоны при загрузке | Добавьте дедупликацию (UC-4) |
| Кодировка в Windows | Некорректное отображение кириллицы | Скрипты содержат фикс: io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
| Не удалось списать время | Метод API не поддерживается | Скрипт add_time_tracking.py автоматически пробует 5 методов (UC-12) |
| Нет задач в результатах каскадного поиска | Неверный код проекта, даты или имя спринта | Проверьте параметры в get_december_tasks(). Попробуйте другие стратегии поиска |
CmfTask.count возвращает список вместо числа |
Разные версии API | Обрабатывайте оба случая: if isinstance(result, list): count = len(result) |
ConnectionError |
Нет доступа к серверу EvaTeam | Проверьте VPN/корпоративную сеть и EVA_BASE в .env |
JSONDecodeError |
Сервер вернул не-JSON ответ | Проверьте EVA_BASE (должен быть без слэша в конце), доступность сервера |
Шаблон для создания нового скрипта:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Описание скрипта"""
import sys, io
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
import requests, uuid, os
from pathlib import Path
from dotenv import load_dotenv
# Загрузка переменных окружения
load_dotenv(Path(__file__).resolve().parent / '.env')
BASE_URL = os.getenv('EVA_BASE') # <<< Берётся из .env
TOKEN = os.getenv('EVA_TOKEN') # <<< Берётся из .env
if not TOKEN:
print("Токен не найден! Установите EVA_TOKEN в файле .env")
exit(1)
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json"
})
def call_api(method, kwargs=None):
payload = {
"jsonrpc": "2.2",
"method": method,
"callid": str(uuid.uuid4()),
}
if kwargs:
payload["kwargs"] = kwargs
response = session.post(f"{BASE_URL}/api/?m={method}", json=payload, verify=False)
result = response.json()
if result.get("error"):
print(f"API Error: {result['error']}")
return []
return result.get("result", [])
# ========================================
# ВАШ КОД ЗДЕСЬ
# ========================================
if __name__ == "__main__":
pass- Массовое добавление тегов — используя метод обновления задачи через API
- Экспорт задач в Excel — подключить библиотеку
openpyxl - Отчёты по времени — агрегация списаний по платформам/исполнителям
- Интеграция с Telegram-ботом — уведомления о задачах без тегов
- Автоматический запуск по расписанию — через
cron(Linux/Mac) или Планировщик задач (Windows) - Сравнение списков — найти задачи, которые были/не были в предыдущем выгруженном списке
- Экспорт в CSV — для загрузки в Google Sheets / Excel
- Валидация тегов — проверить, что у каждой задачи есть хотя бы один тег из обязательного набора
# 1. Создать .env файл
cat > .env << 'EOF'
EVA_BASE=https://eva.your-company.ru
EVA_TOKEN=<<< ВСТАВЬТЕ ВАШ ТОКЕН СЮДА >>>
EOF
# 2. Установить зависимости
pip install requests python-dotenv
# 3. Найти задачи без тега (простая версия)
python find_tasks_simple.py
# 4. Найти задачи без тега (с дедупликацией и диапазонами)
python find_tasks_v2.py
# 5. Найти задачи без тега (полная OOP-версия с деталями)
python find_tasks_without_service_tag.py
# 6. Найти задачи с тегом + ссылки
python find_tasks_with_tag.py
# 7. Списать время — предпросмотр (безопасно, ничего не меняет)
python add_time_tracking.py
# 8. Списать время — применить изменения
python add_time_tracking.py --apply