Compare commits
13 Commits
8d19e1d43f
...
9352a84723
| Author | SHA1 | Date | |
|---|---|---|---|
| 9352a84723 | |||
| a4a4178880 | |||
| eca2f6aa51 | |||
| c35b479a0b | |||
| 4da65657d8 | |||
| c94a31a5c1 | |||
| 9cf4cc75f0 | |||
| ba9eafbb46 | |||
| 0778cc23ae | |||
| baf6e2f3a4 | |||
| 9a0ce15160 | |||
| 64b347e966 | |||
| bd20ef4d48 |
24
README.MD
24
README.MD
@@ -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)
|
||||||
102
api/clockify.py
102
api/clockify.py
@@ -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
8
api/constants.py
Normal 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
|
||||||
@@ -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},
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
|
||||||
|
return []
|
||||||
|
data = resp.json()
|
||||||
|
logger.debug(data)
|
||||||
return [
|
return [
|
||||||
ActivityType(id=act.get("id"), title=act.get("name"))
|
ActivityType(id=act.get("id"), title=act.get("name"))
|
||||||
for act in resp.json().get("time_entry_activities")
|
for act in data.get("time_entry_activities", [])
|
||||||
if act.get("active")
|
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"],
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
13
db/constants.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Константы слоя базы данных."""
|
||||||
|
|
||||||
|
USER_LOOKUP_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"id",
|
||||||
|
"clockify_user_id",
|
||||||
|
"redmine_user_id",
|
||||||
|
"clockify_email",
|
||||||
|
"clockify_username",
|
||||||
|
"redmine_email",
|
||||||
|
"redmine_username",
|
||||||
|
}
|
||||||
|
)
|
||||||
171
db/db.py
171
db/db.py
@@ -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 = ?""",
|
|
||||||
(
|
|
||||||
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()
|
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,24 +98,26 @@ class Database:
|
|||||||
act.author_id
|
act.author_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
except sq.IntegrityError as e:
|
||||||
|
self.con.rollback()
|
||||||
|
logger.error(f"Не удалось сохранить трудочасы: {e}")
|
||||||
|
else:
|
||||||
self.con.commit()
|
self.con.commit()
|
||||||
except sq.IntegrityError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def insert_tags(self, tags: list[Tag]):
|
def insert_tags(self, tags: list[Tag]):
|
||||||
"""Сохраняет в базу данных теги Clockify"""
|
"""Сохраняет в базу данных теги Clockify"""
|
||||||
for tag in tags:
|
|
||||||
try:
|
try:
|
||||||
self.cursor.execute("INSERT INTO tag VALUES(?, ?)",(tag.id, tag.title))
|
for tag in tags:
|
||||||
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.rollback()
|
||||||
|
logger.error(f"Не удалось сохранить теги: {e}")
|
||||||
|
else:
|
||||||
self.con.commit()
|
self.con.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -126,32 +125,32 @@ class Database:
|
|||||||
"""Сохраняет в базу данных Проекты (ДО) Ветро Clockify"""
|
"""Сохраняет в базу данных Проекты (ДО) Ветро Clockify"""
|
||||||
if not vetro_projects:
|
if not vetro_projects:
|
||||||
return
|
return
|
||||||
for vetro_project in vetro_projects:
|
|
||||||
try:
|
try:
|
||||||
self.cursor.execute("INSERT INTO vetro_project VALUES(?, ?)", (vetro_project.id, vetro_project.title))
|
for vetro_project in vetro_projects:
|
||||||
except sq.IntegrityError:
|
self.cursor.execute(
|
||||||
self.cursor.execute("""
|
"""INSERT INTO vetro_project VALUES(?, ?)
|
||||||
UPDATE vetro_project
|
ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
|
||||||
SET title = ?
|
(vetro_project.id, vetro_project.title)
|
||||||
WHERE id = ?""",
|
|
||||||
(vetro_project.title, vetro_project.id)
|
|
||||||
)
|
)
|
||||||
finally:
|
except sq.Error as e:
|
||||||
|
self.con.rollback()
|
||||||
|
logger.error(f"Не удалось сохранить проекты Ветро: {e}")
|
||||||
|
else:
|
||||||
self.con.commit()
|
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:
|
||||||
self.cursor.execute("INSERT INTO activity_type VALUES(?, ?)", (activity_type.id, activity_type.title))
|
for activity_type in activity_types:
|
||||||
except sq.IntegrityError:
|
self.cursor.execute(
|
||||||
self.cursor.execute("""
|
"""INSERT INTO activity_type VALUES(?, ?)
|
||||||
UPDATE activity_type
|
ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
|
||||||
SET title = ?
|
(activity_type.id, activity_type.title)
|
||||||
WHERE id = ?""",
|
|
||||||
(activity_type.title, activity_type.id)
|
|
||||||
)
|
)
|
||||||
finally:
|
except sq.Error as e:
|
||||||
|
self.con.rollback()
|
||||||
|
logger.error(f"Не удалось сохранить типы деятельности: {e}")
|
||||||
|
else:
|
||||||
self.con.commit()
|
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" = ?,
|
|
||||||
"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()
|
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
93
main.py
Normal 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
27
pyproject.toml
Normal 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
1
services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Сервисы бизнес-сценариев приложения."""
|
||||||
88
services/tracking.py
Normal file
88
services/tracking.py
Normal 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}"
|
||||||
|
)
|
||||||
74
track.py
74
track.py
@@ -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()
|
||||||
|
|||||||
37
utils.py
37
utils.py
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user