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
pip install -r requirements.txt
```
И установите локальную команду запуска:
```bash
pip install -e .
```
Создайте файл .env и добавьте в него переменные:
```bash
nano .env
@@ -82,17 +86,23 @@ 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]` | Режим инициализации бд.<br>Подгружает с серверов теги, виды деятельности и Проекты (ДО) Ветро| `redmine-tracker -init-db` |
Также можно запускать напрямую из корня проекта:
```bash
python main.py
```
[Власов Эдуард](https://github.com/QuickLike)

View File

@@ -1,15 +1,15 @@
from datetime import datetime
import logging
from typing import Union
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__)
@@ -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)
self.db = db
self.redmine_api = redmine_api
self.token = self._check_token(token)
def get_activities(self, is_updating=False, **kwargs) -> list[Activity]:
@@ -33,7 +35,16 @@ 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)}")
@@ -43,6 +54,7 @@ class ClockifyAPI(ClockifyConfig):
if not tags:
logger.info(f"{Fore.YELLOW}В базе данных нет тегов! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}")
return []
tags_by_id = {tag.id: tag for tag in tags}
result = []
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)
tag = [tag for tag in tags if tag.id == tag_id]
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):
db.insert_tags([tag])
tags.append(tag)
from config import red
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
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:
db.insert_tasks([task])
self.db.insert_tasks([task])
else:
logger.error(f"{Fore.RED} Тег не найден: {tag_id}{Fore.RESET}")
continue
else:
tag = tag[0]
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(
Activity(
@@ -91,16 +107,16 @@ class ClockifyAPI(ClockifyConfig):
time_spent=time_to_hour((end if end else datetime.utcnow()) - start),
date_start=start,
date_end=end,
is_tracked=db.has_time_entry(activity["id"]),
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, tag_id="", **kwargs) -> Union[list[Tag], Tag, None]:
def get_tags(self, is_updating=False, tag_id="", **kwargs) -> list[Tag] | Tag | None:
"""
Получает с сервера Clockify теги
name: str - optional
@@ -119,11 +135,16 @@ class ClockifyAPI(ClockifyConfig):
**kwargs,
"page": page
}
tags = requests.get(
resp = requests.get(
self.base_url + self.tags_url,
params=params,
headers={"X-API-KEY": self.token}
).json()
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(
@@ -144,16 +165,32 @@ class ClockifyAPI(ClockifyConfig):
logger.debug(f"{Fore.YELLOW}Получено тегов: {len(result)}.{Fore.RESET}")
return result
return db.get_tags()
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"]
@@ -180,12 +217,13 @@ class ClockifyAPI(ClockifyConfig):
"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.debug(resp.text)
logger.error(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "")
except Exception as e:
logger.error(e)
@@ -193,9 +231,13 @@ class ClockifyAPI(ClockifyConfig):
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"]})
@@ -203,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 {

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 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,
REDMINE_ISSUES_LIMIT,
REQUEST_TIMEOUT,
)
logger = logging.getLogger(__name__)
WORKSPACE_ID = config.WORKSPACE_ID
USER_ID = config.USER_ID
class RedmineAPI(RedmineConfig):
"""API клиент для взаимодействия с Redmine.
@@ -47,19 +49,23 @@ class RedmineAPI(RedmineConfig):
json={
"time_entry": {
"issue_id": activity.task_id,
"hours": activity.time_spent / (2 if is_beginner else 1),
"activity_id": ActivityTypes.DEVELOPMENT,
"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)
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:
try:
errors = resp.json()["errors"]
@@ -82,22 +88,45 @@ class RedmineAPI(RedmineConfig):
logger.info(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "")
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"""
resp = requests.get(self.base_url + self.time_entry_activities, headers={"X-Redmine-API-KEY": self.token})
logger.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 {
@@ -108,16 +137,16 @@ class RedmineAPI(RedmineConfig):
"username": json["login"]
}
def get_issue(self, issue_id: int) -> Task:
def get_issue(self, issue_id: int) -> Task | None:
"""Получает задачу по её id с сервера Redmine"""
resp = requests.get(
self.base_url + self.issue.format(**{"id": issue_id}),
headers={"X-Redmine-Api-Key": self.token},
timeout=None
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
logger.error("Ошибка RedmineAPI")
return {}
logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
return None
issue = resp.json()["issue"]
return Task(**{
"id": issue["id"],
@@ -133,17 +162,25 @@ class RedmineAPI(RedmineConfig):
def get_issues(self) -> list[Task]:
"""Получает задачи с сервера Redmine"""
issues = []
for i in range(25):
offset = 0
total_count = None
while total_count is None or offset < total_count:
resp = requests.get(
self.base_url + self.issues,
headers={"X-Redmine-Api-Key": self.token},
params={"limit": 100, "offset": 100 * i, "status_id": "*"},
timeout=None
params={"limit": REDMINE_ISSUES_LIMIT, "offset": offset, "status_id": "*"},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok or not resp.json():
logger.error("Ошибка RedmineAPI")
return {}
issues.extend(resp.json()["issues"])
if not resp.ok:
logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
return []
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(**{
"id": issue["id"],
"subject": issue["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
@@ -67,6 +65,7 @@ def configure_argument_parser():
parser.add_argument(
'-p',
'--page-size',
type=int,
help='Количество задач для вывода'
)
parser.add_argument(
@@ -75,6 +74,11 @@ 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',
@@ -85,14 +89,3 @@ def configure_argument_parser():
arg_parser = configure_argument_parser()
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:
def __init__(self):
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.classes import Task
from db.constants import USER_LOOKUP_FIELDS
from enums import ActivityTypes
logger = logging.getLogger(__name__)
@@ -32,8 +33,8 @@ class Database:
def insert_activities(self, activities: list[Activity]):
"""Сохраняет в базу данных активности Clockify"""
for act in activities:
try:
try:
for act in activities:
self.cursor.execute(
"""INSERT INTO activity(
id,
@@ -47,7 +48,18 @@ class Database:
task_id,
project_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.description,
@@ -62,31 +74,16 @@ class Database:
act.author_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()
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:
try:
for act in activities:
self.cursor.execute(
"""INSERT INTO time_entry
(date, description, activity_id, time_spent, task_id, activity_type_id, author_id)
@@ -101,58 +98,60 @@ class Database:
act.author_id
)
)
self.con.commit()
except sq.IntegrityError:
pass
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):
"""Сохраняет в базу данных пользователя"""
@@ -160,35 +159,35 @@ 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"""
for task in tasks:
try:
try:
for task in tasks:
self.cursor.execute("""
INSERT INTO task(
"id",
@@ -200,7 +199,15 @@ class Database:
"tracker",
"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.subject,
@@ -211,35 +218,21 @@ class Database:
task.tracker,
task.project,
])
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()
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],

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
import sys
"""Совместимая точка входа для старых запусков через track.py."""
logging.basicConfig(
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 main import main
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__":
main()

View File

@@ -3,7 +3,6 @@ from datetime import datetime, timedelta
import logging
import os
import re
from typing import Optional
from colorama import Fore
@@ -12,7 +11,20 @@ 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()
@@ -26,22 +38,24 @@ 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():
from config import clock, db, red
def db_update(clock, db, red):
"""Обновляет локальную БД данными из Clockify и Redmine."""
logger.info(Fore.YELLOW + "Обновление базы данных..." + Fore.RESET)
try:
@@ -69,7 +83,8 @@ def db_update():
red_tasks = red.get_issues()
if red_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]
if new_tasks:
@@ -142,8 +157,8 @@ def get_start_end_dates(args: Namespace):
return start, end
def check_envs():
from config import config, clock
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}")