Compare commits

...

95 Commits

Author SHA1 Message Date
49286d0fcf добавил систему миграций и связь одной записи Redmine с несколькими активностями Clockify и запретил отправку трудочасов при инициализации базы данных 2026-07-14 15:54:26 +03:00
5dca3ae446 добавил объединение повторных активностей Clockify 2026-07-14 15:23:02 +03:00
9352a84723 убрал служебный id Clockify из комментариев Redmine 2026-07-06 16:56:15 +03:00
a4a4178880 исправил кликабельность номера задачи в цветном выводе 2026-07-06 16:47:05 +03:00
eca2f6aa51 обновил вывод успешного переноса активности 2026-07-06 16:45:11 +03:00
c35b479a0b добавил человекочитаемый формат длительности трудозатрат 2026-07-06 16:41:32 +03:00
4da65657d8 сделал номера задач кликабельными в консоли 2026-07-06 16:35:59 +03:00
c94a31a5c1 округлил вывод трудозатрат до трех знаков 2026-07-06 16:32:42 +03:00
9cf4cc75f0 сократил формат логов в консоли 2026-07-06 16:30:21 +03:00
ba9eafbb46 добавил предпросмотр и защиту от повторной отправки трудочасов 2026-07-06 16:28:00 +03:00
0778cc23ae добавил пакетную установку и консольную команду запуска 2026-07-06 16:23:47 +03:00
baf6e2f3a4 разделил создание зависимостей и сценарий переноса трудочасов, переименовал основную точку входа в main.py 2026-07-06 16:18:55 +03:00
9a0ce15160 оптимизировал сохранение данных и загрузку задач из Redmine 2026-07-06 16:11:41 +03:00
64b347e966 повысил надежность обработки API-ответов и ошибок БД 2026-07-06 16:07:08 +03:00
bd20ef4d48 Добавил таймауты для API-запросов, уточнил парсинг тегов и типизацию аргументов 2026-07-06 16:01:24 +03:00
8d19e1d43f Слегка изменил отображение процесса занесения трудочасов 2026-07-01 16:25:12 +03:00
fa97f75fbe Merge branch 'dev' 2026-06-26 17:09:15 +03:00
4e2aea78fe Сохранять трудочасы в БД сразу после успешного Redmine
- убрать tqdm из занесения трудочасов в Redmine
- логировать успех и ошибку по каждой активности
- обновлять is_tracked и time_entry сразу после успешного занесения
- выводить в итоговом логе список незанесённых задач
2026-06-19 16:45:24 +03:00
3683925e14 Merge branch 'dev' 2026-06-19 16:28:17 +03:00
e7cbffcd70 Убрал billable и заменил проверку о "затреканности" на запись из бд 2026-06-16 17:44:20 +03:00
e7837cc0f3 Merge branch 'dev' 2026-06-05 16:14:21 +03:00
20fa3eb8a1 Поправил ошибку с list has no attribute 'title' 2026-06-05 16:14:07 +03:00
63044be106 Revert "Бот + прокси"
This reverts commit 5dc9c8f0d1.
2026-06-04 16:00:35 +03:00
5dc9c8f0d1 Бот + прокси 2026-04-30 16:34:57 +03:00
ddec6094c2 Добавили пагинацию для тегов 2025-10-29 16:07:06 +03:00
129c2377e8 Merge pull request 'Изменил отображение в консоли и проработал режим отладки' (#15) from dev into master
Reviewed-on: #15
2025-10-03 18:22:45 +03:00
e14972c8a2 Изменил отображение в консоли 2025-10-03 18:20:30 +03:00
9688bd644d Проработал режим отладки 2025-10-03 18:15:45 +03:00
8fc8c97597 merge dev into master 2025-09-30 11:49:22 +03:00
8c603cc7ab Изменил вывод информации об успешном/неудачном затрекивании задачи 2025-09-30 11:48:36 +03:00
0bac6a5b5f Merge pull request 'Добавил более подробное описание ошибки при обновлении тега и номера строк в логи' (#14) from dev into master
Reviewed-on: #14
2025-08-06 11:16:58 +03:00
ee4fc1b8f8 Добавил более подробное описание ошибки при обновлении тега 2025-08-06 11:15:55 +03:00
4581142ac6 Добавил номера строк в логи 2025-08-06 11:12:50 +03:00
e0d90b4e59 Merge pull request 'Обработал случай, когда Теги приходят списком, Ошибка cannot access local variable 'end'' (#13) from dev into master
Reviewed-on: #13
2025-08-05 16:53:18 +03:00
2333c6165b Обработал случай, когда Теги приходят списком 2025-08-05 16:52:03 +03:00
f9e14fb7af Ошибка cannot access local variable 'end' 2025-08-04 16:55:53 +03:00
97c2a7aa37 Merge pull request 'Заменил logging на logger' (#12) from dev into master
Reviewed-on: #12
2025-07-04 09:23:27 +03:00
ea9fa5b472 Заменил logging на logger 2025-07-04 09:23:01 +03:00
6f54c6d3ac Ошибка при отсутствии Проектов и проектов Ветро 2025-07-03 17:02:08 +03:00
d3c0a6a8c2 Merge pull request 'Большой рефактор. Обновление тегов, задач и типов деятельности при сохранении активностей в БД' (#11) from dev into master
Reviewed-on: #11
2025-07-03 15:51:59 +03:00
f627f318a6 Большой рефактор. Обновление тегов, задач и типов деятельности при сохранении активностей в БД 2025-07-03 15:50:43 +03:00
ca20bd8620 Много чего сделал 2025-07-02 14:23:00 +03:00
572c123432 Merge pull request 'dev' (#10) from dev into master
Reviewed-on: #10
2025-07-02 11:16:03 +03:00
a3fd037008 Починил ошибку сохранения в бд без id 2025-07-02 09:58:40 +03:00
9840d3aadf Починил ошибку сохранения в бд без id 2025-07-02 09:57:06 +03:00
8a658a4cf3 Merge pull request 'Строгий порядок полей activity при загрузке в бд' (#9) from dev into master
Reviewed-on: #9
2025-06-24 10:45:22 +03:00
feb9cef463 Строгий порядок полей activity при загрузке в бд 2025-06-24 10:44:55 +03:00
bcfadfdfb9 Merge pull request 'dev' (#8) from dev into master
Reviewed-on: #8
2025-06-23 17:53:43 +03:00
46997ff572 Немного оптимизировал запросы к бд 2025-06-23 17:52:07 +03:00
4428fa2881 Немного оптимизировал запросы к бд 2025-06-23 17:51:35 +03:00
f0233d2683 Небольшой рефактор 2025-06-23 16:45:17 +03:00
9a00a4cf24 Merge pull request 'Поправил ошибку с пустым проектом Ветро' (#7) from dev into master
Reviewed-on: #7
2025-06-23 14:25:11 +03:00
57556bafc5 Поправил ошибку с пустым проектом Ветро 2025-06-23 14:22:31 +03:00
c841ca7fa6 Проект может быть пустым 2025-06-23 13:56:16 +03:00
03abe704da Перенес author_id в конец 2025-06-20 17:04:25 +03:00
05f6beee90 Связь time_entry activity_id 2025-06-20 17:02:33 +03:00
5909f0d80a Merge pull request 'Новая таблица tasks' (#6) from dev into master
Reviewed-on: #6
2025-06-20 16:47:57 +03:00
a5f86563fa Новая таблица tasks 2025-06-20 16:47:05 +03:00
3c995e0618 Merge pull request 'Добавил связь между полями activity_type_id' (#5) from dev into master
Reviewed-on: #5
2025-06-20 14:42:20 +03:00
0db2b55d9f Добавил связь между полями activity_type_id 2025-06-20 14:42:02 +03:00
de885281fc Merge pull request 'Добавил деление трудочасов пополам для новичков' (#4) from dev into master
Reviewed-on: #4
2025-06-19 17:20:51 +03:00
764437112b Добавил деление трудочасов пополам для новичков 2025-06-19 17:19:29 +03:00
b586998f9d Merge pull request 'Исправил баг, при котором не сохранялись в БД активности без указания Проекта Ветро' (#3) from dev into master
Reviewed-on: #3
2025-06-19 16:57:32 +03:00
b33bedcced Исправил баг, при котором не сохранялись в БД активности без указания Проекта Ветро 2025-06-19 16:56:44 +03:00
4882cfb46d Merge pull request 'Получение тегов с сервера только при обновлении БД. Исправил баг с предупреждением об устаревшей БД, когда не указан vetro_project' (#2) from dev into master
Reviewed-on: #2
2025-06-19 11:20:32 +03:00
77f35bc757 Получение тегов с сервера только при обновлении БД. Исправил баг с предупреждением об устаревшей БД, когда не указан vetro_project 2025-06-19 11:19:48 +03:00
cad77f41f0 Merge pull request 'Исправил ошибку, при которой нужный тег не парсился с сервера из-за ограничения выборки в 50 тегов. Увеличил до 100 тегов' (#1) from dev into master
Reviewed-on: #1
2025-06-19 10:19:47 +03:00
b5a9232467 Исправил ошибку, при которой нужный тег не парсился с сервера из-за ограничения выборки в 50 тегов. Увеличил до 100 тегов 2025-06-19 10:18:32 +03:00
fe028fca7b Обновление активностей в бд после занесения трудочасов 2025-06-06 13:43:19 +03:00
fc644c9c90 Убрал print 2025-06-06 13:42:55 +03:00
f21acb23ab Много изменений в бд 2025-05-30 12:19:25 +03:00
21b202f090 Изменил отображение даты промежутка сбора активностей 2025-05-26 18:07:20 +03:00
7a9817c0aa README 2025-05-26 13:41:15 +03:00
6172921f55 README 2025-05-26 13:34:16 +03:00
4ac9108e13 README 2025-05-26 13:33:18 +03:00
309588c038 README 2025-05-26 13:32:17 +03:00
1a4ea71f4d Добавил док строку для Базы Данных 2025-05-26 13:21:59 +03:00
04f1df0153 Написал док строки классам 2025-05-26 13:20:19 +03:00
bb57e3828b Добавил строки документации 2025-05-26 13:16:24 +03:00
405576d8cc Мелкие правки 2025-05-26 13:08:21 +03:00
47e93333e4 Убрал лишнюю свзяь в таблице 2025-05-26 12:52:46 +03:00
b1cb13615a Выделил сообщения желтым цветом 2025-05-26 12:42:38 +03:00
7280d435e1 Исправил ошибку при которой трекалась не законченная задача. Исправил отображение в README 2025-05-26 12:30:09 +03:00
QuickLike
2ac11b7d8f Инструкция по получению переменных в README 2025-05-24 16:22:27 +03:00
QuickLike
5ea040815e Автоматическая подгрузка переменных с сервера при их отсутствии 2025-05-24 16:09:57 +03:00
1dc299406b Убрал VETRO_PROJECTS 2025-05-23 15:54:10 +03:00
89c6a99b2d self.cursor 2025-05-23 15:45:38 +03:00
f48a05611c self.cursor 2025-05-23 15:45:25 +03:00
0e5bc28930 Обновление полей в бд 2025-05-23 15:41:14 +03:00
d93c467e1c Комментарий 2025-05-23 10:34:24 +03:00
08ca8d1b4d Изменил формат Номера задачи 2025-05-22 17:29:53 +03:00
217f3a184f SSH вместо HTTPS 2025-05-22 17:02:39 +03:00
414b64721f Merge branch 'master' of ssh://git.sbps.ru:4567/e.vlasov/redmine_tracker 2025-05-22 16:59:54 +03:00
de63f06710 readme 2025-05-22 16:58:17 +03:00
eb69a29486 Убрал лишние поля у класса. Выделил важные сообщения разными цветами 2025-05-22 16:57:03 +03:00
26 changed files with 1591 additions and 321 deletions

1
.gitignore vendored
View File

@@ -123,6 +123,7 @@ celerybeat.pid
*.sage.py *.sage.py
# Environments # Environments
.env.txt
.env .env
.venv .venv
env/ env/

View File

@@ -9,14 +9,21 @@
|Clockify|Redmine|Пример/Уточнение| |Clockify|Redmine|Пример/Уточнение|
|--------|-------|------| |--------|-------|------|
|Описание задачи (Description)|Комментарий|Уточнение у аналитика| |Описание задачи (Description)|Комментарий|Уточнение у аналитика|
|Теги (Tags)|<Задача> - <Название задачи>|28882 - Прочие активности по Ветро| |Теги (Tags)|<Номер задачи> - <Название задачи>|28882 - Прочие активности по Ветро|
|Трекать/Не трекать (Billable/Non-billable)|<Занесена в Redmine или нет>|Если выбрано, то активность будет перенесена |Задача (Task)|Название ДО|(Проект ветро): Страховые направления|
|Проект (Project)|Проект ветро|Ветро: (Задача/Название ДО)|
|Трекать/Не трекать (Billable/Non-billable)|<Занесена в Redmine или нет>|Если выбрано, то активность будет перенесена|
Тег обязательно должен содержать номер задачи (5 цифр) в начале.<br>
28882 - Прочие активности<br>
28882<br>
28882 Прочие активности<br><br>
![Пример](https://s686sas.storage.yandex.net/rdisk/6a2d5791ea3c702cb49f0aa4e3ddeb4b0a18ef534d33ac1577921796fa7f918e/68347d06/20rtLAu3u3ZLRqMT2Uf2DEa2hbpEsCZqsXeTwtuquk31yMeSEoyRF8DpBtv-P498wM53Dw2qo2ZNI7_n9S6eZQ==?uid=242286937&filename=Пример%20Clockify.png&disposition=inline&hash=&limit=0&content_type=image%2Fpng&owner_uid=242286937&fsize=75967&hid=87e0f79d157948ce5b962d68f96fda1f&media_type=image&tknv=v3&etag=79e05016a7ea094f07db2f502bda8c26&ts=6360ae8b4cd80&s=70ab255959c751cfa96c5dc6848f581bcca3178973ba9003652200278ac58720&pb=U2FsdGVkX1_lZF8bmjS5dNV_GgHPlP0wO1x1dpo0eIFQPRo2yX5WLuuV9Qa4yZo4n95LFujA-wW2-JGPlulKbcuL7h9fllNiDfwkYdHwSZY)
### Запуск: ### Запуск:
Клонируйте [репозиторий](https://git.sbps.ru/e.vlasov/redmine_tracker) и перейдите в него в командной строке: Клонируйте [репозиторий](https://git.sbps.ru/e.vlasov/redmine_tracker) и перейдите в него в командной строке:
```bash ```bash
git clone https://git.sbps.ru/e.vlasov/redmine_tracker.git git clone ssh://git@git.sbps.ru:4567/e.vlasov/redmine_tracker.git
cd redmine_tracker cd redmine_tracker
``` ```
@@ -44,35 +51,62 @@ python -m pip install --upgrade pip
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
И установите локальную команду запуска:
```bash
pip install -e .
```
Создайте файл .env и добавьте в него переменные: Создайте файл .env и добавьте в него переменные:
```bash ```bash
touch .env nano .env
``` ```
```env ```env
*REDMINE_TOKEN=<api_ключ_redmine> *REDMINE_TOKEN=<api_ключ_redmine>
*CLOCKIFY_TOKEN=<api_ключ_clockify> *CLOCKIFY_TOKEN=<api_ключ_clockify>
*CLOCKIFY_USER_ID=<user_id_clockify> ?USER_ID=<user_id_clockify>
*CLOCKIFY_WORKSPACE_ID=<workspace_id_clockify> ?WORKSPACE_ID=<workspace_id_clockify>
?TRIAL_END_DATE=<дата_середины_испытательного_срока>
``` ```
\* - Обязательные<br> \* - Обязательные.<br>
? - Опциональные ? - Опциональные. Могут быть пустыми, но не должны отсутствовать вовсе.
## Получение `USER_ID` и `WORKSPACE_ID`
1. Простой способ: запустить скрипт с пустыми полями `USER_ID` и `WORKSPACE_ID` в файле `.env`
<br>и они автоматически подгрузятся и сохранятся локально в файле.<br>
Этот способ может помочь в том случае, если у вас всего одно рабочее пространство и один пользователь в Clockify.<br>
В других случаях рекомендую использовать второй способ.
2. Чуть сложнее: Сделать GET-запроc по эндпоинту `https://app.clockify.me/api/v1/user` и указать ваш токен в заголовках запроса.
{"X-API-KEY": "ВАШ_ТОКЕН"}<br>
В теле ответа вы получите нужные переменные:<br>
id - это `USER_ID`<br>
activeWorkspace - это `WORKSPACE_ID`
<br>
## TRIAL_END_DATE
Это дата, после которой ваши трудочасы должны заноситься в Redmine в полном количестве. Данная переменная больше актуальна для новичков, которым нужно делить трудочасы пополам.<br>
До указанной даты трудочасы будут заноситься в Redmine наполовину. После этой даты - целиком.<br>
## Запуск. ## Запуск.
``` ```
python track.py redmine-tracker
``` ```
## Аргументы запуска ## Аргументы запуска
| Аргумент | Описание | Пример использования | | Аргумент | Описание | Пример использования |
|-------------------|--------------------------------------------------------------|--------------------------------------| |-------------------|----------------------------------------------------------------------------------------------|--------------------------------------|
| `-h [--help]` | Показать справку | `python track.py --help` | | `-h [--help]` | Показать справку | `redmine-tracker --help` |
| `-s [--start]` | Дата начала парсинга активностей в формате гггг-мм-дд | `python track.py --start 2025-05-19` | | `-s [--start]` | Дата начала парсинга активностей в формате гггг-мм-дд | `redmine-tracker --start 2025-05-19` |
| `-e [--end]` | Дата окончания парсинга активностей в формате гггг-мм-дд | `python track.py -e 2025-05-19` | | `-e [--end]` | Дата окончания парсинга активностей в формате гггг-мм-дд | `redmine-tracker -e 2025-05-19` |
| `-p [--page-size]`| Количество задач для вывода. Выведет последние n записей | `python track.py --page-size 15` | | `-p [--page-size]`| Количество задач для вывода. Выведет последние n записей | `redmine-tracker --page-size 15` |
| `-d [--debug]` | Режим отладки. Не загружает трудочасы в Redmine | `python track.py -d` | | `-d [--debug]` | Режим отладки. Не загружает трудочасы в Redmine | `redmine-tracker -d` |
| `-i [--init-db]` | Режим инициализации бд. | `python track.py -init-db` | | `--dry-run` | Предпросмотр без сохранения активностей и отправки трудочасов | `redmine-tracker --dry-run` |
| | Подгружает с серверов теги, виды деятельности и проекты Ветро| | | `-i [--init-db]` | Выполняет миграции и обновляет справочники без отправки трудочасов в Redmine | `redmine-tracker --init-db` |
При запуске с `--init-db` файлы из каталога `migrations` выполняются по имени.
Успешно применённые миграции повторно не запускаются: их имя и время выполнения
сохраняются в таблице `migration`.
Также можно запускать напрямую из корня проекта:
```bash
python main.py
```
[Власов Эдуард](https://github.com/QuickLike) [Власов Эдуард](https://github.com/QuickLike)

View File

View File

@@ -8,29 +8,54 @@ from tqdm import tqdm
from config import ClockifyConfig, config from config import ClockifyConfig, config
from classes import Activity, Tag, VetroProject from classes import Activity, Tag, VetroProject
from exceptions import InvalidToken from exceptions import InvalidToken
from utils import time_to_hour from utils import format_hours, parse_task_id, time_to_hour
from .constants import REQUEST_TIMEOUT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ClockifyAPI(ClockifyConfig): class ClockifyAPI(ClockifyConfig):
def __init__(self, token: str, *args, **kwargs): """API клиент для взаимодействия с Clockify.
Позволяет получать активности, теги, проекты ветро и помечать активности затрканными.
"""
def __init__(self, token: str, db, redmine_api=None, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.db = db
self.redmine_api = redmine_api
self.token = self._check_token(token) self.token = self._check_token(token)
def get_activities(self, **kwargs) -> list[Activity]: def get_activities(self, is_updating=False, **kwargs) -> list[Activity]:
""" """
start: datetime - Начальная дата (обязательно) Получает с сервера Clockify активности за определнный период
end: datetime - Конечная дата (обязательно) start: datetime - Начальная дата (обязательно)
page: int - Номер страницы end: datetime - Конечная дата (обязательно)
page-size: int - Размер страницы (по умолчанию 50) page: int - Номер страницы
page-size: int - Размер страницы (по умолчанию 50)
""" """
time_entries_url = self.time_entries_url time_entries_url = self.time_entries_url
activities = requests.get(self.base_url + time_entries_url, params=kwargs, headers={"X-API-KEY": self.token}).json() resp = requests.get(
self.base_url + time_entries_url,
params=kwargs,
headers={"X-API-KEY": self.token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
logger.error(f"Ошибка ClockifyAPI: {resp.text} {resp.status_code}")
resp.raise_for_status()
activities = resp.json()
if not activities:
return []
logger.info(f"Получено задач: {len(activities)}") logger.info(f"Получено задач: {len(activities)}")
time_worked_out_total = 0 time_worked_out_total = 0
tags = self.get_tags(is_updating=is_updating)
if not tags:
logger.info(f"{Fore.YELLOW}В базе данных нет тегов! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}")
return []
tags_by_id = {tag.id: tag for tag in tags}
result = [] result = []
for activity in tqdm(activities, desc="Парсинг задач"): for activity in tqdm(activities, desc="Парсинг задач"):
if not activity.get("tagIds"): if not activity.get("tagIds"):
@@ -40,88 +65,209 @@ class ClockifyAPI(ClockifyConfig):
start = datetime.strptime(activity["timeInterval"]["start"], config.DATETIME_FORMAT) start = datetime.strptime(activity["timeInterval"]["start"], config.DATETIME_FORMAT)
end = datetime.strptime(activity["timeInterval"]["end"], config.DATETIME_FORMAT) if activity["timeInterval"]["end"] else None end = datetime.strptime(activity["timeInterval"]["end"], config.DATETIME_FORMAT) if activity["timeInterval"]["end"] else None
time_worked_out_total += time_to_hour((end if end else datetime.utcnow()) - start) time_worked_out_total += time_to_hour((end if end else datetime.utcnow()) - start)
tag = [tag for tag in self.get_tags() if tag.id == tag_id][0]
try:
task_id, task_title = tag.title.split(" - ") tag = tags_by_id.get(tag_id)
except ValueError:
logger.info(f'{activity["timeInterval"]["start"]}: {activity["description"]} - Неправильный формат тега {tag}') if not tag:
tag = self.get_tags(True, tag_id=tag_id, **{"page-size": self.tags_count})
if isinstance(tag, Tag):
self.db.insert_tags([tag])
tags_by_id[tag.id] = tag
try:
task_id = parse_task_id(tag.title)
except Exception as e:
logger.error(f"При обновлении тега произошла ошибка: {e}\nПопробуйте обновить флагом -i [--init-db]")
continue
if not self.redmine_api:
logger.error("Не передан RedmineAPI для обновления задачи по новому тегу")
continue
task = self.redmine_api.get_issue(task_id)
if task:
self.db.insert_tasks([task])
else:
logger.error(f"{Fore.RED} Тег не найден: {tag_id}{Fore.RESET}")
continue
else:
task_id = parse_task_id(tag.title)
author = self.db.get_user_by("clockify_user_id", activity["userId"])
if not author:
logger.error(f"Не найден автор активности {activity['id']}")
continue continue
result.append( result.append(
Activity( Activity(
id=activity["id"], id=activity["id"],
tag_id=tag_id, tag_id=tag_id,
task_id=task_id, task_id=task_id,
task_title=task_title,
project_id=activity["projectId"], project_id=activity["projectId"],
vetro_project_id=activity["taskId"], vetro_project_id=activity["taskId"],
vetro_project_title=config.VETRO_PROJECTS.get(activity.get("taskId")),
description=activity["description"], description=activity["description"],
time_spent=time_to_hour((end if end else datetime.utcnow()) - start), time_spent=time_to_hour((end if end else datetime.utcnow()) - start),
date_start=start, date_start=start,
date_end=end, date_end=end,
is_tracked=activity["billable"] is_tracked=self.db.has_time_entry(activity["id"]),
author_id=author["id"]
) )
) )
logger.info(f"Сохранено задач: {len(result)}") logger.info(f"Сохранено задач: {len(result)}")
logger.info(f"Отработано часов: {time_worked_out_total}") logger.info(f"Отработано часов: {format_hours(time_worked_out_total)}")
return result return result
def get_tags(self, **kwargs) -> list[Tag]: def get_tags(self, is_updating=False, tag_id="", **kwargs) -> list[Tag] | Tag | None:
""" """
workspace_id: str - required Получает с сервера Clockify теги
name: str - optional name: str - optional
archived: bool - optional archived: bool - optional
page-size: int - optional
page: int - optional
""" """
tags = requests.get(self.base_url + self.tags_url, params=kwargs, headers={"X-API-KEY": self.token}).json() if is_updating:
return [Tag( page = 1
id=tag["id"], page_size = int(kwargs.get("page-size", self.tags_count))
title=tag["name"] result = []
) for tag in tags] while True:
params = {
"sort-column": "name",
"sort-order": "descending",
**kwargs,
"page": page
}
resp = requests.get(
self.base_url + self.tags_url,
params=params,
headers={"X-API-KEY": self.token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
logger.error(f"Ошибка ClockifyAPI: {resp.text} {resp.status_code}")
resp.raise_for_status()
tags = resp.json()
for tag in tags:
clockify_tag = Tag(
id=tag["id"],
title=tag["name"]
)
if tag_id and tag_id == clockify_tag.id:
return clockify_tag
if not tag_id:
result.append(clockify_tag)
if len(tags) < page_size:
break
page += 1
if tag_id:
return None
logger.debug(f"{Fore.YELLOW}Получено тегов: {len(result)}.{Fore.RESET}")
return result
return self.db.get_tags()
def get_vetro_projects(self) -> list[VetroProject]: def get_vetro_projects(self) -> list[VetroProject]:
projects = requests.get(self.base_url + self.projects_url, headers={"X-API-KEY": self.token}).json() """
Получает с сервера Clockify Проекты (ДО) Ветро
"""
resp = requests.get(
self.base_url + self.projects_url,
headers={"X-API-KEY": self.token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
logger.error(f"Ошибка ClockifyAPI: {resp.text} {resp.status_code}")
resp.raise_for_status()
projects = resp.json()
for project in projects: for project in projects:
if not project.get("archived"): if not project.get("archived"):
vetro_projects = requests.get(self.base_url + self.tasks_url(project.get("id")), headers={"X-API-KEY": self.token}).json() resp = requests.get(
self.base_url + self.tasks_url(project.get("id")),
headers={"X-API-KEY": self.token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
logger.error(f"Ошибка ClockifyAPI: {resp.text} {resp.status_code}")
resp.raise_for_status()
vetro_projects = resp.json()
return [VetroProject( return [VetroProject(
id=vetro_project["id"], id=vetro_project["id"],
title=vetro_project["name"] title=vetro_project["name"]
) for vetro_project in vetro_projects] ) for vetro_project in vetro_projects]
# DEPRECATED
def mark_tracked(self, activities: list[Activity]): def mark_tracked(self, activities: list[Activity]):
"""
Помечает активности затреканными (Billable) на сервере Clockify
"""
for activity in activities: for activity in activities:
try: try:
resp = requests.put( if not config.is_debug:
self.base_url + self.update_time_entry( resp = requests.put(
activity_id=activity.id self.base_url + self.update_time_entry(
), json={ activity_id=activity.id
"billable": True, ), json={
"start": activity.date_start.strftime(config.DATETIME_FORMAT), "billable": True,
"end": activity.date_end.strftime(config.DATETIME_FORMAT) if activity.date_end else None, "start": activity.date_start.strftime(config.DATETIME_FORMAT),
"description": activity.description, "end": activity.date_end.strftime(config.DATETIME_FORMAT) if activity.date_end else None,
"tagIds": [activity.tag_id], "description": activity.description,
"taskId": activity.vetro_project_id, "tagIds": [activity.tag_id],
"projectId": activity.project_id "taskId": activity.vetro_project_id,
}, headers={ "projectId": activity.project_id
"X-API-KEY": self.token }, headers={
} "X-API-KEY": self.token
) },
if resp.ok: timeout=REQUEST_TIMEOUT,
logger.debug(resp.json()) )
logger.info(f"{activity} {Fore.GREEN} помечена как затреканная{Fore.RESET}") if config.is_debug or resp.ok:
logger.info(f"{Fore.GREEN}{activity}{Fore.RESET}".ljust(60) + "")
else: else:
logger.debug(resp.json()) logger.debug(resp.text)
logger.error(f"{activity} {Fore.RED} не помечена как затреканная{Fore.RESET}") logger.error(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "")
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return return
def _check_token(self, token: str) -> str: def _check_token(self, token: str) -> str:
resp = requests.get(self.base_url + self.user_url, headers={"X-API-KEY": token}) """Проверяет токен"""
resp = requests.get(
self.base_url + self.user_url,
headers={"X-API-KEY": token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok: if not resp.ok:
logger.error(resp.json()) logger.error(resp.text)
raise InvalidToken raise InvalidToken
resp = resp.json() resp = resp.json()
logger.debug({"id": resp["id"], "email": resp["email"], "name": resp["name"]}) logger.debug({"id": resp["id"], "email": resp["email"], "name": resp["name"]})
return token return token
def get_user(self):
"""Получает нужные данные пользователя с сервера Clockify"""
resp = requests.get(
self.base_url + self.user_url,
headers={"X-API-KEY": self.token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
logger.error(resp.text)
raise requests.exceptions.RequestException(Fore.RED + "Ошибка запроса." + Fore.RESET)
json = resp.json()
return {
"id": json['id'],
"workspace_id": json['activeWorkspace'],
"email": json["email"],
"username": json["name"]
}
def getenvs(self):
"""Получает нужные USER_ID и WORKSPACE_ID с сервера Clockify"""
user = self.get_user()
with open(config.ENV_TXT, "w") as file:
try:
file.write(f"USER_ID={user['id']}\nWORKSPACE_ID={user['workspace_id']}")
logger.info(f"{Fore.GREEN}Необходимые переменные были сохранены в файл {config.ENV_TXT}{Fore.RESET}")
except Exception as e:
logger.error(f"{Fore.RED}Не удалось сохранить необходимые переменные.{Fore.RESET}")
raise e

7
api/constants.py Normal file
View File

@@ -0,0 +1,7 @@
"""Константы API-клиентов."""
from enums import ActivityTypes
REQUEST_TIMEOUT = 30
ACTIVITY_TYPE_BY_VETRO_PROJECT = {}
DEFAULT_ACTIVITY_TYPE = ActivityTypes.DEVELOPMENT

View File

@@ -1,77 +1,179 @@
import logging import logging
from colorama import Fore
import requests import requests
from tqdm import tqdm
from classes.classes import Task
from config import RedmineConfig, config from config import RedmineConfig, config
from classes import Activity, ActivityType from classes import Activity, ActivityType
from enums import ActivityTypes
from exceptions import InvalidToken from exceptions import InvalidToken
from utils import format_hours
from .constants import (
ACTIVITY_TYPE_BY_VETRO_PROJECT,
DEFAULT_ACTIVITY_TYPE,
REQUEST_TIMEOUT,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
WORKSPACE_ID = config.CLOCKIFY_WORKSPACE_ID
USER_ID = config.CLOCKIFY_USER_ID
class RedmineAPI(RedmineConfig): class RedmineAPI(RedmineConfig):
"""API клиент для взаимодействия с Redmine.
Позволяет заносить трудочасы и получать виды деятельности.
"""
def __init__(self, token): def __init__(self, token):
super().__init__() super().__init__()
self.token = self._check_token(token) self.token = token
self._check_token()
def track_activities(self, activities: list[Activity]) -> list[Activity]: def track_activities(self, activities: list[Activity], on_success=None) -> list[Activity]:
if len(activities) == 0: """Заносит трудочасы на сервер Redmine"""
logger.info("Нет задач для занесения трудочасов") is_beginner = config.is_beginner
return if is_beginner:
logger.info(f"{Fore.YELLOW}Делим трудочасы пополам{Fore.RESET}")
tracked = [] tracked = []
for activity in tqdm(activities, desc="Занесение трудочасов"): failed_activities = []
if not activity.is_tracked: activities_to_track = [
try: activity
for activity in activities
if not activity.is_tracked and activity.date_end
]
for activity in activities_to_track:
try:
if not config.is_debug:
resp = requests.post( resp = requests.post(
self.base_url + self.time_entries_url, self.base_url + self.time_entries_url,
headers={"X-Redmine-Api-Key": self.token}, headers={"X-Redmine-Api-Key": self.token},
json={ json={
"time_entry": { "time_entry": {
"issue_id": activity.task_id, "issue_id": activity.task_id,
"hours": activity.time_spent, "hours": round(activity.time_spent / (2 if is_beginner else 1), 3),
"activity_id": ActivityTypes.DEVELOPMENT, "activity_id": self.get_activity_type_id(activity),
"comments": activity.description, "comments": activity.description,
"spent_on": activity.date_start.strftime(self.datetime_format) "spent_on": activity.date_start.strftime(self.datetime_format)
} }
} },
timeout=REQUEST_TIMEOUT,
) )
if resp.ok: if config.is_debug or resp.ok:
activity.is_tracked = True activity.is_tracked = True
tracked.append(activity) if on_success:
logger.debug(activity) on_success(activity, resp.json()["time_entry"]["id"])
else: tracked.append(activity)
logger.error(f"{activity} Не была затрекана") logger.info(
logger.error(f"{resp.json()['errors']} {resp.status_code}") f"{Fore.GREEN}{self.format_activity_result(activity, is_beginner)}"
except Exception as e: f"{Fore.RESET}".ljust(100) + ""
logger.error(e) )
continue else:
if len(activities) == len(tracked): try:
logger.info("Все задачи были успешно занесены в Redmine") errors = resp.json()["errors"]
except Exception:
errors = resp.text
logger.debug(errors)
logger.error(f"{Fore.RED}{activity}: с ошибкой {Fore.RESET}".ljust(100) + "")
logger.error(f"{errors} {resp.status_code}")
failed_activities.append(activity)
except Exception as e:
logger.error(e)
failed_activities.append(activity)
continue
if len(activities_to_track) == len(tracked):
logger.info(Fore.GREEN + "Все задачи были успешно занесены в Redmine" + Fore.RESET)
else: else:
logger.info(f"Не все задачи были занесены в Redmine. Занесено {len(tracked)}") logger.info(f"{Fore.YELLOW}Не все задачи были занесены в Redmine. Занесено {len(tracked)}{Fore.RESET}")
logger.info(f"{Fore.YELLOW}Не занесены задачи:{Fore.RESET}")
for activity in failed_activities:
logger.info(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "")
return tracked return tracked
def get_time_entry_activities(self) -> list[ActivityTypes]: def format_activity_result(self, activity: Activity, is_beginner: bool = False) -> str:
resp = requests.get(self.base_url + self.time_entry_activities, headers={"X-Redmine-API-KEY": self.token}) """Форматирует строку результата переноса активности."""
logging.debug(resp.json()) hours = round(activity.time_spent / (2 if is_beginner else 1), 3)
if resp.ok: return f"#{activity.task_id} | {format_hours(hours)} | {activity.description}"
return [
ActivityType(id=act.get("id"), title=act.get("name"))
for act in resp.json().get("time_entry_activities")
if act.get("active")
]
def _check_token(self, token): def get_activity_type_id(self, activity: Activity) -> int:
resp = requests.get(self.base_url + self.my_account_url, headers={"X-Redmine-Api-Key": token}) """Возвращает тип деятельности Redmine для активности Clockify."""
return ACTIVITY_TYPE_BY_VETRO_PROJECT.get(
activity.vetro_project_id,
DEFAULT_ACTIVITY_TYPE,
)
def get_time_entry_activities(self) -> list[ActivityType]:
"""Получает виды Деятельности с сервера Redmine"""
resp = requests.get(
self.base_url + self.time_entry_activities,
headers={"X-Redmine-API-KEY": self.token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok: if not resp.ok:
logger.error("Invalid Token for RedmineAPI") logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
return []
data = resp.json()
logger.debug(data)
return [
ActivityType(id=act.get("id"), title=act.get("name"))
for act in data.get("time_entry_activities", [])
if act.get("active")
]
def get_user(self):
"""Проверяет токен и возвращает данные юзера"""
resp = requests.get(
self.base_url + self.my_account_url,
headers={"X-Redmine-Api-Key": self.token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
logger.error(f"Invalid Token for RedmineAPI: {resp.text}")
raise InvalidToken raise InvalidToken
resp = resp.json()['user'] json = resp.json()["user"]
logger.debug({"login": resp["login"], "email": resp["mail"], "full_name": f'{resp["firstname"]} {resp["lastname"]}'}) return {
return token "id": json["id"],
"first_name": json["firstname"],
"last_name": json["lastname"],
"email": json["mail"],
"username": json["login"]
}
def get_issue(self, issue_id: int) -> Task | None:
"""Получает задачу по её id с сервера Redmine"""
try:
resp = requests.get(
self.base_url + self.issue.format(**{"id": issue_id}),
headers={"X-Redmine-Api-Key": self.token},
timeout=REQUEST_TIMEOUT,
)
except requests.RequestException as error:
logger.error(
"Не удалось загрузить задачу #%s из Redmine: %s",
issue_id,
error,
)
return None
if not resp.ok:
logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
return None
issue = resp.json()["issue"]
return Task(**{
"id": issue["id"],
"subject": issue["subject"],
"description": issue["description"],
"status": issue["status"]["name"],
"priority": issue["priority"]["name"],
"author": issue["author"]["name"],
"tracker": issue["tracker"]["name"],
"project": issue["project"]["name"]
})
def get_issues(self, issue_ids: set[int]) -> list[Task]:
"""Получает с сервера Redmine только задачи с указанными номерами."""
return [
task
for issue_id in sorted(issue_ids)
if (task := self.get_issue(issue_id))
]
def _check_token(self):
user = self.get_user()
logger.debug(user)

View File

@@ -1 +1,7 @@
from .classes import Activity, ActivityType, Tag, VetroProject # noqa: F401 from .classes import ( # noqa: F401
Activity,
ActivityType,
AggregatedActivity,
Tag,
VetroProject,
)

View File

@@ -6,11 +6,12 @@ from enums import ClockifyProjects, VetroProjects
@dataclass @dataclass
class Activity: class Activity:
"""Описывает одну активность, полученную из Clockify."""
id: str id: str
author_id: int
vetro_project_id: VetroProjects vetro_project_id: VetroProjects
vetro_project_title: str
task_id: int task_id: int
task_title: str
tag_id: str tag_id: str
description: str description: str
project_id: ClockifyProjects project_id: ClockifyProjects
@@ -20,7 +21,14 @@ class Activity:
is_tracked: bool = False is_tracked: bool = False
def __str__(self): def __str__(self):
return f'{self.date_start + timedelta(hours=3)}|{self.vetro_project_title}|{self.description} для задачи "{self.task_title}"' return f'({self.task_id}) - "{self.description}"'[:60]
@dataclass
class AggregatedActivity(Activity):
"""Представляет несколько активностей Clockify одной записью Redmine."""
source_activities: tuple[Activity, ...] = ()
@dataclass @dataclass
@@ -47,3 +55,17 @@ class VetroProject:
def __str__(self): def __str__(self):
return self.title return self.title
@dataclass
class Task:
id: int
subject: str
description: str
status: str
priority: str
author: str
tracker: str
project: str
def __str__(self):
return f"#{self.id}: {self.subject}"

View File

@@ -1,45 +1,81 @@
CREATE TABLE IF NOT EXISTS "activities" ( CREATE TABLE IF NOT EXISTS "activity" (
"id" VARCHAR NOT NULL UNIQUE, "id" VARCHAR NOT NULL UNIQUE,
"description" VARCHAR NOT NULL, "description" VARCHAR NOT NULL,
"time_spent" NUMERIC(3, 3) NOT NULL, "author_id" INTEGER NOT NULL,
"date_start" DATETIME NOT NULL, "time_spent" NUMERIC NOT NULL,
"date_end" DATETIME, "date_start" DATETIME NOT NULL,
"is_tracked" BOOLEAN NOT NULL DEFAULT false, "date_end" DATETIME,
"vetro_project_id" VARCHAR NOT NULL, "is_tracked" BOOLEAN NOT NULL DEFAULT false,
"tag_id" VARCHAR NOT NULL, "vetro_project_id" VARCHAR,
"project_id" VARCHAR NOT NULL, "tag_id" VARCHAR NOT NULL,
PRIMARY KEY("id"), "task_id" INTEGER,
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE NO ACTION ON DELETE NO ACTION, "project_id" VARCHAR,
FOREIGN KEY ("vetro_project_id") REFERENCES "vetro_projects"("id") ON UPDATE NO ACTION ON DELETE NO ACTION PRIMARY KEY("id"),
FOREIGN KEY ("author_id") REFERENCES "user"("id")
ON UPDATE NO ACTION ON DELETE NO ACTION,
FOREIGN KEY ("task_id") REFERENCES "task"("id")
ON UPDATE NO ACTION ON DELETE NO ACTION
); );
CREATE TABLE IF NOT EXISTS "tags" ( CREATE TABLE IF NOT EXISTS "tag" (
"id" VARCHAR NOT NULL UNIQUE, "id" VARCHAR NOT NULL UNIQUE,
"title" VARCHAR NOT NULL, "title" VARCHAR NOT NULL,
PRIMARY KEY("id") PRIMARY KEY("id")
); );
CREATE TABLE IF NOT EXISTS "vetro_projects" ( CREATE TABLE IF NOT EXISTS "vetro_project" (
"id" VARCHAR NOT NULL UNIQUE, "id" VARCHAR NOT NULL UNIQUE,
"title" VARCHAR NOT NULL, "title" VARCHAR NOT NULL,
PRIMARY KEY("id") PRIMARY KEY("id")
); );
CREATE TABLE IF NOT EXISTS "activity_types" ( CREATE TABLE IF NOT EXISTS "activity_type" (
"id" INTEGER NOT NULL UNIQUE, "id" INTEGER NOT NULL UNIQUE,
"title" VARCHAR NOT NULL, "title" VARCHAR NOT NULL,
PRIMARY KEY("id") PRIMARY KEY("id")
);
CREATE TABLE IF NOT EXISTS "task" (
"id" INTEGER,
"subject" TEXT,
"description" TEXT,
"status" TEXT,
"priority" TEXT,
"author" TEXT,
"tracker" TEXT,
"project" TEXT,
PRIMARY KEY ("id")
); );
CREATE TABLE IF NOT EXISTS "time_entry" ( CREATE TABLE IF NOT EXISTS "time_entry" (
"id" INTEGER PRIMARY KEY, "id" INTEGER,
"date" DATETIME NOT NULL, "description" VARCHAR NOT NULL,
"description" VARCHAR NOT NULL, "date" DATETIME NOT NULL,
"activity_id" VARCHAR NOT NULL, "activity_id" VARCHAR NOT NULL,
"time_spent" NUMERIC(3, 3) NOT NULL, "time_spent" NUMERIC NOT NULL,
"task_id" INTEGER NOT NULL, "task_id" INTEGER NOT NULL,
"activity_type_id" INTEGER NOT NULL, "activity_type_id" INTEGER NOT NULL,
FOREIGN KEY ("activity_id") REFERENCES "activities"("id") ON UPDATE NO ACTION ON DELETE NO ACTION, "author_id" INTEGER NOT NULL,
FOREIGN KEY ("task_id") REFERENCES "activities"("id") ON UPDATE NO ACTION ON DELETE NO ACTION, PRIMARY KEY("id"),
FOREIGN KEY ("activity_type_id") REFERENCES "activity_types"("id") ON UPDATE NO ACTION ON DELETE NO ACTION FOREIGN KEY ("author_id") REFERENCES "user"("id")
ON UPDATE NO ACTION ON DELETE NO ACTION,
FOREIGN KEY ("activity_id") REFERENCES "activity"("id")
ON UPDATE NO ACTION ON DELETE NO ACTION,
FOREIGN KEY ("task_id") REFERENCES "task"("id")
ON UPDATE NO ACTION ON DELETE NO ACTION,
FOREIGN KEY ("activity_type_id") REFERENCES "activity_type"("id")
ON UPDATE NO ACTION ON DELETE NO ACTION
);
CREATE TABLE IF NOT EXISTS "user" (
"id" INTEGER NOT NULL UNIQUE,
"first_name" VARCHAR,
"last_name" VARCHAR,
"clockify_user_id" VARCHAR NOT NULL UNIQUE,
"redmine_user_id" VARCHAR NOT NULL UNIQUE,
"clockify_email" VARCHAR UNIQUE,
"clockify_username" VARCHAR UNIQUE,
"redmine_email" VARCHAR UNIQUE,
"redmine_username" VARCHAR UNIQUE,
PRIMARY KEY("id")
); );

View File

@@ -1,9 +1,8 @@
import argparse import argparse
from datetime import datetime
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from dotenv import load_dotenv from dotenv import load_dotenv
from db.db import Database
from .clockify import ClockifyConfig # noqa: F401 from .clockify import ClockifyConfig # noqa: F401
from .redmine import RedmineConfig # noqa: F401 from .redmine import RedmineConfig # noqa: F401
@@ -13,35 +12,44 @@ load_dotenv()
class Config(BaseSettings): class Config(BaseSettings):
CLOCKIFY_TOKEN: str CLOCKIFY_TOKEN: str
REDMINE_TOKEN: str REDMINE_TOKEN: str
CLOCKIFY_USER_ID: str USER_ID: str = None
CLOCKIFY_WORKSPACE_ID: str WORKSPACE_ID: str = None
DB_NAME: str = "RedmineTracker.db" MODE: str = "prod"
TRIAL_END_DATE: str = datetime.today().strftime("%Y-%m-%d")
ENV_TXT: str = ".env.txt"
DB_NAME: str = "db/RedmineTracker.db"
SQL_PATH: str = "config/RedmineTracker.sql" SQL_PATH: str = "config/RedmineTracker.sql"
MIGRATIONS_PATH: str = "migrations"
DATETIME_FORMAT: str = "%Y-%m-%dT%H:%M:%SZ" DATETIME_FORMAT: str = "%Y-%m-%dT%H:%M:%SZ"
VETRO_PROJECTS: dict = { MONTHS: dict = {
"682c438ca9a5d61a4cc9a405": "Админка", 1: "января",
"682c7c1b537d26222fe3cde8": "Чаты Авито", 2: "февраля",
"682c7cc5a4e2fd3c34a5e933": "Звонки", 3: "марта",
"682b2c2d537d26222fc5671c": "Регистрация гарантии", 4: "апреля",
"682b3cc4f01c6c02e4e765b0": "Клиент", 5: "мая",
"682b2c1366a3b415535a23fa": "Контрагенты", 6: "июня",
"682b2c3f24f321560e283cb2": "Урегулирование страховых случаев", 7: "июля",
"682b2c52d7ce797b226fddef": "Почта", 8: "августа",
"682c7cd766a3b4155378f8a4": "Товар", 9: "сентября",
"682c7d05d7ce797b228edd06": "Закупка", 10: "октября",
"682b2bf324f321560e283084": "Страховые направления", 11: "ноября",
"682b2c09f01c6c02e4e49681": "Задачи направлений", 12: "декабря"
"682b2c1bd096503066890985": "Заказ",
"682b2c22b417733736a3ae9f": "Задачи заказов",
"682b3280560c2445c917951c": "Услуга клиенту",
"682b2c7324f321560e284588": "Потерянные трудочасы",
"682c7d0ed7ce797b228edf3a": "WhatsApp",
"682b2bada9a5d61a4cb1b6e1": "Прочие активности по Ветро",
} }
@property
def is_beginner(self):
from utils import str_to_date
return datetime.today() < str_to_date(self.TRIAL_END_DATE)
@property
def is_debug(self):
return self.MODE == "debug"
def configure_argument_parser(): def configure_argument_parser():
parser = argparse.ArgumentParser(description='Трекер трудочасов с Clockify в Redmine') parser = argparse.ArgumentParser(description='Трекер трудочасов с Clockify в Redmine')
@@ -58,6 +66,7 @@ def configure_argument_parser():
parser.add_argument( parser.add_argument(
'-p', '-p',
'--page-size', '--page-size',
type=int,
help='Количество задач для вывода' help='Количество задач для вывода'
) )
parser.add_argument( parser.add_argument(
@@ -66,14 +75,21 @@ def configure_argument_parser():
action="store_true", action="store_true",
help='Режим отладки. Не загружает трудочасы в Redmine' help='Режим отладки. Не загружает трудочасы в Redmine'
) )
parser.add_argument(
'--dry-run',
action="store_true",
help='Предпросмотр. Показывает активности без их сохранения и отправки в Redmine'
)
parser.add_argument( parser.add_argument(
'-i', '-i',
'--init-db', '--init-db',
action="store_true", action="store_true",
help='Режим инициализации бд.\nПодгружает с серверов теги, виды деятельности и проекты Ветро' help=(
'Режим инициализации БД. Выполняет миграции и обновляет справочники '
'без отправки трудочасов в Redmine'
)
) )
return parser return parser
arg_parser = configure_argument_parser() arg_parser = configure_argument_parser()
config = Config() config = Config()
db = Database(config.DB_NAME, config.SQL_PATH)

View File

@@ -10,6 +10,8 @@ class ClockifyConfig:
self.user_url = "/user" self.user_url = "/user"
self.projects_url = f"{self.workspace_url}/projects" self.projects_url = f"{self.workspace_url}/projects"
self.tags_count = 20
def update_time_entry(self, activity_id): def update_time_entry(self, activity_id):
return f"{self.workspace_url}/time-entries/{activity_id}" return f"{self.workspace_url}/time-entries/{activity_id}"

View File

@@ -1,3 +1,6 @@
REDMINE_ISSUE_URL_TEMPLATE = "https://redmine.sbps.ru/issues/{issue_id}"
class RedmineConfig: class RedmineConfig:
def __init__(self): def __init__(self):
self.base_url = "https://redmine.sbps.ru" self.base_url = "https://redmine.sbps.ru"
@@ -5,3 +8,5 @@ class RedmineConfig:
self.my_account_url = "/my/account.json" self.my_account_url = "/my/account.json"
self.datetime_format = "%Y-%m-%d" self.datetime_format = "%Y-%m-%d"
self.time_entry_activities = "/enumerations/time_entry_activities.json" self.time_entry_activities = "/enumerations/time_entry_activities.json"
self.issue = "/issues/{id}.json"
self.issues = "/issues.json"

13
db/constants.py Normal file
View File

@@ -0,0 +1,13 @@
"""Константы слоя базы данных."""
USER_LOOKUP_FIELDS = frozenset(
{
"id",
"clockify_user_id",
"redmine_user_id",
"clockify_email",
"clockify_username",
"redmine_email",
"redmine_username",
}
)

389
db/db.py
View File

@@ -1,40 +1,101 @@
import logging import logging
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import sqlite3 as sq import sqlite3 as sq
from colorama import Fore
from classes import Activity, ActivityType, Tag, VetroProject from classes import Activity, ActivityType, Tag, VetroProject
from classes.classes import Task
from db.constants import USER_LOOKUP_FIELDS
from enums import ActivityTypes from enums import ActivityTypes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Database: class Database:
"""Класс для работы с SQLite базой данных приложения.
Обеспечивает сохранение и обновление данных из Clockify и Redmine.
"""
def __init__(self, db_name: str, sql_path: str): def __init__(self, db_name: str, sql_path: str):
self.con = sq.connect(db_name) self.con = sq.connect(db_name)
cursor = self.con.cursor() self.cursor = self.con.cursor()
try: try:
with open(sql_path, 'r', encoding='utf-8') as f: with open(sql_path, 'r', encoding='utf-8') as f:
sql_script = f.read() sql_script = f.read()
cursor.executescript(sql_script) self.cursor.executescript(sql_script)
self.con.commit() self.con.commit()
logger.info("База данных успешно инициализирована!") logger.info("База данных успешно инициализирована!")
except Exception as e: except Exception as e:
logger.error("Ошибка при инициализации БД:", e) logger.error("Ошибка при инициализации БД: ", e)
self.con.rollback() self.con.rollback()
def insert_activities(self, activities: list[Activity]): def run_migrations(self, migrations_path: str):
cur = self.con.cursor() """Выполняет новые миграции и сохраняет время их применения."""
is_updated = True self.cursor.execute(
for act in activities: """CREATE TABLE IF NOT EXISTS migration (
if is_updated: name TEXT PRIMARY KEY,
tags = cur.execute("SELECT * FROM tags WHERE id = ?", (act.tag_id, )).fetchall() executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
vetro_projects = cur.execute("SELECT * FROM vetro_projects WHERE id = ?", (act.vetro_project_id, )).fetchall() )"""
if not tags or not vetro_projects: )
is_updated = False self.con.commit()
applied = {
row[0]
for row in self.cursor.execute("SELECT name FROM migration").fetchall()
}
for path in sorted(Path(migrations_path).glob("*.py")):
if path.name.startswith("_") or path.name in applied:
continue
try: try:
cur.execute( spec = spec_from_file_location(f"migration_{path.stem}", path)
"INSERT INTO activities VALUES(?,?,?,?,?,?,?,?,?)", if not spec or not spec.loader:
raise ImportError(f"Не удалось загрузить миграцию {path.name}")
module = module_from_spec(spec)
spec.loader.exec_module(module)
module.upgrade(self.con)
self.cursor.execute(
"INSERT INTO migration(name) VALUES(?)",
(path.name,),
)
self.con.commit()
except Exception:
self.con.rollback()
logger.exception("Не удалось выполнить миграцию %s", path.name)
raise
logger.info("Миграция %s успешно выполнена", path.name)
def insert_activities(self, activities: list[Activity]):
"""Сохраняет в базу данных активности Clockify"""
try:
for act in activities:
self.cursor.execute(
"""INSERT INTO activity(
id,
description,
time_spent,
date_start,
date_end,
is_tracked,
vetro_project_id,
tag_id,
task_id,
project_id,
author_id
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
description = excluded.description,
time_spent = excluded.time_spent,
date_start = excluded.date_start,
date_end = excluded.date_end,
is_tracked = excluded.is_tracked,
vetro_project_id = excluded.vetro_project_id,
tag_id = excluded.tag_id,
task_id = excluded.task_id,
project_id = excluded.project_id,
author_id = excluded.author_id""",
( (
act.id, act.id,
act.description, act.description,
@@ -44,62 +105,266 @@ class Database:
act.is_tracked, act.is_tracked,
act.vetro_project_id, act.vetro_project_id,
act.tag_id, act.tag_id,
act.project_id,
)
)
self.con.commit()
except Exception as e:
logger.debug(e)
if not is_updated:
logging.info("База данных устарела. Рекомендуем обновить с помощью флага -i [--init-db]")
# Доделать
def insert_time_entries(self, activities: list[Activity]):
cur = self.con.cursor()
for act in activities:
try:
cur.execute(
"""INSERT INTO time_entry
(date, description, activity_id, time_spent, task_id, activity_type_id)
VALUES(?,?,?,?,?,?)""",
(
act.date_start.date(),
act.description,
act.id,
act.time_spent,
act.task_id, act.task_id,
ActivityTypes.DEVELOPMENT act.project_id,
act.author_id,
) )
) )
self.con.commit() except sq.Error as e:
except Exception as e: self.con.rollback()
logger.debug(e) logger.error(f"Не удалось сохранить активности: {e}")
else:
self.con.commit()
def insert_time_entry(
self,
activity: Activity,
source_activities: list[Activity],
redmine_time_entry_id: int,
):
"""Сохраняет запись Redmine и связывает с ней активности Clockify."""
try:
self.cursor.execute(
"""INSERT INTO time_entry
(id, date, description, time_spent, task_id, activity_type_id, author_id)
VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
date = excluded.date,
description = excluded.description,
time_spent = excluded.time_spent,
task_id = excluded.task_id,
activity_type_id = excluded.activity_type_id,
author_id = excluded.author_id""",
(
redmine_time_entry_id,
activity.date_start.date(),
activity.description,
activity.time_spent,
activity.task_id,
ActivityTypes.DEVELOPMENT,
activity.author_id,
)
)
self.cursor.executemany(
"UPDATE activity SET time_entry_id = ? WHERE id = ?",
[
(redmine_time_entry_id, source_activity.id)
for source_activity in source_activities
],
)
except sq.IntegrityError as e:
self.con.rollback()
logger.error(f"Не удалось сохранить трудочасы: {e}")
else:
self.con.commit()
def insert_tags(self, tags: list[Tag]): def insert_tags(self, tags: list[Tag]):
cur = self.con.cursor() """Сохраняет в базу данных теги Clockify"""
for tag in tags: try:
try: for tag in tags:
cur.execute("INSERT INTO tags VALUES(?, ?)",(tag.id,tag.title)) self.cursor.execute(
self.con.commit() """INSERT INTO tag VALUES(?, ?)
except Exception as e: ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
logger.debug(e) (tag.id, tag.title)
)
except sq.Error as e:
self.con.rollback()
logger.error(f"Не удалось сохранить теги: {e}")
else:
self.con.commit()
def insert_vetro_projects(self, vetro_projects: list[VetroProject]): def insert_vetro_projects(self, vetro_projects: list[VetroProject]):
cur = self.con.cursor() """Сохраняет в базу данных Проекты (ДО) Ветро Clockify"""
for vetro_project in vetro_projects: if not vetro_projects:
try: return
cur.execute("INSERT INTO vetro_projects VALUES(?, ?)", (vetro_project.id, vetro_project.title)) try:
self.con.commit() for vetro_project in vetro_projects:
except Exception as e: self.cursor.execute(
logger.debug(e) """INSERT INTO vetro_project VALUES(?, ?)
ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
(vetro_project.id, vetro_project.title)
)
except sq.Error as e:
self.con.rollback()
logger.error(f"Не удалось сохранить проекты Ветро: {e}")
else:
self.con.commit()
def insert_activity_types(self, activity_types: list[ActivityType]): def insert_activity_types(self, activity_types: list[ActivityType]):
cur = self.con.cursor() """Сохраняет в базу данных Деятельности Redmine"""
for activity_type in activity_types: try:
try: for activity_type in activity_types:
cur.execute("INSERT INTO activity_types VALUES(?, ?)", (activity_type.id, activity_type.title)) self.cursor.execute(
self.con.commit() """INSERT INTO activity_type VALUES(?, ?)
except Exception as e: ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
logger.debug(e) (activity_type.id, activity_type.title)
)
except sq.Error as e:
self.con.rollback()
logger.error(f"Не удалось сохранить типы деятельности: {e}")
else:
self.con.commit()
def insert_user(self, **kwargs):
"""Сохраняет в базу данных пользователя"""
try:
self.cursor.execute("""INSERT INTO user(
clockify_user_id, clockify_email, clockify_username,
redmine_user_id, redmine_email, first_name, last_name, redmine_username)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(clockify_user_id) DO UPDATE SET
clockify_email = excluded.clockify_email,
clockify_username = excluded.clockify_username,
redmine_email = excluded.redmine_email,
first_name = excluded.first_name,
last_name = excluded.last_name,
redmine_username = excluded.redmine_username""",
(
kwargs["clockify_user_id"],
kwargs["clockify_email"],
kwargs["clockify_username"],
kwargs["redmine_user_id"],
kwargs["redmine_email"],
kwargs["first_name"],
kwargs["last_name"],
kwargs["redmine_username"],
)
)
except sq.Error as e:
self.con.rollback()
logger.error(f"Не удалось сохранить пользователя: {e}")
else:
self.con.commit()
def insert_tasks(self, tasks: list[Task]):
"""Сохраняет в БД Задачи из Redmine"""
try:
for task in tasks:
self.cursor.execute("""
INSERT INTO task(
"id",
"subject",
"description",
"status",
"priority",
"author",
"tracker",
"project"
)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
"subject" = excluded."subject",
"description" = excluded."description",
"status" = excluded."status",
"priority" = excluded."priority",
"author" = excluded."author",
"tracker" = excluded."tracker",
"project" = excluded."project" """,
[
task.id,
task.subject,
task.description,
task.status,
task.priority,
task.author,
task.tracker,
task.project,
])
except sq.Error as e:
self.con.rollback()
logger.error(f"Не удалось сохранить задачи: {e}")
else:
self.con.commit()
def get_user_by(self, by_field: str, value: str):
"""Получает пользователя из бд"""
if by_field not in USER_LOOKUP_FIELDS:
raise ValueError(f"Недопустимое поле поиска пользователя: {by_field}")
try:
user = self.cursor.execute(f"SELECT * FROM user WHERE {by_field} = ?", (value,)).fetchone()
if not user:
logger.error(f"Пользователь не найден: {by_field}={value}")
return None
return {
"id": user[0],
"first_name": user[1],
"last_name": user[2],
"clockify_user_id": user[3],
"redmine_user_id": user[4],
"clockify_email": user[5],
"clockify_username": user[6],
"redmine_email": user[7],
"redmine_username": user[8]
}
except Exception as e:
logger.error(e)
def get_tags(self) -> list[Tag]:
"""Получает теги из бд"""
try:
return [
Tag(
id=tag[0],
title=tag[1]
)
for tag in self.cursor.execute("SELECT * FROM tag").fetchall()
]
except Exception as e:
logger.error(e)
def get_tasks(self) -> list[Task]:
"""Получает задачи из бд"""
try:
return [
Task(**{
"id": task[0],
"subject": task[1],
"description": task[2],
"status": task[3],
"priority": task[4],
"author": task[5],
"tracker": task[6],
"project": task[7]
}) for task in self.cursor.execute("SELECT * FROM task").fetchall()
]
except Exception as e:
logger.error(e)
def get_vetro_projects(self) -> list[VetroProject]:
"""Получает Проекты Ветро из бд"""
try:
return [
VetroProject(**{
"id": vetro_project[0],
"title": vetro_project[1]
}) for vetro_project in self.cursor.execute("SELECT * FROM vetro_project").fetchall()
]
except Exception as e:
logger.error(e)
def get_activity_types(self) -> list[ActivityType]:
"""Получает Виды деятельности из бд"""
try:
return [
ActivityType(**{
"id": activity_type[0],
"title": activity_type[1]
}) for activity_type in self.cursor.execute("SELECT * FROM activity_type").fetchall()
]
except Exception as e:
logger.error(e)
def has_time_entry(self, activity_id: str) -> bool:
"""Проверяет, есть ли запись о времени для активности"""
try:
result = self.cursor.execute(
"SELECT time_entry_id FROM activity WHERE id = ? AND time_entry_id IS NOT NULL",
(activity_id,),
).fetchone()
return bool(result)
except Exception as e:
logger.error(e)
return False

5
env.example Normal file
View File

@@ -0,0 +1,5 @@
REDMINE_TOKEN=
CLOCKIFY_TOKEN=
USER_ID=
WORKSPACE_ID=
TRIAL_END_DATE=

95
main.py Normal file
View File

@@ -0,0 +1,95 @@
import logging
import re
import sys
from config.redmine import REDMINE_ISSUE_URL_TEMPLATE
class ConsoleFormatter(logging.Formatter):
"""Форматирует консольные логи и делает номера задач Redmine кликабельными."""
ISSUE_PATTERN = re.compile(r"#(?P<issue_id>\d+)")
def format(self, record):
message = super().format(record)
return self.ISSUE_PATTERN.sub(self._format_issue_link, message)
def _format_issue_link(self, match):
issue_id = match.group("issue_id")
issue_text = match.group(0)
issue_url = REDMINE_ISSUE_URL_TEMPLATE.format(issue_id=issue_id)
return f"\033]8;;{issue_url}\033\\{issue_text}\033]8;;\033\\"
def configure_logging(level: int = logging.INFO):
"""Настраивает короткий консольный лог и подробный файловый лог."""
file_handler = logging.FileHandler('app.log', encoding='utf-8')
file_handler.setFormatter(
logging.Formatter('%(asctime)s %(name)s:%(lineno)d %(levelname)s %(message)s')
)
file_handler.setLevel(level)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(ConsoleFormatter('%(message)s'))
console_handler.setLevel(level)
logging.basicConfig(
level=level,
handlers=[file_handler, console_handler],
force=True,
)
from api.clockify import ClockifyAPI # noqa: E402
from api.redmine import RedmineAPI # noqa: E402
from config import arg_parser, config # noqa: E402
from db.db import Database # noqa: E402
from services.tracking import TrackingService # noqa: E402
from utils import check_envs, db_update, get_start_end_dates # noqa: E402
def main():
"""Запускает CLI-сценарий переноса трудочасов из Clockify в Redmine."""
args = arg_parser.parse_args()
log_level = logging.DEBUG if args.debug else logging.INFO
configure_logging(log_level)
if is_debug := args.debug:
config.MODE = "debug"
if is_dry_run := args.dry_run:
config.MODE = "debug"
logger = logging.getLogger(__name__)
db = Database(config.DB_NAME, config.SQL_PATH)
red = RedmineAPI(config.REDMINE_TOKEN)
clock = ClockifyAPI(
token=config.CLOCKIFY_TOKEN,
workspace_id=config.WORKSPACE_ID,
user_id=config.USER_ID,
db=db,
redmine_api=red,
)
start, end = get_start_end_dates(args)
if not check_envs(clock):
return
if args.init_db:
db.run_migrations(config.MIGRATIONS_PATH)
db_update(clock, db, red)
return
try:
TrackingService(
clock=clock,
red=red,
db=db,
is_debug=is_debug,
is_dry_run=is_dry_run,
).track_period(start=start, end=end, page_size=args.page_size)
except Exception as e:
logger.error(e)
if __name__ == "__main__":
main()

1
migrations/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Миграции локальной базы данных приложения."""

View File

@@ -0,0 +1,46 @@
"""Меняет связь Redmine и Clockify с один-к-одному на один-ко-многим."""
import sqlite3
def upgrade(connection: sqlite3.Connection):
"""Переносит внешний ключ записи Redmine в активности Clockify."""
connection.execute(
"""ALTER TABLE activity
ADD COLUMN time_entry_id INTEGER REFERENCES time_entry(id)"""
)
connection.execute(
"""UPDATE activity
SET time_entry_id = (
SELECT time_entry.id
FROM time_entry
WHERE time_entry.activity_id = activity.id
LIMIT 1
)"""
)
connection.execute(
"""CREATE TABLE time_entry_new (
id INTEGER PRIMARY KEY,
description VARCHAR NOT NULL,
date DATETIME NOT NULL,
time_spent NUMERIC NOT NULL,
task_id INTEGER NOT NULL,
activity_type_id INTEGER NOT NULL,
author_id INTEGER NOT NULL,
FOREIGN KEY (author_id) REFERENCES user(id),
FOREIGN KEY (task_id) REFERENCES task(id),
FOREIGN KEY (activity_type_id) REFERENCES activity_type(id)
)"""
)
connection.execute(
"""INSERT INTO time_entry_new(
id, description, date, time_spent, task_id, activity_type_id, author_id
)
SELECT id, description, date, time_spent, task_id, activity_type_id, author_id
FROM time_entry"""
)
connection.execute("DROP TABLE time_entry")
connection.execute("ALTER TABLE time_entry_new RENAME TO time_entry")
connection.execute(
"CREATE INDEX activity_time_entry_id_idx ON activity(time_entry_id)"
)

27
pyproject.toml Normal file
View File

@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "redmine-tracker"
version = "0.1.0"
description = "CLI для переноса трудочасов из Clockify в Redmine"
readme = "README.MD"
requires-python = ">=3.12"
dependencies = [
"colorama==0.4.6",
"pydantic==2.11.4",
"pydantic-settings==2.9.1",
"python-dotenv==1.1.0",
"requests==2.32.3",
"tqdm==4.67.1",
]
[project.scripts]
redmine-tracker = "main:main"
[tool.setuptools]
py-modules = ["main", "track", "utils", "enums", "exceptions"]
[tool.setuptools.packages.find]
include = ["api", "classes", "config", "db", "services", "migrations"]

1
services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Сервисы бизнес-сценариев приложения."""

134
services/tracking.py Normal file
View File

@@ -0,0 +1,134 @@
"""Сервис переноса трудочасов из Clockify в Redmine."""
import logging
from colorama import Fore
from classes import Activity, AggregatedActivity
from utils import format_date, format_hours
logger = logging.getLogger(__name__)
def aggregate_activities(activities: list[Activity]) -> list[AggregatedActivity]:
"""Объединяет одинаковые активности и суммирует их трудозатраты."""
grouped_activities = {}
for activity in activities:
key = (
activity.author_id,
activity.task_id,
activity.tag_id,
activity.project_id,
activity.vetro_project_id,
activity.date_start.date(),
)
grouped_activities.setdefault(key, []).append(activity)
result = []
for grouped in grouped_activities.values():
descriptions = list(
dict.fromkeys(
description.rstrip(".")
for activity in grouped
if (description := activity.description.strip())
)
)
result.append(
AggregatedActivity(
**{
**grouped[0].__dict__,
"description": ". ".join(descriptions),
"time_spent": sum(activity.time_spent for activity in grouped),
},
source_activities=tuple(grouped),
)
)
return result
class TrackingService:
"""Координирует загрузку, сохранение и перенос активностей в Redmine."""
def __init__(self, clock, red, db, is_debug: bool, is_dry_run: bool = False):
self.clock = clock
self.red = red
self.db = db
self.is_debug = is_debug
self.is_dry_run = is_dry_run
def track_period(self, start, end, page_size: int | None):
"""Переносит активности за период и возвращает список найденных активностей."""
self._save_current_user()
activities = sorted(
self.clock.get_activities(
**{
"start": format_date(start),
"end": format_date(end),
"page-size": page_size,
},
),
key=lambda x: x.date_start,
)
if not activities:
logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET)
return []
activities_to_track = aggregate_activities(
[
activity
for activity in activities
if not activity.is_tracked and activity.date_end
]
)
if self.is_dry_run:
self._log_preview(activities, activities_to_track)
return activities
self.db.insert_activities(activities)
self.red.track_activities(
activities_to_track,
on_success=None if self.is_debug else self._save_tracked_activity,
)
return activities
def _save_current_user(self):
"""Сохраняет связку текущих пользователей Clockify и Redmine."""
user = self.clock.get_user()
clock_user = {
"clockify_user_id": user["id"],
"clockify_email": user["email"],
"clockify_username": user["username"],
}
user = self.red.get_user()
red_user = {
"redmine_user_id": user["id"],
"redmine_email": user["email"],
"first_name": user["first_name"],
"last_name": user["last_name"],
"redmine_username": user["username"],
}
self.db.insert_user(**clock_user, **red_user)
def _save_tracked_activity(self, activity, redmine_time_entry_id: int):
"""Сохраняет активность как успешно перенесенную в Redmine."""
source_activities = list(activity.source_activities or (activity,))
for source_activity in source_activities:
source_activity.is_tracked = True
self.db.insert_activities(source_activities)
self.db.insert_time_entry(
activity,
source_activities,
redmine_time_entry_id,
)
def _log_preview(self, activities, activities_to_track):
"""Выводит план переноса активностей без сохранения и отправки."""
total_hours = sum(activity.time_spent for activity in activities_to_track)
logger.info(f"{Fore.YELLOW}Предпросмотр переноса трудочасов{Fore.RESET}")
logger.info(f"Найдено активностей: {len(activities)}")
logger.info(f"Готово к переносу: {len(activities_to_track)}")
logger.info(f"Всего часов: {format_hours(total_hours)}")
for activity in activities_to_track:
logger.info(
f"{activity.date_start.date()} | #{activity.task_id} | "
f"{format_hours(activity.time_spent)} | {activity.description}"
)

78
tests/test_migrations.py Normal file
View File

@@ -0,0 +1,78 @@
"""Тесты применения миграций локальной базы данных."""
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest import TestCase
from db.db import Database
class MigrationTestCase(TestCase):
"""Проверяет миграцию связи Redmine с активностями Clockify."""
def test_migration_creates_one_to_many_relation_and_runs_once(self):
"""Миграция переносит связь и не выполняется повторно."""
with TemporaryDirectory() as directory:
db_path = Path(directory) / "tracker.db"
db = Database(str(db_path), "config/RedmineTracker.sql")
db.run_migrations("migrations")
db.run_migrations("migrations")
activity_columns = {
row[1]
for row in db.cursor.execute("PRAGMA table_info(activity)").fetchall()
}
time_entry_columns = {
row[1]
for row in db.cursor.execute("PRAGMA table_info(time_entry)").fetchall()
}
migration = db.cursor.execute(
"SELECT name, executed_at FROM migration"
).fetchall()
activity_foreign_keys = {
row[3]: row[2]
for row in db.cursor.execute(
"PRAGMA foreign_key_list(activity)"
).fetchall()
}
self.assertIn("time_entry_id", activity_columns)
self.assertNotIn("activity_id", time_entry_columns)
self.assertEqual(activity_foreign_keys["time_entry_id"], "time_entry")
self.assertEqual(len(migration), 1)
self.assertEqual(
migration[0][0],
"a7f3c9e1_redmine_clockify_relation.py",
)
self.assertIsNotNone(migration[0][1])
db.con.close()
def test_multiple_activities_can_reference_one_time_entry(self):
"""Несколько активностей Clockify могут ссылаться на одну запись Redmine."""
with TemporaryDirectory() as directory:
db_path = Path(directory) / "tracker.db"
db = Database(str(db_path), "config/RedmineTracker.sql")
db.run_migrations("migrations")
db.cursor.execute("PRAGMA foreign_keys = OFF")
db.cursor.execute(
"""INSERT INTO time_entry(
id, description, date, time_spent,
task_id, activity_type_id, author_id
) VALUES(42, 'Работа', '2026-07-14', 1, 51906, 9, 1)"""
)
for activity_id in ("first", "second"):
db.cursor.execute(
"""INSERT INTO activity(
id, description, author_id, time_spent, date_start,
tag_id, time_entry_id
) VALUES(?, 'Работа', 1, 0.5, '2026-07-14', 'tag', 42)""",
(activity_id,),
)
count = db.cursor.execute(
"SELECT COUNT(*) FROM activity WHERE time_entry_id = 42"
).fetchone()[0]
self.assertEqual(count, 2)
db.con.close()

43
tests/test_redmine.py Normal file
View File

@@ -0,0 +1,43 @@
"""Тесты выборочной загрузки задач Redmine."""
from unittest import TestCase
from unittest.mock import Mock, patch
import requests
from api.redmine import RedmineAPI
class RedmineAPITestCase(TestCase):
"""Проверяет запрос только необходимых приложению задач."""
def test_get_issues_requests_only_given_issue_ids(self):
"""Задачи загружаются отдельно и только по номерам тегов Clockify."""
redmine = RedmineAPI.__new__(RedmineAPI)
redmine.get_issue = Mock(side_effect=lambda issue_id: issue_id)
result = redmine.get_issues({60130, 51906})
self.assertEqual(result, [51906, 60130])
self.assertEqual(
[call.args[0] for call in redmine.get_issue.call_args_list],
[51906, 60130],
)
def test_get_issues_skips_unavailable_issue(self):
"""Недоступная задача не мешает загрузить остальные указанные задачи."""
redmine = RedmineAPI.__new__(RedmineAPI)
redmine.get_issue = Mock(side_effect=[51906, None])
self.assertEqual(redmine.get_issues({51906, 60130}), [51906])
@patch("api.redmine.requests.get")
def test_get_issue_returns_none_on_timeout(self, get):
"""Сетевой таймаут одной задачи не прерывает обновление БД."""
get.side_effect = requests.ConnectTimeout("Redmine недоступен")
redmine = RedmineAPI.__new__(RedmineAPI)
redmine.base_url = "https://redmine.example/"
redmine.issue = "issues/{id}.json"
redmine.token = "token"
self.assertIsNone(redmine.get_issue(38243))

114
tests/test_tracking.py Normal file
View File

@@ -0,0 +1,114 @@
"""Тесты агрегации активностей Clockify перед переносом в Redmine."""
from datetime import datetime
from unittest import TestCase
from unittest.mock import Mock
from classes import Activity
from services.tracking import TrackingService, aggregate_activities
class TrackingServiceTestCase(TestCase):
"""Проверяет объединение и сохранение активностей Clockify."""
def create_activity(self, activity_id: str, description: str, **changes) -> Activity:
"""Создаёт активность с типовыми значениями для теста."""
values = {
"id": activity_id,
"author_id": 1,
"vetro_project_id": "vetro-project",
"task_id": 51906,
"tag_id": "tag",
"description": description,
"project_id": "project",
"time_spent": 0.5,
"date_start": datetime(2026, 7, 14, 9),
"date_end": datetime(2026, 7, 14, 9, 30),
}
values.update(changes)
return Activity(**values)
def test_fully_matching_activities_are_merged(self):
"""Полностью одинаковые активности образуют одну запись без повтора описания."""
activities = [
self.create_activity("first", "Доработки"),
self.create_activity(
"second",
"Доработки",
date_start=datetime(2026, 7, 14, 10),
date_end=datetime(2026, 7, 14, 10, 30),
),
]
result = aggregate_activities(activities)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].time_spent, 1)
self.assertEqual(result[0].description, "Доработки")
self.assertEqual(result[0].source_activities, tuple(activities))
def test_descriptions_are_joined_for_otherwise_matching_activities(self):
"""Разные описания одинаковых активностей объединяются через точку."""
result = aggregate_activities(
[
self.create_activity("first", "Доработки"),
self.create_activity("second", "Влил в тест.", time_spent=0.25),
self.create_activity("third", "Доработки", time_spent=0.25),
]
)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].time_spent, 1)
self.assertEqual(result[0].description, "Доработки. Влил в тест")
def test_activities_for_different_dates_are_not_merged(self):
"""Активности за разные даты остаются отдельными записями Redmine."""
result = aggregate_activities(
[
self.create_activity("first", "Доработки"),
self.create_activity(
"second",
"Доработки",
date_start=datetime(2026, 7, 15, 9),
date_end=datetime(2026, 7, 15, 9, 30),
),
]
)
self.assertEqual(len(result), 2)
def test_all_source_activities_are_marked_as_tracked(self):
"""После успешной отправки сохраняются все исходные таймеры Clockify."""
activities = [
self.create_activity("first", "Доработки"),
self.create_activity("second", "Влил в тест"),
]
aggregated_activity = aggregate_activities(activities)[0]
db = Mock()
service = TrackingService(
clock=Mock(),
red=Mock(),
db=db,
is_debug=False,
)
service._save_tracked_activity(aggregated_activity, 42)
self.assertTrue(all(activity.is_tracked for activity in activities))
db.insert_activities.assert_called_once_with(activities)
db.insert_time_entry.assert_called_once_with(
aggregated_activity,
activities,
42,
)
def test_activities_for_different_tasks_are_not_merged(self):
"""Активности разных задач остаются отдельными записями Redmine."""
result = aggregate_activities(
[
self.create_activity("first", "Доработки"),
self.create_activity("second", "Доработки", task_id=60130),
]
)
self.assertEqual(len(result), 2)

View File

@@ -1,75 +1,6 @@
import logging """Совместимая точка входа для старых запусков через track.py."""
import sys
logging.basicConfig( from main import main
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('app.log', encoding='utf-8'),
logging.StreamHandler(sys.stdout)
]
)
from api import ClockifyAPI, RedmineAPI # noqa: E402
from config import arg_parser, config, db # noqa: E402
from utils import format_date, str_to_date, today_start, today_end # noqa: E402
WORKSPACE_ID = config.CLOCKIFY_WORKSPACE_ID
USER_ID = config.CLOCKIFY_USER_ID
def main():
args = arg_parser.parse_args()
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
logger = logging.getLogger(__name__)
start, end = None, None
if args.start:
start = str_to_date(args.start)
if args.end:
end = str_to_date(args.end)
if args.start is None and args.end is None:
logger.info("Парсим задачи за сегодняшний день")
start = today_start()
end = today_end()
else:
logger.info("Парсим задачи" + (f" с {start.date()} " if start else " ") + (f"до {end.date()}" if end else ""))
clock = ClockifyAPI(
token=config.CLOCKIFY_TOKEN,
workspace_id=WORKSPACE_ID,
user_id=USER_ID
)
red = RedmineAPI(config.REDMINE_TOKEN)
if args.init_db:
logger.info("Обновление базы данных...")
db.insert_tags(clock.get_tags())
db.insert_vetro_projects(clock.get_vetro_projects())
db.insert_activity_types(red.get_time_entry_activities())
activities = clock.get_activities(
**{
"start": format_date(start),
"end": format_date(end),
"page-size": args.page_size,
},
)
db.insert_activities(activities)
if args.debug:
logger.debug("Пропускаем этап загрузки трудочасов")
return
try:
tracked_activities = red.track_activities(activities)
clock.mark_tracked(tracked_activities)
db.insert_time_entries(tracked_activities)
except Exception as e:
logger.error(e)
if __name__ == "__main__": if __name__ == "__main__":

150
utils.py
View File

@@ -1,10 +1,30 @@
from argparse import Namespace
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional import logging
import os
import re
from colorama import Fore
from config import config from config import config
logger = logging.getLogger(__name__)
def time_to_hour(time: timedelta) -> float: def time_to_hour(time: timedelta) -> float:
return time.total_seconds() / 3600 return round(time.total_seconds() / 3600, 3)
def format_hours(hours: float) -> str:
"""Форматирует десятичные часы и длительность без нулевых старших единиц."""
total_seconds = round(hours * 3600)
hours_part, remaining_seconds = divmod(total_seconds, 3600)
minutes_part, seconds_part = divmod(remaining_seconds, 60)
if hours_part:
duration = f"{hours_part}:{minutes_part:02}:{seconds_part:02}"
elif minutes_part:
duration = f"{minutes_part}:{seconds_part:02}"
else:
duration = str(seconds_part)
return f"{hours:.3f} ч ({duration})"
def today_start() -> datetime: def today_start() -> datetime:
td = datetime.today() td = datetime.today()
@@ -18,13 +38,133 @@ def today_end() -> datetime:
year = td.year year = td.year
month = td.month month = td.month
day = td.day day = td.day
return datetime(year=year, month=month, day=day, hour=23) return datetime(year=year, month=month, day=day, hour=23, minute=59, second=59)
def str_to_date(date: str) -> Optional[datetime]: def str_to_date(date: str) -> datetime | None:
if date: if date:
return datetime.strptime(date + "T00:00:00Z", config.DATETIME_FORMAT) return datetime.strptime(date + "T00:00:00Z", config.DATETIME_FORMAT)
def format_date(date: datetime) -> datetime: def format_date(date: datetime) -> str | None:
if date: if date:
return date.strftime(config.DATETIME_FORMAT) return date.strftime(config.DATETIME_FORMAT)
def parse_task_id(tag: str) -> int:
if match := re.search(r"^(?P<task_id>\d{5})", tag):
return int(match.group("task_id"))
raise ValueError(f'Тег "{tag}" должен начинаться с номера задачи из 5 цифр')
def db_update(clock, db, red):
"""Обновляет локальную БД данными из Clockify и Redmine."""
logger.info(Fore.YELLOW + "Обновление базы данных..." + Fore.RESET)
try:
# Обновление Тегов
logger.info(f"{Fore.YELLOW}Обновление Тегов...\n{Fore.RESET}")
db_tags = db.get_tags() or []
new_tags = []
clock_tags = clock.get_tags(is_updating=True, **{"page-size": clock.tags_count})
if clock_tags:
new_tags = [tag for tag in clock_tags if tag not in db_tags]
if new_tags:
try:
db.insert_tags(new_tags)
logger.info(f"{Fore.GREEN}Обновлено тегов: {len(new_tags)}{Fore.RESET}")
except Exception as e:
logger.error(f"{Fore.RED}Ошибка при сохранении тегов!{Fore.RESET}")
else:
logger.info(f"{Fore.YELLOW}Обновление тегов не требуется.\n{Fore.RESET}")
# Обновление Задач
logger.info(f"{Fore.YELLOW}Обновление Задач...\n{Fore.RESET}")
clock_task_ids = {
parse_task_id(tag.title)
for tag in [*db_tags, *new_tags]
}
red_tasks = red.get_issues(clock_task_ids)
if red_tasks:
db_tasks = db.get_tasks() or []
new_tasks = [task for task in red_tasks if task not in db_tasks]
if new_tasks:
try:
db.insert_tasks(new_tasks)
logger.info(f"{Fore.GREEN}Обновлено задач: {len(new_tasks)}{Fore.RESET}")
except Exception as e:
logger.error(f"{Fore.RED}Ошибка при сохранении задач!{Fore.RESET}")
else:
logger.info(f"{Fore.YELLOW}Обновление задач не требуется\n{Fore.RESET}")
# Обновление Проектов Ветро
logger.info(f"{Fore.YELLOW}Обновление Проектов Ветро{Fore.RESET}")
clock_projects = clock.get_vetro_projects()
if clock_projects:
db_projects = db.get_vetro_projects()
new_projects = [project for project in clock_projects if project not in db_projects]
if new_projects:
try:
db.insert_vetro_projects(new_projects)
logger.info(f"{Fore.GREEN}Обновлено Проектов Ветро: {len(new_projects)}{Fore.RESET}")
except Exception as e:
logger.error(f"{Fore.RED}Ошибка при сохранении Проектов Ветро!{Fore.RESET}")
else:
logger.info(f"{Fore.YELLOW}Обновление Проектов Ветро не требуется\n{Fore.RESET}")
# Обновление Типов деятельности
logger.info(f"{Fore.YELLOW}Обновление Типов деятельности{Fore.RESET}")
red_types = red.get_time_entry_activities()
if red_types:
db_types = db.get_activity_types()
new_types = [type_ for type_ in red_types if type_ not in db_types]
if new_types:
try:
db.insert_activity_types(new_types)
logger.info(f"{Fore.GREEN}Обновлено Типов деятельности: {len(new_types)}{Fore.RESET}")
except Exception as e:
logger.error(f"{Fore.RED}Ошибка при сохранении Типов деятельности!{Fore.RESET}")
else:
logger.info(f"{Fore.YELLOW}Обновление Типов деятельности не требуется\n{Fore.RESET}")
except Exception as e:
logger.error(Fore.RED + f"Ошибка при обновлении базы данных: {e}" + Fore.RESET)
else:
logger.info(Fore.GREEN + "База данных теперь актуальна." + Fore.RESET)
def get_start_end_dates(args: Namespace):
start, end = None, None
if args.start:
start = str_to_date(args.start)
if args.end:
end = str_to_date(args.end)
if args.start is None and args.end is None:
logger.info("Парсим задачи за сегодняшний день")
start = today_start()
end = today_end()
else:
if start:
from_ = f"{start.day} {config.MONTHS.get(start.month)} {start.year} г."
if end:
to_ = f"{end.day} {config.MONTHS.get(end.month)} {end.year} г."
if start == end:
logger.info(f"{Fore.YELLOW}Парсим задачи за {from_}{Fore.RESET}")
else:
logger.info(Fore.YELLOW + "Парсим задачи" + (f" с {from_} " if start else " ") + (f"до {to_}" if end else "") + Fore.RESET)
return start, end
def check_envs(clock):
"""Проверяет наличие обязательных переменных окружения Clockify."""
if not config.WORKSPACE_ID or not config.USER_ID:
if not os.path.exists(config.ENV_TXT):
logger.info(f"{Fore.YELLOW}Необходимые переменные не были обнаружены. Попытка получения...{Fore.RESET}")
clock.getenvs()
else:
logger.info(f'{Fore.YELLOW}Проверьте файл "{config.ENV_TXT}". Если необходимых переменных в нём нет, то просто удалите его.{Fore.RESET}')
return False
return True