Compare commits

...

53 Commits

Author SHA1 Message Date
b1df6220a5 сделал README независимым от хостинга 2026-07-21 13:48:34 +03:00
bac0fcf216 обновил ссылки на GitHub в README 2026-07-21 13:42:33 +03:00
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
22 changed files with 1303 additions and 406 deletions

View File

@@ -1,4 +1,4 @@
# [Redmine Tracker](https://git.sbps.ru/e.vlasov/redmine_tracker)
# Redmine Tracker
## Описание:
@@ -21,10 +21,8 @@
![Пример](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) и перейдите в него в командной строке:
Склонируйте репозиторий командой из раздела «Code»/«Клонировать» на странице проекта, затем перейдите в его каталог:
```bash
git clone ssh://git@git.sbps.ru:4567/e.vlasov/redmine_tracker.git
cd redmine_tracker
```
Создайте виртуальное окружение и активируйте его
@@ -51,6 +49,10 @@ python -m pip install --upgrade pip
```bash
pip install -r requirements.txt
```
И установите локальную команду запуска:
```bash
pip install -e .
```
Создайте файл .env и добавьте в него переменные:
```bash
nano .env
@@ -82,17 +84,27 @@ activeWorkspace - это `WORKSPACE_ID`
## Запуск.
```
python track.py
redmine-tracker
```
## Аргументы запуска
| Аргумент | Описание | Пример использования |
|-------------------|----------------------------------------------------------------------------------------------|--------------------------------------|
| `-h [--help]` | Показать справку | `python track.py --help` |
| `-s [--start]` | Дата начала парсинга активностей в формате гггг-мм-дд | `python track.py --start 2025-05-19` |
| `-e [--end]` | Дата окончания парсинга активностей в формате гггг-мм-дд | `python track.py -e 2025-05-19` |
| `-p [--page-size]`| Количество задач для вывода. Выведет последние n записей | `python track.py --page-size 15` |
| `-d [--debug]` | Режим отладки. Не загружает трудочасы в Redmine | `python track.py -d` |
| `-i [--init-db]` | Режим инициализации бд.<br>Подгружает с серверов теги, виды деятельности и Проекты (ДО) Ветро| `python track.py -init-db` |
| `-h [--help]` | Показать справку | `redmine-tracker --help` |
| `-s [--start]` | Дата начала парсинга активностей в формате гггг-мм-дд | `redmine-tracker --start 2025-05-19` |
| `-e [--end]` | Дата окончания парсинга активностей в формате гггг-мм-дд | `redmine-tracker -e 2025-05-19` |
| `-p [--page-size]`| Количество задач для вывода. Выведет последние n записей | `redmine-tracker --page-size 15` |
| `-d [--debug]` | Режим отладки. Не загружает трудочасы в Redmine | `redmine-tracker -d` |
| `--dry-run` | Предпросмотр без сохранения активностей и отправки трудочасов | `redmine-tracker --dry-run` |
| `-i [--init-db]` | Выполняет миграции и обновляет справочники без отправки трудочасов в Redmine | `redmine-tracker --init-db` |
[Власов Эдуард](https://github.com/QuickLike)
При запуске с `--init-db` файлы из каталога `migrations` выполняются по имени.
Успешно применённые миграции повторно не запускаются: их имя и время выполнения
сохраняются в таблице `migration`.
Также можно запускать напрямую из корня проекта:
```bash
python main.py
```
[Власов Эдуард](https://github.com/QuickLike)

View File

@@ -5,10 +5,11 @@ from colorama import Fore
import requests
from tqdm import tqdm
from config import ClockifyConfig, config, db
from config import ClockifyConfig, config
from classes import Activity, Tag, VetroProject
from exceptions import InvalidToken
from utils import parse_task_id, time_to_hour
from utils import format_hours, parse_task_id, time_to_hour
from .constants import REQUEST_TIMEOUT
logger = logging.getLogger(__name__)
@@ -19,8 +20,10 @@ class ClockifyAPI(ClockifyConfig):
Позволяет получать активности, теги, проекты ветро и помечать активности затрканными.
"""
def __init__(self, token: str, *args, **kwargs):
def __init__(self, token: str, db, redmine_api=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.db = db
self.redmine_api = redmine_api
self.token = self._check_token(token)
def get_activities(self, is_updating=False, **kwargs) -> list[Activity]:
@@ -32,14 +35,26 @@ class ClockifyAPI(ClockifyConfig):
page-size: int - Размер страницы (по умолчанию 50)
"""
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)}")
time_worked_out_total = 0
tags = self.get_tags(is_updating=is_updating)
if not tags:
logging.info(f"{Fore.YELLOW}В базе данных нет тегов! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}")
logger.info(f"{Fore.YELLOW}В базе данных нет тегов! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}")
return []
tags_by_id = {tag.id: tag for tag in tags}
result = []
for activity in tqdm(activities, desc="Парсинг задач"):
@@ -50,19 +65,36 @@ class ClockifyAPI(ClockifyConfig):
start = datetime.strptime(activity["timeInterval"]["start"], config.DATETIME_FORMAT)
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)
tag = [tag for tag in tags if tag.id == tag_id]
if not tag:
logging.info(f"{Fore.YELLOW}В базе данных нет нужного тега {Fore.GREEN}{tag_id}{Fore.YELLOW}! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}")
break
else:
tag = tag[0]
try:
task_id = parse_task_id(tag.title)
except ValueError:
logger.info(f'{activity["timeInterval"]["start"]}: {activity["description"]} - Неправильный формат тега {tag}')
continue
author = db.get_user_by("clockify_user_id", activity["userId"])
tag = tags_by_id.get(tag_id)
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
result.append(
Activity(
@@ -75,80 +107,137 @@ class ClockifyAPI(ClockifyConfig):
time_spent=time_to_hour((end if end else datetime.utcnow()) - start),
date_start=start,
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"Отработано часов: {time_worked_out_total}")
logger.info(f"Отработано часов: {format_hours(time_worked_out_total)}")
return result
def get_tags(self, is_updating=False, **kwargs) -> list[Tag]:
def get_tags(self, is_updating=False, tag_id="", **kwargs) -> list[Tag] | Tag | None:
"""
Получает с сервера Clockify теги
name: str - optional
archived: bool - optional
page-size: int - optional
page: int - optional
"""
if is_updating:
tags = requests.get(self.base_url + self.tags_url, params={"sort-column": "name", "sort-order": "descending", **kwargs}, headers={"X-API-KEY": self.token}).json()
logging.debug(f"{Fore.YELLOW}Получено тегов: {len(tags)}.{Fore.RESET}")
return [Tag(
id=tag["id"],
title=tag["name"]
) for tag in tags]
return db.get_tags()
page = 1
page_size = int(kwargs.get("page-size", self.tags_count))
result = []
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]:
"""
Получает с сервера Clockify Проекты (ДО) Ветро
"""
projects = requests.get(self.base_url + self.projects_url, headers={"X-API-KEY": self.token}).json()
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:
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(
id=vetro_project["id"],
title=vetro_project["name"]
) for vetro_project in vetro_projects]
# DEPRECATED
def mark_tracked(self, activities: list[Activity]):
"""
Помечает активности затреканными (Billable) на сервере Clockify
"""
for activity in activities:
try:
resp = requests.put(
self.base_url + self.update_time_entry(
activity_id=activity.id
), json={
"billable": True,
"start": activity.date_start.strftime(config.DATETIME_FORMAT),
"end": activity.date_end.strftime(config.DATETIME_FORMAT) if activity.date_end else None,
"description": activity.description,
"tagIds": [activity.tag_id],
"taskId": activity.vetro_project_id,
"projectId": activity.project_id
}, headers={
"X-API-KEY": self.token
}
)
if resp.ok:
logger.debug(resp.json())
logger.info(f"{Fore.GREEN} {activity} помечена как затреканная{Fore.RESET}")
if not config.is_debug:
resp = requests.put(
self.base_url + self.update_time_entry(
activity_id=activity.id
), json={
"billable": True,
"start": activity.date_start.strftime(config.DATETIME_FORMAT),
"end": activity.date_end.strftime(config.DATETIME_FORMAT) if activity.date_end else None,
"description": activity.description,
"tagIds": [activity.tag_id],
"taskId": activity.vetro_project_id,
"projectId": activity.project_id
}, headers={
"X-API-KEY": self.token
},
timeout=REQUEST_TIMEOUT,
)
if config.is_debug or resp.ok:
logger.info(f"{Fore.GREEN}{activity}{Fore.RESET}".ljust(60) + "")
else:
logger.debug(resp.json())
logger.error(f"{Fore.RED} {activity} не помечена как затреканная{Fore.RESET}")
logger.debug(resp.text)
logger.error(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "")
except Exception as e:
logger.error(e)
return
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:
logger.error(resp.json())
logger.error(resp.text)
raise InvalidToken
resp = resp.json()
logger.debug({"id": resp["id"], "email": resp["email"], "name": resp["name"]})
@@ -156,9 +245,13 @@ class ClockifyAPI(ClockifyConfig):
def get_user(self):
"""Получает нужные данные пользователя с сервера Clockify"""
resp = requests.get(self.base_url + self.user_url, headers={"X-API-KEY": self.token})
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.json())
logger.error(resp.text)
raise requests.exceptions.RequestException(Fore.RED + "Ошибка запроса." + Fore.RESET)
json = resp.json()
return {

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

@@ -2,20 +2,21 @@ import logging
from colorama import Fore
import requests
from tqdm import tqdm
from classes.classes import Task
from config import RedmineConfig, config
from classes import Activity, ActivityType
from enums import ActivityTypes
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__)
WORKSPACE_ID = config.WORKSPACE_ID
USER_ID = config.USER_ID
class RedmineAPI(RedmineConfig):
"""API клиент для взаимодействия с Redmine.
@@ -26,64 +27,105 @@ class RedmineAPI(RedmineConfig):
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]:
"""Заносит трудочасы на сервер Redmine"""
is_beginner = config.is_beginner
if is_beginner:
logging.info(f"{Fore.YELLOW}Делим трудочасы пополам{Fore.RESET}")
if len(activities) == 0:
logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET)
return
logger.info(f"{Fore.YELLOW}Делим трудочасы пополам{Fore.RESET}")
tracked = []
for activity in tqdm(activities, desc="Занесение трудочасов"):
if not(not activity.is_tracked and activity.date_end):
continue
failed_activities = []
activities_to_track = [
activity
for activity in activities
if not activity.is_tracked and activity.date_end
]
for activity in activities_to_track:
try:
resp = requests.post(
self.base_url + self.time_entries_url,
headers={"X-Redmine-Api-Key": self.token},
json={
"time_entry": {
"issue_id": activity.task_id,
"hours": activity.time_spent / (2 if is_beginner else 1),
"activity_id": ActivityTypes.DEVELOPMENT,
"comments": activity.description,
"spent_on": activity.date_start.strftime(self.datetime_format)
}
}
)
if resp.ok:
if not config.is_debug:
resp = requests.post(
self.base_url + self.time_entries_url,
headers={"X-Redmine-Api-Key": self.token},
json={
"time_entry": {
"issue_id": activity.task_id,
"hours": round(activity.time_spent / (2 if is_beginner else 1), 3),
"activity_id": self.get_activity_type_id(activity),
"comments": activity.description,
"spent_on": activity.date_start.strftime(self.datetime_format)
}
},
timeout=REQUEST_TIMEOUT,
)
if config.is_debug or resp.ok:
activity.is_tracked = True
if on_success:
on_success(activity, resp.json()["time_entry"]["id"])
tracked.append(activity)
logger.debug(activity)
logger.info(
f"{Fore.GREEN}{self.format_activity_result(activity, is_beginner)}"
f"{Fore.RESET}".ljust(100) + ""
)
else:
logger.error(Fore.RED + f"{activity} Не была затрекана" + Fore.RED)
logger.error(f"{resp.json()['errors']} {resp.status_code}")
try:
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) == len(tracked):
if len(activities_to_track) == len(tracked):
logger.info(Fore.GREEN + "Все задачи были успешно занесены в Redmine" + Fore.RESET)
else:
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
def format_activity_result(self, activity: Activity, is_beginner: bool = False) -> str:
"""Форматирует строку результата переноса активности."""
hours = round(activity.time_spent / (2 if is_beginner else 1), 3)
return f"#{activity.task_id} | {format_hours(hours)} | {activity.description}"
def get_activity_type_id(self, activity: Activity) -> int:
"""Возвращает тип деятельности Redmine для активности Clockify."""
return ACTIVITY_TYPE_BY_VETRO_PROJECT.get(
activity.vetro_project_id,
DEFAULT_ACTIVITY_TYPE,
)
def get_time_entry_activities(self) -> list[ActivityTypes]:
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})
logging.debug(resp.json())
if resp.ok:
return [
ActivityType(id=act.get("id"), title=act.get("name"))
for act in resp.json().get("time_entry_activities")
if act.get("active")
]
resp = requests.get(
self.base_url + self.time_entry_activities,
headers={"X-Redmine-API-KEY": self.token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
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})
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("Invalid Token for RedmineAPI")
logger.error(f"Invalid Token for RedmineAPI: {resp.text}")
raise InvalidToken
json = resp.json()["user"]
return {
@@ -94,18 +136,26 @@ class RedmineAPI(RedmineConfig):
"username": json["login"]
}
def get_issue(self, issue_id: int) -> dict:
def get_issue(self, issue_id: int) -> Task | None:
"""Получает задачу по её id с сервера Redmine"""
resp = requests.get(
self.base_url + self.issues.format(**{"id": issue_id}),
headers={"X-Redmine-Api-Key": self.token},
timeout=None
)
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("Ошибка RedmineAPI")
return {}
logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
return None
issue = resp.json()["issue"]
return {
return Task(**{
"id": issue["id"],
"subject": issue["subject"],
"description": issue["description"],
@@ -114,8 +164,16 @@ class RedmineAPI(RedmineConfig):
"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()
logging.debug(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,6 +6,8 @@ from enums import ClockifyProjects, VetroProjects
@dataclass
class Activity:
"""Описывает одну активность, полученную из Clockify."""
id: str
author_id: int
vetro_project_id: VetroProjects
@@ -19,7 +21,14 @@ class Activity:
is_tracked: bool = False
def __str__(self):
return f'|{self.date_start + timedelta(hours=3)}| {self.description} для задачи #{self.task_id}'
return f'({self.task_id}) - "{self.description}"'[:60]
@dataclass
class AggregatedActivity(Activity):
"""Представляет несколько активностей Clockify одной записью Redmine."""
source_activities: tuple[Activity, ...] = ()
@dataclass
@@ -46,3 +55,17 @@ class VetroProject:
def __str__(self):
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

@@ -3,8 +3,6 @@ from datetime import datetime
from pydantic_settings import BaseSettings
from dotenv import load_dotenv
from db.db import Database
from .clockify import ClockifyConfig # noqa: F401
from .redmine import RedmineConfig # noqa: F401
@@ -17,11 +15,14 @@ class Config(BaseSettings):
USER_ID: str = None
WORKSPACE_ID: str = None
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"
MIGRATIONS_PATH: str = "migrations"
DATETIME_FORMAT: str = "%Y-%m-%dT%H:%M:%SZ"
@@ -44,6 +45,10 @@ class Config(BaseSettings):
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():
@@ -61,6 +66,7 @@ def configure_argument_parser():
parser.add_argument(
'-p',
'--page-size',
type=int,
help='Количество задач для вывода'
)
parser.add_argument(
@@ -69,14 +75,21 @@ def configure_argument_parser():
action="store_true",
help='Режим отладки. Не загружает трудочасы в Redmine'
)
parser.add_argument(
'--dry-run',
action="store_true",
help='Предпросмотр. Показывает активности без их сохранения и отправки в Redmine'
)
parser.add_argument(
'-i',
'--init-db',
action="store_true",
help='Режим инициализации бд.\nПодгружает с серверов теги, виды деятельности и проекты Ветро'
help=(
'Режим инициализации БД. Выполняет миграции и обновляет справочники '
'без отправки трудочасов в Redmine'
)
)
return parser
arg_parser = configure_argument_parser()
config = Config()
db = Database(config.DB_NAME, config.SQL_PATH)

View File

@@ -10,7 +10,7 @@ class ClockifyConfig:
self.user_url = "/user"
self.projects_url = f"{self.workspace_url}/projects"
self.tags_count = 100
self.tags_count = 20
def update_time_entry(self, 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:
def __init__(self):
self.base_url = "https://redmine.sbps.ru"
@@ -5,4 +8,5 @@ class RedmineConfig:
self.my_account_url = "/my/account.json"
self.datetime_format = "%Y-%m-%d"
self.time_entry_activities = "/enumerations/time_entry_activities.json"
self.issues = "/issues/{id}.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",
}
)

418
db/db.py
View File

@@ -1,9 +1,13 @@
import logging
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import sqlite3 as sq
from colorama import Fore
from classes import Activity, ActivityType, Tag, VetroProject
from classes.classes import Task
from db.constants import USER_LOOKUP_FIELDS
from enums import ActivityTypes
logger = logging.getLogger(__name__)
@@ -29,128 +33,179 @@ class Database:
logger.error("Ошибка при инициализации БД: ", e)
self.con.rollback()
def run_migrations(self, migrations_path: str):
"""Выполняет новые миграции и сохраняет время их применения."""
self.cursor.execute(
"""CREATE TABLE IF NOT EXISTS migration (
name TEXT PRIMARY KEY,
executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)"""
)
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:
spec = spec_from_file_location(f"migration_{path.stem}", path)
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"""
is_updated = True
for act in activities:
if is_updated:
tags = self.cursor.execute("SELECT * FROM tag WHERE id = ?", (act.tag_id, )).fetchall()
vetro_project_id = act.vetro_project_id
if vetro_project_id:
vetro_projects = self.cursor.execute("SELECT * FROM vetro_project WHERE id = ?", (vetro_project_id, )).fetchall()
is_updated = tags or vetro_projects
else:
is_updated = bool(tags)
try:
try:
for act in activities:
self.cursor.execute(
"INSERT INTO activity VALUES(?,?,?,?,?,?,?,?,?,?,?)",
"""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.description,
act.time_spent,
act.date_start,
act.date_end,
act.is_tracked,
act.vetro_project_id,
act.tag_id,
act.task_id,
act.project_id,
act.author_id,
act.time_spent,
act.date_start,
act.date_end,
act.is_tracked,
act.vetro_project_id,
act.tag_id,
act.task_id,
act.project_id,
)
)
except sq.IntegrityError:
self.cursor.execute(
"""UPDATE activity
SET description = ?, time_spent = ?, date_start = ?, date_end = ?, is_tracked = ?, vetro_project_id = ?, tag_id = ?, task_id = ?, project_id = ?
WHERE id = ?""",
(
act.description,
act.time_spent,
act.date_start,
act.date_end,
act.is_tracked,
act.vetro_project_id,
act.tag_id,
act.task_id,
act.project_id,
act.id,
)
)
finally:
self.con.commit()
if not is_updated:
logging.info(Fore.YELLOW + "База данных устарела. Рекомендуем обновить с помощью флага -i [--init-db]" + Fore.RESET)
except sq.Error as e:
self.con.rollback()
logger.error(f"Не удалось сохранить активности: {e}")
else:
self.con.commit()
def insert_time_entries(self, activities: list[Activity]):
"""Сохраняет в базу данных трудочасы Redmine"""
for act in activities:
try:
self.cursor.execute(
"""INSERT INTO time_entry
(date, description, activity_id, time_spent, task_id, activity_type_id, author_id)
VALUES(?,?,?,?,?,?,?)""",
(
act.date_start.date(),
act.description,
act.id,
act.time_spent,
act.task_id,
ActivityTypes.DEVELOPMENT,
act.author_id
)
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.con.commit()
except sq.IntegrityError:
pass
)
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]):
"""Сохраняет в базу данных теги Clockify"""
for tag in tags:
try:
self.cursor.execute("INSERT INTO tag VALUES(?, ?)",(tag.id,tag.title))
except sq.IntegrityError:
try:
for tag in tags:
self.cursor.execute(
"""UPDATE tag
SET title = ?
WHERE id = ?""",
(tag.title, tag.id)
"""INSERT INTO tag VALUES(?, ?)
ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
(tag.id, tag.title)
)
finally:
self.con.commit()
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]):
"""Сохраняет в базу данных Проекты (ДО) Ветро Clockify"""
if not vetro_projects:
return
for vetro_project in vetro_projects:
try:
self.cursor.execute("INSERT INTO vetro_project VALUES(?, ?)", (vetro_project.id, vetro_project.title))
except sq.IntegrityError:
self.cursor.execute("""
UPDATE vetro_project
SET title = ?
WHERE id = ?""",
(vetro_project.title, vetro_project.id)
try:
for vetro_project in vetro_projects:
self.cursor.execute(
"""INSERT INTO vetro_project VALUES(?, ?)
ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
(vetro_project.id, vetro_project.title)
)
finally:
self.con.commit()
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]):
"""Сохраняет в базу данных Деятельности Redmine"""
for activity_type in activity_types:
try:
self.cursor.execute("INSERT INTO activity_type VALUES(?, ?)", (activity_type.id, activity_type.title))
except sq.IntegrityError:
self.cursor.execute("""
UPDATE activity_type
SET title = ?
WHERE id = ?""",
(activity_type.title, activity_type.id)
try:
for activity_type in activity_types:
self.cursor.execute(
"""INSERT INTO activity_type VALUES(?, ?)
ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
(activity_type.id, activity_type.title)
)
finally:
self.con.commit()
except sq.Error as e:
self.con.rollback()
logger.error(f"Не удалось сохранить типы деятельности: {e}")
else:
self.con.commit()
def insert_user(self, **kwargs):
"""Сохраняет в базу данных пользователя"""
@@ -158,35 +213,80 @@ class Database:
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(?, ?, ?, ?, ?, ?, ?, ?)""", [v for k, v in kwargs.items()])
except sq.IntegrityError:
self.cursor.execute("""
UPDATE user
SET clockify_email = ?,
clockify_username = ?,
redmine_email = ?,
first_name = ?,
last_name = ?,
redmine_username = ?
WHERE redmine_user_id = ? AND clockify_user_id = ?""",
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"],
kwargs["redmine_user_id"],
kwargs["clockify_user_id"]
)
)
finally:
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],
@@ -199,62 +299,72 @@ class Database:
"redmine_username": user[8]
}
except Exception as e:
logging.error(e)
logger.error(e)
def get_tags(self) -> list[Tag]:
"""Получает теги из бд"""
try:
tags = self.cursor.execute("SELECT * FROM tag").fetchall()
return [
Tag(
id=tag[0],
title=tag[1]
)
for tag in tags
for tag in self.cursor.execute("SELECT * FROM tag").fetchall()
]
except Exception as e:
logging.error(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 insert_tasks(self, tasks: list[dict]):
"""Сохраняет в БД Задачи из Redmine"""
for task in tasks:
try:
self.cursor.execute("""
INSERT INTO task(
"id",
"subject",
"description",
"status",
"priority",
"author",
"tracker",
"project"
)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)""",
[v for k, v in task.items()])
except sq.IntegrityError:
self.cursor.execute("""
UPDATE task
SET "subject" = ?,
"description" = ?,
"status" = ?,
"priority" = ?,
"author" = ?,
"tracker" = ?,
"project" = ?
WHERE "id" = ?""",
(
task["subject"],
task["description"],
task["status"],
task["priority"],
task["author"],
task["tracker"],
task["project"],
task["id"]
)
)
finally:
self.con.commit()
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

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)

121
track.py
View File

@@ -1,124 +1,7 @@
import logging
import os.path
import sys
"""Совместимая точка входа для старых запусков через track.py."""
logging.basicConfig(
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 main import main
from colorama import Fore # noqa: E402
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, parse_task_id # noqa: E402
WORKSPACE_ID = config.WORKSPACE_ID
USER_ID = config.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:
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} г."
logger.info(Fore.YELLOW + "Парсим задачи" + (f" с {from_} " if start else " ") + (f"до {to_}" if end else "") + Fore.RESET)
clock = ClockifyAPI(
token=config.CLOCKIFY_TOKEN,
workspace_id=WORKSPACE_ID,
user_id=USER_ID
)
if not WORKSPACE_ID or not USER_ID:
if not os.path.exists(config.ENV_TXT):
logging.info(f"{Fore.YELLOW}Необходимые переменные не были обнаружены. Попытка получения...{Fore.RESET}")
clock.getenvs()
else:
logger.info(f'{Fore.YELLOW}Проверьте файл "{config.ENV_TXT}". Если необходимых переменных в нём нет, то просто удалите его.{Fore.RESET}')
return
red = RedmineAPI(config.REDMINE_TOKEN)
user = clock.get_user()
clock_user = {
"clockify_user_id": user["id"],
"clockify_email": user["email"],
"clockify_username": user["username"],
}
user = 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"]
}
db.insert_user(**clock_user, **red_user)
if args.init_db:
logger.info(Fore.YELLOW + "Обновление базы данных..." + Fore.RESET)
try:
logging.info(f"{Fore.YELLOW}Получение Тегов{Fore.RESET}")
tags = clock.get_tags(is_updating=True, **{"page-size": clock.tags_count})
logging.info(f"{Fore.YELLOW}Получение Задач с Redmine{Fore.RESET}")
issues = [red.get_issue(parse_task_id(tag.title)) for tag in tags]
logging.info(f"{Fore.YELLOW}Получение Проектов Ветро{Fore.RESET}")
vetro_projects = clock.get_vetro_projects()
logging.info(f"{Fore.YELLOW}Получение Типов деятельности{Fore.RESET}")
activity_types = red.get_time_entry_activities()
logging.info(f"{Fore.YELLOW}Сохранение Тегов{Fore.RESET}")
db.insert_tags(tags)
logging.info(f"{Fore.YELLOW}Сохранение Задач Redmine{Fore.RESET}")
db.insert_tasks(issues)
logging.info(f"{Fore.YELLOW}Сохранение Проектов Ветро{Fore.RESET}")
db.insert_vetro_projects(vetro_projects)
logging.info(f"{Fore.YELLOW}Сохранение Типов деятелности{Fore.RESET}")
db.insert_activity_types(activity_types)
except Exception as e:
logger.error(Fore.RED + f"Ошибка при обновлении базы данных: {e}" + Fore.RESET)
else:
logger.info(Fore.GREEN + "База данных теперь актуальна." + Fore.RESET)
activities = sorted(clock.get_activities(
**{
"start": format_date(start),
"end": format_date(end),
"page-size": args.page_size,
},
), key=lambda x: x.date_start)
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)
db.insert_activities(tracked_activities)
except Exception as e:
logger.error(e)
if __name__ == "__main__":
main()

148
utils.py
View File

@@ -1,11 +1,30 @@
from argparse import Namespace
from datetime import datetime, timedelta
import logging
import os
import re
from typing import Optional
from colorama import Fore
from config import config
logger = logging.getLogger(__name__)
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:
td = datetime.today()
@@ -19,16 +38,133 @@ def today_end() -> datetime:
year = td.year
month = td.month
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:
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:
return date.strftime(config.DATETIME_FORMAT)
def parse_task_id(tag: str) -> int:
return int(re.search(r"^(?P<task_id>\d{5})", tag).group("task_id"))
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