Compare commits

...

13 Commits

Author SHA1 Message Date
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
14 changed files with 521 additions and 264 deletions

View File

@@ -51,6 +51,10 @@ 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
nano .env nano .env
@@ -82,17 +86,23 @@ activeWorkspace - это `WORKSPACE_ID`
## Запуск. ## Запуск.
``` ```
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]` | Режим инициализации бд.<br>Подгружает с серверов теги, виды деятельности и Проекты (ДО) Ветро| `python track.py -init-db` | | `--dry-run` | Предпросмотр без сохранения активностей и отправки трудочасов | `redmine-tracker --dry-run` |
| `-i [--init-db]` | Режим инициализации бд.<br>Подгружает с серверов теги, виды деятельности и Проекты (ДО) Ветро| `redmine-tracker -init-db` |
Также можно запускать напрямую из корня проекта:
```bash
python main.py
```
[Власов Эдуард](https://github.com/QuickLike) [Власов Эдуард](https://github.com/QuickLike)

View File

@@ -1,15 +1,15 @@
from datetime import datetime from datetime import datetime
import logging import logging
from typing import Union
from colorama import Fore from colorama import Fore
import requests import requests
from tqdm import tqdm from tqdm import tqdm
from config import ClockifyConfig, config, db 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 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__) logger = logging.getLogger(__name__)
@@ -20,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) 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, is_updating=False, **kwargs) -> list[Activity]: def get_activities(self, is_updating=False, **kwargs) -> list[Activity]:
@@ -33,7 +35,16 @@ class ClockifyAPI(ClockifyConfig):
page-size: int - Размер страницы (по умолчанию 50) 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: if not activities:
return [] return []
logger.info(f"Получено задач: {len(activities)}") logger.info(f"Получено задач: {len(activities)}")
@@ -43,6 +54,7 @@ class ClockifyAPI(ClockifyConfig):
if not tags: if not tags:
logger.info(f"{Fore.YELLOW}В базе данных нет тегов! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}") logger.info(f"{Fore.YELLOW}В базе данных нет тегов! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}")
return [] 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="Парсинг задач"):
@@ -55,30 +67,34 @@ class ClockifyAPI(ClockifyConfig):
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 tags if tag.id == tag_id] tag = tags_by_id.get(tag_id)
if not tag: if not tag:
tag = self.get_tags(True, tag_id=tag_id, **{"page-size": self.tags_count}) tag = self.get_tags(True, tag_id=tag_id, **{"page-size": self.tags_count})
if isinstance(tag, Tag): if isinstance(tag, Tag):
db.insert_tags([tag]) self.db.insert_tags([tag])
tags.append(tag) tags_by_id[tag.id] = tag
from config import red
try: try:
task_id = parse_task_id(tag.title) task_id = parse_task_id(tag.title)
except Exception as e: except Exception as e:
logger.error(f"При обновлении тега произошла ошибка: {e}\nПопробуйте обновить флагом -i [--init-db]") logger.error(f"При обновлении тега произошла ошибка: {e}\nПопробуйте обновить флагом -i [--init-db]")
continue continue
task = red.get_issue(task_id) if not self.redmine_api:
logger.error("Не передан RedmineAPI для обновления задачи по новому тегу")
continue
task = self.redmine_api.get_issue(task_id)
if task: if task:
db.insert_tasks([task]) self.db.insert_tasks([task])
else: else:
logger.error(f"{Fore.RED} Тег не найден: {tag_id}{Fore.RESET}") logger.error(f"{Fore.RED} Тег не найден: {tag_id}{Fore.RESET}")
continue continue
else: else:
tag = tag[0]
task_id = parse_task_id(tag.title) task_id = parse_task_id(tag.title)
author = db.get_user_by("clockify_user_id", activity["userId"]) author = self.db.get_user_by("clockify_user_id", activity["userId"])
if not author:
logger.error(f"Не найден автор активности {activity['id']}")
continue
result.append( result.append(
Activity( Activity(
@@ -91,16 +107,16 @@ class ClockifyAPI(ClockifyConfig):
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=db.has_time_entry(activity["id"]), is_tracked=self.db.has_time_entry(activity["id"]),
author_id=author["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, is_updating=False, tag_id="", **kwargs) -> Union[list[Tag], Tag, None]: def get_tags(self, is_updating=False, tag_id="", **kwargs) -> list[Tag] | Tag | None:
""" """
Получает с сервера Clockify теги Получает с сервера Clockify теги
name: str - optional name: str - optional
@@ -119,11 +135,16 @@ class ClockifyAPI(ClockifyConfig):
**kwargs, **kwargs,
"page": page "page": page
} }
tags = requests.get( resp = requests.get(
self.base_url + self.tags_url, self.base_url + self.tags_url,
params=params, params=params,
headers={"X-API-KEY": self.token} headers={"X-API-KEY": self.token},
).json() 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: for tag in tags:
clockify_tag = Tag( clockify_tag = Tag(
@@ -144,16 +165,32 @@ class ClockifyAPI(ClockifyConfig):
logger.debug(f"{Fore.YELLOW}Получено тегов: {len(result)}.{Fore.RESET}") logger.debug(f"{Fore.YELLOW}Получено тегов: {len(result)}.{Fore.RESET}")
return result return result
return db.get_tags() return self.db.get_tags()
def get_vetro_projects(self) -> list[VetroProject]: def get_vetro_projects(self) -> list[VetroProject]:
""" """
Получает с сервера Clockify Проекты (ДО) Ветро Получает с сервера 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: 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"]
@@ -180,12 +217,13 @@ class ClockifyAPI(ClockifyConfig):
"projectId": activity.project_id "projectId": activity.project_id
}, headers={ }, headers={
"X-API-KEY": self.token "X-API-KEY": self.token
} },
timeout=REQUEST_TIMEOUT,
) )
if config.is_debug or resp.ok: if config.is_debug or resp.ok:
logger.info(f"{Fore.GREEN}{activity}{Fore.RESET}".ljust(60) + "") logger.info(f"{Fore.GREEN}{activity}{Fore.RESET}".ljust(60) + "")
else: else:
logger.debug(resp.json()) logger.debug(resp.text)
logger.error(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "") logger.error(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "")
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
@@ -193,9 +231,13 @@ class ClockifyAPI(ClockifyConfig):
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"]})
@@ -203,9 +245,13 @@ class ClockifyAPI(ClockifyConfig):
def get_user(self): def get_user(self):
"""Получает нужные данные пользователя с сервера Clockify""" """Получает нужные данные пользователя с сервера 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: if not resp.ok:
logger.error(resp.json()) logger.error(resp.text)
raise requests.exceptions.RequestException(Fore.RED + "Ошибка запроса." + Fore.RESET) raise requests.exceptions.RequestException(Fore.RED + "Ошибка запроса." + Fore.RESET)
json = resp.json() json = resp.json()
return { return {

8
api/constants.py Normal file
View File

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

View File

@@ -6,16 +6,18 @@ import requests
from classes.classes import Task 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,
REDMINE_ISSUES_LIMIT,
REQUEST_TIMEOUT,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
WORKSPACE_ID = config.WORKSPACE_ID
USER_ID = config.USER_ID
class RedmineAPI(RedmineConfig): class RedmineAPI(RedmineConfig):
"""API клиент для взаимодействия с Redmine. """API клиент для взаимодействия с Redmine.
@@ -47,19 +49,23 @@ class RedmineAPI(RedmineConfig):
json={ json={
"time_entry": { "time_entry": {
"issue_id": activity.task_id, "issue_id": activity.task_id,
"hours": activity.time_spent / (2 if is_beginner else 1), "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 config.is_debug or resp.ok: if config.is_debug or resp.ok:
activity.is_tracked = True activity.is_tracked = True
if on_success: if on_success:
on_success(activity) on_success(activity)
tracked.append(activity) tracked.append(activity)
logger.info(f"{Fore.GREEN}{activity}: успешно {Fore.RESET}".ljust(100) + "") logger.info(
f"{Fore.GREEN}{self.format_activity_result(activity, is_beginner)}"
f"{Fore.RESET}".ljust(100) + ""
)
else: else:
try: try:
errors = resp.json()["errors"] errors = resp.json()["errors"]
@@ -82,22 +88,45 @@ class RedmineAPI(RedmineConfig):
logger.info(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "") 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:
"""Форматирует строку результата переноса активности."""
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[ActivityType]:
"""Получает виды Деятельности с сервера Redmine""" """Получает виды Деятельности с сервера Redmine"""
resp = requests.get(self.base_url + self.time_entry_activities, headers={"X-Redmine-API-KEY": self.token}) resp = requests.get(
logger.debug(resp.json()) self.base_url + self.time_entry_activities,
if resp.ok: headers={"X-Redmine-API-KEY": self.token},
return [ timeout=REQUEST_TIMEOUT,
ActivityType(id=act.get("id"), title=act.get("name")) )
for act in resp.json().get("time_entry_activities") if not resp.ok:
if act.get("active") 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): 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: if not resp.ok:
logger.error("Invalid Token for RedmineAPI") logger.error(f"Invalid Token for RedmineAPI: {resp.text}")
raise InvalidToken raise InvalidToken
json = resp.json()["user"] json = resp.json()["user"]
return { return {
@@ -108,16 +137,16 @@ class RedmineAPI(RedmineConfig):
"username": json["login"] "username": json["login"]
} }
def get_issue(self, issue_id: int) -> Task: def get_issue(self, issue_id: int) -> Task | None:
"""Получает задачу по её id с сервера Redmine""" """Получает задачу по её id с сервера Redmine"""
resp = requests.get( resp = requests.get(
self.base_url + self.issue.format(**{"id": issue_id}), self.base_url + self.issue.format(**{"id": issue_id}),
headers={"X-Redmine-Api-Key": self.token}, headers={"X-Redmine-Api-Key": self.token},
timeout=None timeout=REQUEST_TIMEOUT,
) )
if not resp.ok: if not resp.ok:
logger.error("Ошибка RedmineAPI") logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
return {} return None
issue = resp.json()["issue"] issue = resp.json()["issue"]
return Task(**{ return Task(**{
"id": issue["id"], "id": issue["id"],
@@ -133,17 +162,25 @@ class RedmineAPI(RedmineConfig):
def get_issues(self) -> list[Task]: def get_issues(self) -> list[Task]:
"""Получает задачи с сервера Redmine""" """Получает задачи с сервера Redmine"""
issues = [] issues = []
for i in range(25): offset = 0
total_count = None
while total_count is None or offset < total_count:
resp = requests.get( resp = requests.get(
self.base_url + self.issues, self.base_url + self.issues,
headers={"X-Redmine-Api-Key": self.token}, headers={"X-Redmine-Api-Key": self.token},
params={"limit": 100, "offset": 100 * i, "status_id": "*"}, params={"limit": REDMINE_ISSUES_LIMIT, "offset": offset, "status_id": "*"},
timeout=None timeout=REQUEST_TIMEOUT,
) )
if not resp.ok or not resp.json(): if not resp.ok:
logger.error("Ошибка RedmineAPI") logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
return {} return []
issues.extend(resp.json()["issues"]) data = resp.json()
if not data:
logger.error("Ошибка RedmineAPI: пустой ответ")
return []
issues.extend(data["issues"])
total_count = data.get("total_count", len(issues))
offset += data.get("limit", REDMINE_ISSUES_LIMIT)
return [Task(**{ return [Task(**{
"id": issue["id"], "id": issue["id"],
"subject": issue["subject"], "subject": issue["subject"],

View File

@@ -3,8 +3,6 @@ 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
@@ -67,6 +65,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(
@@ -75,6 +74,11 @@ 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',
@@ -85,14 +89,3 @@ def configure_argument_parser():
arg_parser = configure_argument_parser() arg_parser = configure_argument_parser()
config = Config() config = Config()
db = Database(config.DB_NAME, config.SQL_PATH)
from api.clockify import ClockifyAPI
from api.redmine import RedmineAPI
clock = ClockifyAPI(
token=config.CLOCKIFY_TOKEN,
workspace_id=config.WORKSPACE_ID,
user_id=config.USER_ID
)
red = RedmineAPI(config.REDMINE_TOKEN)

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"

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",
}
)

195
db/db.py
View File

@@ -5,6 +5,7 @@ from colorama import Fore
from classes import Activity, ActivityType, Tag, VetroProject from classes import Activity, ActivityType, Tag, VetroProject
from classes.classes import Task 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__)
@@ -32,8 +33,8 @@ class Database:
def insert_activities(self, activities: list[Activity]): def insert_activities(self, activities: list[Activity]):
"""Сохраняет в базу данных активности Clockify""" """Сохраняет в базу данных активности Clockify"""
for act in activities: try:
try: for act in activities:
self.cursor.execute( self.cursor.execute(
"""INSERT INTO activity( """INSERT INTO activity(
id, id,
@@ -47,7 +48,18 @@ class Database:
task_id, task_id,
project_id, project_id,
author_id author_id
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ) 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,
@@ -62,31 +74,16 @@ class Database:
act.author_id, act.author_id,
) )
) )
except sq.IntegrityError: except sq.Error as e:
self.cursor.execute( self.con.rollback()
"""UPDATE activity logger.error(f"Не удалось сохранить активности: {e}")
SET description = ?, time_spent = ?, date_start = ?, date_end = ?, is_tracked = ?, vetro_project_id = ?, tag_id = ?, task_id = ?, project_id = ? else:
WHERE id = ?""", self.con.commit()
(
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()
def insert_time_entries(self, activities: list[Activity]): def insert_time_entries(self, activities: list[Activity]):
"""Сохраняет в базу данных трудочасы Redmine""" """Сохраняет в базу данных трудочасы Redmine"""
for act in activities: try:
try: for act in activities:
self.cursor.execute( self.cursor.execute(
"""INSERT INTO time_entry """INSERT INTO time_entry
(date, description, activity_id, time_spent, task_id, activity_type_id, author_id) (date, description, activity_id, time_spent, task_id, activity_type_id, author_id)
@@ -101,58 +98,60 @@ class Database:
act.author_id act.author_id
) )
) )
self.con.commit() except sq.IntegrityError as e:
except sq.IntegrityError: self.con.rollback()
pass logger.error(f"Не удалось сохранить трудочасы: {e}")
else:
self.con.commit()
def insert_tags(self, tags: list[Tag]): def insert_tags(self, tags: list[Tag]):
"""Сохраняет в базу данных теги Clockify""" """Сохраняет в базу данных теги Clockify"""
for tag in tags: try:
try: for tag in tags:
self.cursor.execute("INSERT INTO tag VALUES(?, ?)",(tag.id, tag.title))
except sq.IntegrityError:
self.cursor.execute( self.cursor.execute(
"""UPDATE tag """INSERT INTO tag VALUES(?, ?)
SET title = ? ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
WHERE id = ?""", (tag.id, tag.title)
(tag.title, tag.id)
) )
finally: except sq.Error as e:
self.con.commit() 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]):
"""Сохраняет в базу данных Проекты (ДО) Ветро Clockify""" """Сохраняет в базу данных Проекты (ДО) Ветро Clockify"""
if not vetro_projects: if not vetro_projects:
return return
for vetro_project in vetro_projects: try:
try: for vetro_project in vetro_projects:
self.cursor.execute("INSERT INTO vetro_project VALUES(?, ?)", (vetro_project.id, vetro_project.title)) self.cursor.execute(
except sq.IntegrityError: """INSERT INTO vetro_project VALUES(?, ?)
self.cursor.execute(""" ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
UPDATE vetro_project (vetro_project.id, vetro_project.title)
SET title = ?
WHERE id = ?""",
(vetro_project.title, vetro_project.id)
) )
finally: except sq.Error as e:
self.con.commit() 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]):
"""Сохраняет в базу данных Деятельности Redmine""" """Сохраняет в базу данных Деятельности Redmine"""
for activity_type in activity_types: try:
try: for activity_type in activity_types:
self.cursor.execute("INSERT INTO activity_type VALUES(?, ?)", (activity_type.id, activity_type.title)) self.cursor.execute(
except sq.IntegrityError: """INSERT INTO activity_type VALUES(?, ?)
self.cursor.execute(""" ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
UPDATE activity_type (activity_type.id, activity_type.title)
SET title = ?
WHERE id = ?""",
(activity_type.title, activity_type.id)
) )
finally: except sq.Error as e:
self.con.commit() self.con.rollback()
logger.error(f"Не удалось сохранить типы деятельности: {e}")
else:
self.con.commit()
def insert_user(self, **kwargs): def insert_user(self, **kwargs):
"""Сохраняет в базу данных пользователя""" """Сохраняет в базу данных пользователя"""
@@ -160,35 +159,35 @@ class Database:
self.cursor.execute("""INSERT INTO user( self.cursor.execute("""INSERT INTO user(
clockify_user_id, clockify_email, clockify_username, clockify_user_id, clockify_email, clockify_username,
redmine_user_id, redmine_email, first_name, last_name, redmine_username) redmine_user_id, redmine_email, first_name, last_name, redmine_username)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)""", [v for k, v in kwargs.items()]) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
except sq.IntegrityError: ON CONFLICT(clockify_user_id) DO UPDATE SET
self.cursor.execute(""" clockify_email = excluded.clockify_email,
UPDATE user clockify_username = excluded.clockify_username,
SET clockify_email = ?, redmine_email = excluded.redmine_email,
clockify_username = ?, first_name = excluded.first_name,
redmine_email = ?, last_name = excluded.last_name,
first_name = ?, redmine_username = excluded.redmine_username""",
last_name = ?,
redmine_username = ?
WHERE redmine_user_id = ? AND clockify_user_id = ?""",
( (
kwargs["clockify_user_id"],
kwargs["clockify_email"], kwargs["clockify_email"],
kwargs["clockify_username"], kwargs["clockify_username"],
kwargs["redmine_user_id"],
kwargs["redmine_email"], kwargs["redmine_email"],
kwargs["first_name"], kwargs["first_name"],
kwargs["last_name"], kwargs["last_name"],
kwargs["redmine_username"], 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() self.con.commit()
def insert_tasks(self, tasks: list[Task]): def insert_tasks(self, tasks: list[Task]):
"""Сохраняет в БД Задачи из Redmine""" """Сохраняет в БД Задачи из Redmine"""
for task in tasks: try:
try: for task in tasks:
self.cursor.execute(""" self.cursor.execute("""
INSERT INTO task( INSERT INTO task(
"id", "id",
@@ -200,7 +199,15 @@ class Database:
"tracker", "tracker",
"project" "project"
) )
VALUES(?, ?, ?, ?, ?, ?, ?, ?)""", 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.id,
task.subject, task.subject,
@@ -211,35 +218,21 @@ class Database:
task.tracker, task.tracker,
task.project, task.project,
]) ])
except sq.IntegrityError: except sq.Error as e:
self.cursor.execute(""" self.con.rollback()
UPDATE task logger.error(f"Не удалось сохранить задачи: {e}")
SET "subject" = ?, else:
"description" = ?, self.con.commit()
"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_user_by(self, by_field: str, value: str): def get_user_by(self, by_field: str, value: str):
"""Получает пользователя из бд""" """Получает пользователя из бд"""
if by_field not in USER_LOOKUP_FIELDS:
raise ValueError(f"Недопустимое поле поиска пользователя: {by_field}")
try: try:
user = self.cursor.execute(f"SELECT * FROM user WHERE {by_field} = ?", (value,)).fetchone() 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 { return {
"id": user[0], "id": user[0],
"first_name": user[1], "first_name": user[1],

93
main.py Normal file
View File

@@ -0,0 +1,93 @@
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_update(clock, db, red)
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()

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"]

1
services/__init__.py Normal file
View File

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

88
services/tracking.py Normal file
View File

@@ -0,0 +1,88 @@
"""Сервис переноса трудочасов из Clockify в Redmine."""
import logging
from colorama import Fore
from utils import format_date, format_hours
logger = logging.getLogger(__name__)
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 []
if self.is_dry_run:
self._log_preview(activities)
return activities
self.db.insert_activities(activities)
self.red.track_activities(
activities,
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."""
self.db.insert_activities([activity])
self.db.insert_time_entries([activity])
def _log_preview(self, activities):
"""Выводит план переноса активностей без сохранения и отправки."""
total_hours = sum(activity.time_spent for activity in activities if activity.date_end)
trackable_activities = [
activity
for activity in activities
if not activity.is_tracked and activity.date_end
]
logger.info(f"{Fore.YELLOW}Предпросмотр переноса трудочасов{Fore.RESET}")
logger.info(f"Найдено активностей: {len(activities)}")
logger.info(f"Готово к переносу: {len(trackable_activities)}")
logger.info(f"Всего часов: {format_hours(total_hours)}")
for activity in trackable_activities:
logger.info(
f"{activity.date_start.date()} | #{activity.task_id} | "
f"{format_hours(activity.time_spent)} | {activity.description}"
)

View File

@@ -1,77 +1,7 @@
import logging """Совместимая точка входа для старых запусков через track.py."""
import sys
logging.basicConfig( from main import main
level=logging.INFO,
format='%(asctime)s %(name)s:%(lineno)d %(levelname)s %(message)s',
handlers=[
logging.FileHandler('app.log', encoding='utf-8'),
logging.StreamHandler(sys.stdout)
]
)
from colorama import Fore # noqa: E402
from config import arg_parser, clock, config, db, red # noqa: E402
from utils import check_envs, db_update, format_date, get_start_end_dates # noqa: E402
def main():
args = arg_parser.parse_args()
if is_debug := args.debug:
config.MODE = "debug"
logging.getLogger().setLevel(logging.DEBUG)
logger = logging.getLogger(__name__)
start, end = get_start_end_dates(args)
if not check_envs():
return
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:
db_update()
activities = sorted(clock.get_activities(
**{
"start": format_date(start),
"end": format_date(end),
"page-size": args.page_size,
},
), key=lambda x: x.date_start)
if not activities:
logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET)
return
db.insert_activities(activities)
def save_tracked_activity(activity):
db.insert_activities([activity])
db.insert_time_entries([activity])
try:
red.track_activities(
activities,
on_success=None if is_debug else save_tracked_activity,
)
except Exception as e:
logger.error(e)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -3,7 +3,6 @@ from datetime import datetime, timedelta
import logging import logging
import os import os
import re import re
from typing import Optional
from colorama import Fore from colorama import Fore
@@ -12,7 +11,20 @@ from config import config
logger = logging.getLogger(__name__) 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()
@@ -26,22 +38,24 @@ 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: 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(): def db_update(clock, db, red):
from config import clock, db, red """Обновляет локальную БД данными из Clockify и Redmine."""
logger.info(Fore.YELLOW + "Обновление базы данных..." + Fore.RESET) logger.info(Fore.YELLOW + "Обновление базы данных..." + Fore.RESET)
try: try:
@@ -69,7 +83,8 @@ def db_update():
red_tasks = red.get_issues() red_tasks = red.get_issues()
if red_tasks: if red_tasks:
db_tasks = db.get_tasks() db_tasks = db.get_tasks()
clock_tasks = [task for task in red_tasks if task.id in [parse_task_id(tag.title) for tag in [*db_tags, *new_tags]]] clock_task_ids = {parse_task_id(tag.title) for tag in [*db_tags, *new_tags]}
clock_tasks = [task for task in red_tasks if task.id in clock_task_ids]
new_tasks = [task for task in clock_tasks if task not in db_tasks] new_tasks = [task for task in clock_tasks if task not in db_tasks]
if new_tasks: if new_tasks:
@@ -142,8 +157,8 @@ def get_start_end_dates(args: Namespace):
return start, end return start, end
def check_envs(): def check_envs(clock):
from config import config, clock """Проверяет наличие обязательных переменных окружения Clockify."""
if not config.WORKSPACE_ID or not config.USER_ID: if not config.WORKSPACE_ID or not config.USER_ID:
if not os.path.exists(config.ENV_TXT): if not os.path.exists(config.ENV_TXT):
logger.info(f"{Fore.YELLOW}Необходимые переменные не были обнаружены. Попытка получения...{Fore.RESET}") logger.info(f"{Fore.YELLOW}Необходимые переменные не были обнаружены. Попытка получения...{Fore.RESET}")