Compare commits
47 Commits
bcfadfdfb9
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 49286d0fcf | |||
| 5dca3ae446 | |||
| 9352a84723 | |||
| a4a4178880 | |||
| eca2f6aa51 | |||
| c35b479a0b | |||
| 4da65657d8 | |||
| c94a31a5c1 | |||
| 9cf4cc75f0 | |||
| ba9eafbb46 | |||
| 0778cc23ae | |||
| baf6e2f3a4 | |||
| 9a0ce15160 | |||
| 64b347e966 | |||
| bd20ef4d48 | |||
| 8d19e1d43f | |||
| fa97f75fbe | |||
| 4e2aea78fe | |||
| 3683925e14 | |||
| e7cbffcd70 | |||
| e7837cc0f3 | |||
| 20fa3eb8a1 | |||
| 63044be106 | |||
| 5dc9c8f0d1 | |||
| ddec6094c2 | |||
| 129c2377e8 | |||
| e14972c8a2 | |||
| 9688bd644d | |||
| 8fc8c97597 | |||
| 8c603cc7ab | |||
| 0bac6a5b5f | |||
| ee4fc1b8f8 | |||
| 4581142ac6 | |||
| e0d90b4e59 | |||
| 2333c6165b | |||
| f9e14fb7af | |||
| 97c2a7aa37 | |||
| ea9fa5b472 | |||
| 6f54c6d3ac | |||
| d3c0a6a8c2 | |||
| f627f318a6 | |||
| ca20bd8620 | |||
| 572c123432 | |||
| a3fd037008 | |||
| 9840d3aadf | |||
| 8a658a4cf3 | |||
| feb9cef463 |
28
README.MD
28
README.MD
@@ -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,27 @@ activeWorkspace - это `WORKSPACE_ID`
|
||||
## Запуск.
|
||||
|
||||
```
|
||||
python track.py
|
||||
redmine-tracker
|
||||
```
|
||||
|
||||
## Аргументы запуска
|
||||
| Аргумент | Описание | Пример использования |
|
||||
|-------------------|----------------------------------------------------------------------------------------------|--------------------------------------|
|
||||
| `-h [--help]` | Показать справку | `python track.py --help` |
|
||||
| `-s [--start]` | Дата начала парсинга активностей в формате гггг-мм-дд | `python track.py --start 2025-05-19` |
|
||||
| `-e [--end]` | Дата окончания парсинга активностей в формате гггг-мм-дд | `python track.py -e 2025-05-19` |
|
||||
| `-p [--page-size]`| Количество задач для вывода. Выведет последние n записей | `python track.py --page-size 15` |
|
||||
| `-d [--debug]` | Режим отладки. Не загружает трудочасы в Redmine | `python track.py -d` |
|
||||
| `-i [--init-db]` | Режим инициализации бд.<br>Подгружает с серверов теги, виды деятельности и Проекты (ДО) Ветро| `python track.py -init-db` |
|
||||
| `-h [--help]` | Показать справку | `redmine-tracker --help` |
|
||||
| `-s [--start]` | Дата начала парсинга активностей в формате гггг-мм-дд | `redmine-tracker --start 2025-05-19` |
|
||||
| `-e [--end]` | Дата окончания парсинга активностей в формате гггг-мм-дд | `redmine-tracker -e 2025-05-19` |
|
||||
| `-p [--page-size]`| Количество задач для вывода. Выведет последние n записей | `redmine-tracker --page-size 15` |
|
||||
| `-d [--debug]` | Режим отладки. Не загружает трудочасы в Redmine | `redmine-tracker -d` |
|
||||
| `--dry-run` | Предпросмотр без сохранения активностей и отправки трудочасов | `redmine-tracker --dry-run` |
|
||||
| `-i [--init-db]` | Выполняет миграции и обновляет справочники без отправки трудочасов в Redmine | `redmine-tracker --init-db` |
|
||||
|
||||
При запуске с `--init-db` файлы из каталога `migrations` выполняются по имени.
|
||||
Успешно применённые миграции повторно не запускаются: их имя и время выполнения
|
||||
сохраняются в таблице `migration`.
|
||||
|
||||
Также можно запускать напрямую из корня проекта:
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
[Власов Эдуард](https://github.com/QuickLike)
|
||||
157
api/clockify.py
157
api/clockify.py
@@ -5,10 +5,11 @@ from colorama import Fore
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
from config import ClockifyConfig, config, db
|
||||
from config import ClockifyConfig, config
|
||||
from classes import Activity, Tag, VetroProject
|
||||
from exceptions import InvalidToken
|
||||
from utils import parse_task_id, time_to_hour
|
||||
from utils import format_hours, parse_task_id, time_to_hour
|
||||
from .constants import REQUEST_TIMEOUT
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,8 +20,10 @@ class ClockifyAPI(ClockifyConfig):
|
||||
|
||||
Позволяет получать активности, теги, проекты ветро и помечать активности затрканными.
|
||||
"""
|
||||
def __init__(self, token: str, *args, **kwargs):
|
||||
def __init__(self, token: str, db, redmine_api=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.db = db
|
||||
self.redmine_api = redmine_api
|
||||
self.token = self._check_token(token)
|
||||
|
||||
def get_activities(self, is_updating=False, **kwargs) -> list[Activity]:
|
||||
@@ -32,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)}")
|
||||
@@ -40,8 +52,9 @@ class ClockifyAPI(ClockifyConfig):
|
||||
|
||||
tags = self.get_tags(is_updating=is_updating)
|
||||
if not tags:
|
||||
logging.info(f"{Fore.YELLOW}В базе данных нет тегов! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}")
|
||||
logger.info(f"{Fore.YELLOW}В базе данных нет тегов! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}")
|
||||
return []
|
||||
tags_by_id = {tag.id: tag for tag in tags}
|
||||
|
||||
result = []
|
||||
for activity in tqdm(activities, desc="Парсинг задач"):
|
||||
@@ -52,19 +65,36 @@ class ClockifyAPI(ClockifyConfig):
|
||||
start = datetime.strptime(activity["timeInterval"]["start"], config.DATETIME_FORMAT)
|
||||
end = datetime.strptime(activity["timeInterval"]["end"], config.DATETIME_FORMAT) if activity["timeInterval"]["end"] else None
|
||||
time_worked_out_total += time_to_hour((end if end else datetime.utcnow()) - start)
|
||||
tag = [tag for tag in tags if tag.id == tag_id]
|
||||
|
||||
|
||||
tag = tags_by_id.get(tag_id)
|
||||
|
||||
if not tag:
|
||||
logging.info(f"{Fore.YELLOW}В базе данных нет нужного тега {Fore.GREEN}{tag_id}{Fore.YELLOW}! Обновите БД при помощи флага -i [--init-db]{Fore.RESET}")
|
||||
break
|
||||
else:
|
||||
tag = tag[0]
|
||||
tag = self.get_tags(True, tag_id=tag_id, **{"page-size": self.tags_count})
|
||||
if isinstance(tag, Tag):
|
||||
self.db.insert_tags([tag])
|
||||
tags_by_id[tag.id] = tag
|
||||
try:
|
||||
task_id = parse_task_id(tag.title)
|
||||
except ValueError:
|
||||
logger.info(f'{activity["timeInterval"]["start"]}: {activity["description"]} - Неправильный формат тега {tag}')
|
||||
except Exception as e:
|
||||
logger.error(f"При обновлении тега произошла ошибка: {e}\nПопробуйте обновить флагом -i [--init-db]")
|
||||
continue
|
||||
if not self.redmine_api:
|
||||
logger.error("Не передан RedmineAPI для обновления задачи по новому тегу")
|
||||
continue
|
||||
task = self.redmine_api.get_issue(task_id)
|
||||
if task:
|
||||
self.db.insert_tasks([task])
|
||||
else:
|
||||
logger.error(f"{Fore.RED} Тег не найден: {tag_id}{Fore.RESET}")
|
||||
continue
|
||||
else:
|
||||
task_id = parse_task_id(tag.title)
|
||||
|
||||
author = 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(
|
||||
@@ -77,50 +107,103 @@ class ClockifyAPI(ClockifyConfig):
|
||||
time_spent=time_to_hour((end if end else datetime.utcnow()) - start),
|
||||
date_start=start,
|
||||
date_end=end,
|
||||
is_tracked=activity["billable"],
|
||||
is_tracked=self.db.has_time_entry(activity["id"]),
|
||||
author_id=author["id"]
|
||||
)
|
||||
)
|
||||
logger.info(f"Сохранено задач: {len(result)}")
|
||||
logger.info(f"Отработано часов: {time_worked_out_total}")
|
||||
logger.info(f"Отработано часов: {format_hours(time_worked_out_total)}")
|
||||
return result
|
||||
|
||||
|
||||
def get_tags(self, is_updating=False, **kwargs) -> list[Tag]:
|
||||
def get_tags(self, is_updating=False, tag_id="", **kwargs) -> list[Tag] | Tag | None:
|
||||
"""
|
||||
Получает с сервера Clockify теги
|
||||
name: str - optional
|
||||
archived: bool - optional
|
||||
page-size: int - optional
|
||||
page: int - optional
|
||||
"""
|
||||
if is_updating:
|
||||
tags = requests.get(self.base_url + self.tags_url, params={"sort-column": "name", "sort-order": "descending", **kwargs}, headers={"X-API-KEY": self.token}).json()
|
||||
logging.debug(f"{Fore.YELLOW}Получено тегов: {len(tags)}.{Fore.RESET}")
|
||||
return [Tag(
|
||||
page = 1
|
||||
page_size = int(kwargs.get("page-size", self.tags_count))
|
||||
result = []
|
||||
while True:
|
||||
params = {
|
||||
"sort-column": "name",
|
||||
"sort-order": "descending",
|
||||
**kwargs,
|
||||
"page": page
|
||||
}
|
||||
resp = requests.get(
|
||||
self.base_url + self.tags_url,
|
||||
params=params,
|
||||
headers={"X-API-KEY": self.token},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.error(f"Ошибка ClockifyAPI: {resp.text} {resp.status_code}")
|
||||
resp.raise_for_status()
|
||||
tags = resp.json()
|
||||
|
||||
for tag in tags:
|
||||
clockify_tag = Tag(
|
||||
id=tag["id"],
|
||||
title=tag["name"]
|
||||
) for tag in tags]
|
||||
return db.get_tags()
|
||||
)
|
||||
if tag_id and tag_id == clockify_tag.id:
|
||||
return clockify_tag
|
||||
if not tag_id:
|
||||
result.append(clockify_tag)
|
||||
|
||||
if len(tags) < page_size:
|
||||
break
|
||||
page += 1
|
||||
|
||||
if tag_id:
|
||||
return None
|
||||
|
||||
logger.debug(f"{Fore.YELLOW}Получено тегов: {len(result)}.{Fore.RESET}")
|
||||
return result
|
||||
return self.db.get_tags()
|
||||
|
||||
def get_vetro_projects(self) -> list[VetroProject]:
|
||||
"""
|
||||
Получает с сервера Clockify Проекты (ДО) Ветро
|
||||
"""
|
||||
projects = requests.get(self.base_url + self.projects_url, headers={"X-API-KEY": self.token}).json()
|
||||
resp = requests.get(
|
||||
self.base_url + self.projects_url,
|
||||
headers={"X-API-KEY": self.token},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.error(f"Ошибка ClockifyAPI: {resp.text} {resp.status_code}")
|
||||
resp.raise_for_status()
|
||||
projects = resp.json()
|
||||
for project in projects:
|
||||
if not project.get("archived"):
|
||||
vetro_projects = requests.get(self.base_url + self.tasks_url(project.get("id")), headers={"X-API-KEY": self.token}).json()
|
||||
resp = requests.get(
|
||||
self.base_url + self.tasks_url(project.get("id")),
|
||||
headers={"X-API-KEY": self.token},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.error(f"Ошибка ClockifyAPI: {resp.text} {resp.status_code}")
|
||||
resp.raise_for_status()
|
||||
vetro_projects = resp.json()
|
||||
return [VetroProject(
|
||||
id=vetro_project["id"],
|
||||
title=vetro_project["name"]
|
||||
) for vetro_project in vetro_projects]
|
||||
|
||||
# DEPRECATED
|
||||
def mark_tracked(self, activities: list[Activity]):
|
||||
"""
|
||||
Помечает активности затреканными (Billable) на сервере Clockify
|
||||
"""
|
||||
for activity in activities:
|
||||
try:
|
||||
if not config.is_debug:
|
||||
resp = requests.put(
|
||||
self.base_url + self.update_time_entry(
|
||||
activity_id=activity.id
|
||||
@@ -134,23 +217,27 @@ class ClockifyAPI(ClockifyConfig):
|
||||
"projectId": activity.project_id
|
||||
}, headers={
|
||||
"X-API-KEY": self.token
|
||||
}
|
||||
},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if resp.ok:
|
||||
logger.debug(resp.json())
|
||||
logger.info(f"{Fore.GREEN} {activity} помечена как затреканная{Fore.RESET}")
|
||||
if config.is_debug or resp.ok:
|
||||
logger.info(f"{Fore.GREEN}{activity}{Fore.RESET}".ljust(60) + "✅")
|
||||
else:
|
||||
logger.debug(resp.json())
|
||||
logger.error(f"{Fore.RED} {activity} не помечена как затреканная{Fore.RESET}")
|
||||
logger.debug(resp.text)
|
||||
logger.error(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "❌")
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return
|
||||
|
||||
def _check_token(self, token: str) -> str:
|
||||
"""Проверяет токен"""
|
||||
resp = requests.get(self.base_url + self.user_url, headers={"X-API-KEY": token})
|
||||
resp = requests.get(
|
||||
self.base_url + self.user_url,
|
||||
headers={"X-API-KEY": token},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.error(resp.json())
|
||||
logger.error(resp.text)
|
||||
raise InvalidToken
|
||||
resp = resp.json()
|
||||
logger.debug({"id": resp["id"], "email": resp["email"], "name": resp["name"]})
|
||||
@@ -158,9 +245,13 @@ class ClockifyAPI(ClockifyConfig):
|
||||
|
||||
def get_user(self):
|
||||
"""Получает нужные данные пользователя с сервера Clockify"""
|
||||
resp = requests.get(self.base_url + self.user_url, headers={"X-API-KEY": self.token})
|
||||
resp = requests.get(
|
||||
self.base_url + self.user_url,
|
||||
headers={"X-API-KEY": self.token},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.error(resp.json())
|
||||
logger.error(resp.text)
|
||||
raise requests.exceptions.RequestException(Fore.RED + "Ошибка запроса." + Fore.RESET)
|
||||
json = resp.json()
|
||||
return {
|
||||
|
||||
7
api/constants.py
Normal file
7
api/constants.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Константы API-клиентов."""
|
||||
|
||||
from enums import ActivityTypes
|
||||
|
||||
REQUEST_TIMEOUT = 30
|
||||
ACTIVITY_TYPE_BY_VETRO_PROJECT = {}
|
||||
DEFAULT_ACTIVITY_TYPE = ActivityTypes.DEVELOPMENT
|
||||
129
api/redmine.py
129
api/redmine.py
@@ -2,20 +2,21 @@ import logging
|
||||
|
||||
from colorama import Fore
|
||||
import requests
|
||||
from tqdm import tqdm
|
||||
|
||||
from classes.classes import Task
|
||||
from config import RedmineConfig, config
|
||||
from classes import Activity, ActivityType
|
||||
from enums import ActivityTypes
|
||||
from exceptions import InvalidToken
|
||||
from utils import format_hours
|
||||
from .constants import (
|
||||
ACTIVITY_TYPE_BY_VETRO_PROJECT,
|
||||
DEFAULT_ACTIVITY_TYPE,
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
WORKSPACE_ID = config.WORKSPACE_ID
|
||||
USER_ID = config.USER_ID
|
||||
|
||||
|
||||
class RedmineAPI(RedmineConfig):
|
||||
"""API клиент для взаимодействия с Redmine.
|
||||
|
||||
@@ -26,61 +27,105 @@ class RedmineAPI(RedmineConfig):
|
||||
self.token = token
|
||||
self._check_token()
|
||||
|
||||
def track_activities(self, activities: list[Activity]) -> list[Activity]:
|
||||
def track_activities(self, activities: list[Activity], on_success=None) -> list[Activity]:
|
||||
"""Заносит трудочасы на сервер Redmine"""
|
||||
is_beginner = config.is_beginner
|
||||
if is_beginner:
|
||||
logging.info(f"{Fore.YELLOW}Делим трудочасы пополам{Fore.RESET}")
|
||||
logger.info(f"{Fore.YELLOW}Делим трудочасы пополам{Fore.RESET}")
|
||||
tracked = []
|
||||
for activity in tqdm(activities, desc="Занесение трудочасов"):
|
||||
if not(not activity.is_tracked and activity.date_end):
|
||||
continue
|
||||
failed_activities = []
|
||||
activities_to_track = [
|
||||
activity
|
||||
for activity in activities
|
||||
if not activity.is_tracked and activity.date_end
|
||||
]
|
||||
for activity in activities_to_track:
|
||||
try:
|
||||
if not config.is_debug:
|
||||
resp = requests.post(
|
||||
self.base_url + self.time_entries_url,
|
||||
headers={"X-Redmine-Api-Key": self.token},
|
||||
json={
|
||||
"time_entry": {
|
||||
"issue_id": activity.task_id,
|
||||
"hours": 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 resp.ok:
|
||||
if config.is_debug or resp.ok:
|
||||
activity.is_tracked = True
|
||||
if on_success:
|
||||
on_success(activity, resp.json()["time_entry"]["id"])
|
||||
tracked.append(activity)
|
||||
logger.debug(activity)
|
||||
logger.info(
|
||||
f"{Fore.GREEN}{self.format_activity_result(activity, is_beginner)}"
|
||||
f"{Fore.RESET}".ljust(100) + "✅"
|
||||
)
|
||||
else:
|
||||
logger.error(Fore.RED + f"{activity} Не была затрекана" + Fore.RED)
|
||||
logger.error(f"{resp.json()['errors']} {resp.status_code}")
|
||||
try:
|
||||
errors = resp.json()["errors"]
|
||||
except Exception:
|
||||
errors = resp.text
|
||||
logger.debug(errors)
|
||||
logger.error(f"{Fore.RED}{activity}: с ошибкой {Fore.RESET}".ljust(100) + "❌")
|
||||
logger.error(f"{errors} {resp.status_code}")
|
||||
failed_activities.append(activity)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
failed_activities.append(activity)
|
||||
continue
|
||||
if len(activities) == len(tracked):
|
||||
if len(activities_to_track) == len(tracked):
|
||||
logger.info(Fore.GREEN + "Все задачи были успешно занесены в Redmine" + Fore.RESET)
|
||||
else:
|
||||
logger.info(f"{Fore.YELLOW}Не все задачи были занесены в Redmine. Занесено {len(tracked)}{Fore.RESET}")
|
||||
logger.info(f"{Fore.YELLOW}Не занесены задачи:{Fore.RESET}")
|
||||
for activity in failed_activities:
|
||||
logger.info(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "❌")
|
||||
return tracked
|
||||
|
||||
def 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})
|
||||
logging.debug(resp.json())
|
||||
if resp.ok:
|
||||
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 resp.json().get("time_entry_activities")
|
||||
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 {
|
||||
@@ -91,18 +136,26 @@ class RedmineAPI(RedmineConfig):
|
||||
"username": json["login"]
|
||||
}
|
||||
|
||||
def get_issue(self, issue_id: int) -> dict:
|
||||
def get_issue(self, issue_id: int) -> Task | None:
|
||||
"""Получает задачу по её id с сервера Redmine"""
|
||||
try:
|
||||
resp = requests.get(
|
||||
self.base_url + self.issues.format(**{"id": issue_id}),
|
||||
self.base_url + self.issue.format(**{"id": issue_id}),
|
||||
headers={"X-Redmine-Api-Key": self.token},
|
||||
timeout=None
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as error:
|
||||
logger.error(
|
||||
"Не удалось загрузить задачу #%s из Redmine: %s",
|
||||
issue_id,
|
||||
error,
|
||||
)
|
||||
return None
|
||||
if not resp.ok:
|
||||
logger.error("Ошибка RedmineAPI")
|
||||
return {}
|
||||
logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
|
||||
return None
|
||||
issue = resp.json()["issue"]
|
||||
return {
|
||||
return Task(**{
|
||||
"id": issue["id"],
|
||||
"subject": issue["subject"],
|
||||
"description": issue["description"],
|
||||
@@ -111,8 +164,16 @@ class RedmineAPI(RedmineConfig):
|
||||
"author": issue["author"]["name"],
|
||||
"tracker": issue["tracker"]["name"],
|
||||
"project": issue["project"]["name"]
|
||||
}
|
||||
})
|
||||
|
||||
def get_issues(self, issue_ids: set[int]) -> list[Task]:
|
||||
"""Получает с сервера Redmine только задачи с указанными номерами."""
|
||||
return [
|
||||
task
|
||||
for issue_id in sorted(issue_ids)
|
||||
if (task := self.get_issue(issue_id))
|
||||
]
|
||||
|
||||
def _check_token(self):
|
||||
user = self.get_user()
|
||||
logging.debug(user)
|
||||
logger.debug(user)
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
from .classes import Activity, ActivityType, Tag, VetroProject # noqa: F401
|
||||
from .classes import ( # noqa: F401
|
||||
Activity,
|
||||
ActivityType,
|
||||
AggregatedActivity,
|
||||
Tag,
|
||||
VetroProject,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ from enums import ClockifyProjects, VetroProjects
|
||||
|
||||
@dataclass
|
||||
class Activity:
|
||||
"""Описывает одну активность, полученную из Clockify."""
|
||||
|
||||
id: str
|
||||
author_id: int
|
||||
vetro_project_id: VetroProjects
|
||||
@@ -19,7 +21,14 @@ class Activity:
|
||||
is_tracked: bool = False
|
||||
|
||||
def __str__(self):
|
||||
return f'|{self.date_start + timedelta(hours=3)}| {self.description} для задачи #{self.task_id}'
|
||||
return f'({self.task_id}) - "{self.description}"'[:60]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AggregatedActivity(Activity):
|
||||
"""Представляет несколько активностей Clockify одной записью Redmine."""
|
||||
|
||||
source_activities: tuple[Activity, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -46,3 +55,17 @@ class VetroProject:
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@dataclass
|
||||
class Task:
|
||||
id: int
|
||||
subject: str
|
||||
description: str
|
||||
status: str
|
||||
priority: str
|
||||
author: str
|
||||
tracker: str
|
||||
project: str
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.id}: {self.subject}"
|
||||
|
||||
@@ -3,8 +3,6 @@ from datetime import datetime
|
||||
from pydantic_settings import BaseSettings
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from db.db import Database
|
||||
|
||||
from .clockify import ClockifyConfig # noqa: F401
|
||||
from .redmine import RedmineConfig # noqa: F401
|
||||
|
||||
@@ -17,11 +15,14 @@ class Config(BaseSettings):
|
||||
USER_ID: str = None
|
||||
WORKSPACE_ID: str = None
|
||||
|
||||
MODE: str = "prod"
|
||||
|
||||
TRIAL_END_DATE: str = datetime.today().strftime("%Y-%m-%d")
|
||||
|
||||
ENV_TXT: str = ".env.txt"
|
||||
DB_NAME: str = "db/RedmineTracker.db"
|
||||
SQL_PATH: str = "config/RedmineTracker.sql"
|
||||
MIGRATIONS_PATH: str = "migrations"
|
||||
|
||||
DATETIME_FORMAT: str = "%Y-%m-%dT%H:%M:%SZ"
|
||||
|
||||
@@ -45,6 +46,10 @@ class Config(BaseSettings):
|
||||
from utils import str_to_date
|
||||
return datetime.today() < str_to_date(self.TRIAL_END_DATE)
|
||||
|
||||
@property
|
||||
def is_debug(self):
|
||||
return self.MODE == "debug"
|
||||
|
||||
|
||||
def configure_argument_parser():
|
||||
parser = argparse.ArgumentParser(description='Трекер трудочасов с Clockify в Redmine')
|
||||
@@ -61,6 +66,7 @@ def configure_argument_parser():
|
||||
parser.add_argument(
|
||||
'-p',
|
||||
'--page-size',
|
||||
type=int,
|
||||
help='Количество задач для вывода'
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -69,14 +75,21 @@ def configure_argument_parser():
|
||||
action="store_true",
|
||||
help='Режим отладки. Не загружает трудочасы в Redmine'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action="store_true",
|
||||
help='Предпросмотр. Показывает активности без их сохранения и отправки в Redmine'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-i',
|
||||
'--init-db',
|
||||
action="store_true",
|
||||
help='Режим инициализации бд.\nПодгружает с серверов теги, виды деятельности и проекты Ветро'
|
||||
help=(
|
||||
'Режим инициализации БД. Выполняет миграции и обновляет справочники '
|
||||
'без отправки трудочасов в Redmine'
|
||||
)
|
||||
)
|
||||
return parser
|
||||
|
||||
arg_parser = configure_argument_parser()
|
||||
config = Config()
|
||||
db = Database(config.DB_NAME, config.SQL_PATH)
|
||||
|
||||
@@ -10,7 +10,7 @@ class ClockifyConfig:
|
||||
self.user_url = "/user"
|
||||
self.projects_url = f"{self.workspace_url}/projects"
|
||||
|
||||
self.tags_count = 100
|
||||
self.tags_count = 20
|
||||
|
||||
def update_time_entry(self, activity_id):
|
||||
return f"{self.workspace_url}/time-entries/{activity_id}"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
REDMINE_ISSUE_URL_TEMPLATE = "https://redmine.sbps.ru/issues/{issue_id}"
|
||||
|
||||
|
||||
class RedmineConfig:
|
||||
def __init__(self):
|
||||
self.base_url = "https://redmine.sbps.ru"
|
||||
@@ -5,4 +8,5 @@ class RedmineConfig:
|
||||
self.my_account_url = "/my/account.json"
|
||||
self.datetime_format = "%Y-%m-%d"
|
||||
self.time_entry_activities = "/enumerations/time_entry_activities.json"
|
||||
self.issues = "/issues/{id}.json"
|
||||
self.issue = "/issues/{id}.json"
|
||||
self.issues = "/issues.json"
|
||||
|
||||
13
db/constants.py
Normal file
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",
|
||||
}
|
||||
)
|
||||
401
db/db.py
401
db/db.py
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
import sqlite3 as sq
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
from classes import Activity, ActivityType, Tag, VetroProject
|
||||
from classes.classes import Task
|
||||
from db.constants import USER_LOOKUP_FIELDS
|
||||
from enums import ActivityTypes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,94 +33,145 @@ class Database:
|
||||
logger.error("Ошибка при инициализации БД: ", e)
|
||||
self.con.rollback()
|
||||
|
||||
def run_migrations(self, migrations_path: str):
|
||||
"""Выполняет новые миграции и сохраняет время их применения."""
|
||||
self.cursor.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS migration (
|
||||
name TEXT PRIMARY KEY,
|
||||
executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
self.con.commit()
|
||||
applied = {
|
||||
row[0]
|
||||
for row in self.cursor.execute("SELECT name FROM migration").fetchall()
|
||||
}
|
||||
for path in sorted(Path(migrations_path).glob("*.py")):
|
||||
if path.name.startswith("_") or path.name in applied:
|
||||
continue
|
||||
try:
|
||||
spec = spec_from_file_location(f"migration_{path.stem}", path)
|
||||
if not spec or not spec.loader:
|
||||
raise ImportError(f"Не удалось загрузить миграцию {path.name}")
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
module.upgrade(self.con)
|
||||
self.cursor.execute(
|
||||
"INSERT INTO migration(name) VALUES(?)",
|
||||
(path.name,),
|
||||
)
|
||||
self.con.commit()
|
||||
except Exception:
|
||||
self.con.rollback()
|
||||
logger.exception("Не удалось выполнить миграцию %s", path.name)
|
||||
raise
|
||||
logger.info("Миграция %s успешно выполнена", path.name)
|
||||
|
||||
def insert_activities(self, activities: list[Activity]):
|
||||
"""Сохраняет в базу данных активности Clockify"""
|
||||
is_updated = True
|
||||
for act in activities:
|
||||
if is_updated:
|
||||
tags = self.cursor.execute("SELECT * FROM tag WHERE id = ?", (act.tag_id, )).fetchall()
|
||||
vetro_project_id = act.vetro_project_id
|
||||
if vetro_project_id:
|
||||
vetro_projects = self.cursor.execute("SELECT * FROM vetro_project WHERE id = ?", (vetro_project_id, )).fetchall()
|
||||
is_updated = tags or vetro_projects
|
||||
else:
|
||||
is_updated = bool(tags)
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"INSERT INTO activity VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
act.id,
|
||||
act.description,
|
||||
act.author_id,
|
||||
act.time_spent,
|
||||
act.date_start,
|
||||
act.date_end,
|
||||
act.is_tracked,
|
||||
act.vetro_project_id,
|
||||
act.tag_id,
|
||||
act.task_id,
|
||||
act.project_id,
|
||||
)
|
||||
)
|
||||
except sq.IntegrityError:
|
||||
self.cursor.execute(
|
||||
"""UPDATE activity
|
||||
SET description = ?, time_spent = ?, date_start = ?, date_end = ?, is_tracked = ?, vetro_project_id = ?, tag_id = ?, task_id = ?, project_id = ?
|
||||
WHERE id = ?""",
|
||||
(
|
||||
act.description,
|
||||
act.time_spent,
|
||||
act.date_start,
|
||||
act.date_end,
|
||||
act.is_tracked,
|
||||
act.vetro_project_id,
|
||||
act.tag_id,
|
||||
act.task_id,
|
||||
act.project_id,
|
||||
act.id,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
self.con.commit()
|
||||
if not is_updated:
|
||||
logging.info(Fore.YELLOW + "База данных устарела. Рекомендуем обновить с помощью флага -i [--init-db]" + Fore.RESET)
|
||||
|
||||
def insert_time_entries(self, activities: list[Activity]):
|
||||
"""Сохраняет в базу данных трудочасы Redmine"""
|
||||
for act in activities:
|
||||
self.cursor.execute(
|
||||
"""INSERT INTO activity(
|
||||
id,
|
||||
description,
|
||||
time_spent,
|
||||
date_start,
|
||||
date_end,
|
||||
is_tracked,
|
||||
vetro_project_id,
|
||||
tag_id,
|
||||
task_id,
|
||||
project_id,
|
||||
author_id
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
description = excluded.description,
|
||||
time_spent = excluded.time_spent,
|
||||
date_start = excluded.date_start,
|
||||
date_end = excluded.date_end,
|
||||
is_tracked = excluded.is_tracked,
|
||||
vetro_project_id = excluded.vetro_project_id,
|
||||
tag_id = excluded.tag_id,
|
||||
task_id = excluded.task_id,
|
||||
project_id = excluded.project_id,
|
||||
author_id = excluded.author_id""",
|
||||
(
|
||||
act.id,
|
||||
act.description,
|
||||
act.time_spent,
|
||||
act.date_start,
|
||||
act.date_end,
|
||||
act.is_tracked,
|
||||
act.vetro_project_id,
|
||||
act.tag_id,
|
||||
act.task_id,
|
||||
act.project_id,
|
||||
act.author_id,
|
||||
)
|
||||
)
|
||||
except sq.Error as e:
|
||||
self.con.rollback()
|
||||
logger.error(f"Не удалось сохранить активности: {e}")
|
||||
else:
|
||||
self.con.commit()
|
||||
|
||||
def insert_time_entry(
|
||||
self,
|
||||
activity: Activity,
|
||||
source_activities: list[Activity],
|
||||
redmine_time_entry_id: int,
|
||||
):
|
||||
"""Сохраняет запись Redmine и связывает с ней активности Clockify."""
|
||||
try:
|
||||
self.cursor.execute(
|
||||
"""INSERT INTO time_entry
|
||||
(date, description, activity_id, time_spent, task_id, activity_type_id, author_id)
|
||||
VALUES(?,?,?,?,?,?,?)""",
|
||||
(id, date, description, time_spent, task_id, activity_type_id, author_id)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
date = excluded.date,
|
||||
description = excluded.description,
|
||||
time_spent = excluded.time_spent,
|
||||
task_id = excluded.task_id,
|
||||
activity_type_id = excluded.activity_type_id,
|
||||
author_id = excluded.author_id""",
|
||||
(
|
||||
act.date_start.date(),
|
||||
act.description,
|
||||
act.id,
|
||||
act.time_spent,
|
||||
act.task_id,
|
||||
redmine_time_entry_id,
|
||||
activity.date_start.date(),
|
||||
activity.description,
|
||||
activity.time_spent,
|
||||
activity.task_id,
|
||||
ActivityTypes.DEVELOPMENT,
|
||||
act.author_id
|
||||
activity.author_id,
|
||||
)
|
||||
)
|
||||
self.cursor.executemany(
|
||||
"UPDATE activity SET time_entry_id = ? WHERE id = ?",
|
||||
[
|
||||
(redmine_time_entry_id, source_activity.id)
|
||||
for source_activity in source_activities
|
||||
],
|
||||
)
|
||||
except sq.IntegrityError as e:
|
||||
self.con.rollback()
|
||||
logger.error(f"Не удалось сохранить трудочасы: {e}")
|
||||
else:
|
||||
self.con.commit()
|
||||
except sq.IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
except sq.Error as e:
|
||||
self.con.rollback()
|
||||
logger.error(f"Не удалось сохранить теги: {e}")
|
||||
else:
|
||||
self.con.commit()
|
||||
|
||||
|
||||
@@ -124,32 +179,32 @@ class Database:
|
||||
"""Сохраняет в базу данных Проекты (ДО) Ветро 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)
|
||||
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:
|
||||
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)
|
||||
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:
|
||||
except sq.Error as e:
|
||||
self.con.rollback()
|
||||
logger.error(f"Не удалось сохранить типы деятельности: {e}")
|
||||
else:
|
||||
self.con.commit()
|
||||
|
||||
def insert_user(self, **kwargs):
|
||||
@@ -158,35 +213,80 @@ class Database:
|
||||
self.cursor.execute("""INSERT INTO user(
|
||||
clockify_user_id, clockify_email, clockify_username,
|
||||
redmine_user_id, redmine_email, first_name, last_name, redmine_username)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?)""", [v for k, v in kwargs.items()])
|
||||
except sq.IntegrityError:
|
||||
self.cursor.execute("""
|
||||
UPDATE user
|
||||
SET clockify_email = ?,
|
||||
clockify_username = ?,
|
||||
redmine_email = ?,
|
||||
first_name = ?,
|
||||
last_name = ?,
|
||||
redmine_username = ?
|
||||
WHERE redmine_user_id = ? AND clockify_user_id = ?""",
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(clockify_user_id) DO UPDATE SET
|
||||
clockify_email = excluded.clockify_email,
|
||||
clockify_username = excluded.clockify_username,
|
||||
redmine_email = excluded.redmine_email,
|
||||
first_name = excluded.first_name,
|
||||
last_name = excluded.last_name,
|
||||
redmine_username = excluded.redmine_username""",
|
||||
(
|
||||
kwargs["clockify_user_id"],
|
||||
kwargs["clockify_email"],
|
||||
kwargs["clockify_username"],
|
||||
kwargs["redmine_user_id"],
|
||||
kwargs["redmine_email"],
|
||||
kwargs["first_name"],
|
||||
kwargs["last_name"],
|
||||
kwargs["redmine_username"],
|
||||
kwargs["redmine_user_id"],
|
||||
kwargs["clockify_user_id"]
|
||||
)
|
||||
)
|
||||
finally:
|
||||
except sq.Error as e:
|
||||
self.con.rollback()
|
||||
logger.error(f"Не удалось сохранить пользователя: {e}")
|
||||
else:
|
||||
self.con.commit()
|
||||
|
||||
def insert_tasks(self, tasks: list[Task]):
|
||||
"""Сохраняет в БД Задачи из Redmine"""
|
||||
try:
|
||||
for task in tasks:
|
||||
self.cursor.execute("""
|
||||
INSERT INTO task(
|
||||
"id",
|
||||
"subject",
|
||||
"description",
|
||||
"status",
|
||||
"priority",
|
||||
"author",
|
||||
"tracker",
|
||||
"project"
|
||||
)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
"subject" = excluded."subject",
|
||||
"description" = excluded."description",
|
||||
"status" = excluded."status",
|
||||
"priority" = excluded."priority",
|
||||
"author" = excluded."author",
|
||||
"tracker" = excluded."tracker",
|
||||
"project" = excluded."project" """,
|
||||
[
|
||||
task.id,
|
||||
task.subject,
|
||||
task.description,
|
||||
task.status,
|
||||
task.priority,
|
||||
task.author,
|
||||
task.tracker,
|
||||
task.project,
|
||||
])
|
||||
except sq.Error as e:
|
||||
self.con.rollback()
|
||||
logger.error(f"Не удалось сохранить задачи: {e}")
|
||||
else:
|
||||
self.con.commit()
|
||||
|
||||
def get_user_by(self, by_field: str, value: str):
|
||||
"""Получает пользователя из бд"""
|
||||
if by_field not in USER_LOOKUP_FIELDS:
|
||||
raise ValueError(f"Недопустимое поле поиска пользователя: {by_field}")
|
||||
try:
|
||||
user = self.cursor.execute(f"SELECT * FROM user WHERE {by_field} = ?", (value,)).fetchone()
|
||||
if not user:
|
||||
logger.error(f"Пользователь не найден: {by_field}={value}")
|
||||
return None
|
||||
return {
|
||||
"id": user[0],
|
||||
"first_name": user[1],
|
||||
@@ -199,61 +299,72 @@ class Database:
|
||||
"redmine_username": user[8]
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
logger.error(e)
|
||||
|
||||
|
||||
def get_tags(self) -> list[Tag]:
|
||||
"""Получает теги из бд"""
|
||||
try:
|
||||
tags = self.cursor.execute("SELECT * FROM tag").fetchall()
|
||||
return [
|
||||
Tag(
|
||||
id=tag[0],
|
||||
title=tag[1]
|
||||
)
|
||||
for tag in tags
|
||||
for tag in self.cursor.execute("SELECT * FROM tag").fetchall()
|
||||
]
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
logger.error(e)
|
||||
|
||||
def insert_tasks(self, tasks: list[dict]):
|
||||
"""Сохраняет в БД Задачи из Redmine"""
|
||||
for task in tasks:
|
||||
def get_tasks(self) -> list[Task]:
|
||||
"""Получает задачи из бд"""
|
||||
try:
|
||||
self.cursor.execute("""
|
||||
INSERT INTO task(
|
||||
"id",
|
||||
"subject",
|
||||
"description",
|
||||
"status",
|
||||
"priority",
|
||||
"author",
|
||||
"tracker",
|
||||
"project"
|
||||
)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[v for k, v in task.items()])
|
||||
except sq.IntegrityError:
|
||||
self.cursor.execute("""
|
||||
UPDATE task
|
||||
SET "subject" = ?,
|
||||
"description" = ?,
|
||||
"status" = ?,
|
||||
"priority" = ?,
|
||||
"author" = ?,
|
||||
"tracker" = ?,
|
||||
"project" = ?
|
||||
WHERE "id" = ?""",
|
||||
(
|
||||
task["subject"],
|
||||
task["description"],
|
||||
task["status"],
|
||||
task["priority"],
|
||||
task["author"],
|
||||
task["tracker"],
|
||||
task["project"],
|
||||
task["id"]
|
||||
)
|
||||
)
|
||||
finally:
|
||||
self.con.commit()
|
||||
return [
|
||||
Task(**{
|
||||
"id": task[0],
|
||||
"subject": task[1],
|
||||
"description": task[2],
|
||||
"status": task[3],
|
||||
"priority": task[4],
|
||||
"author": task[5],
|
||||
"tracker": task[6],
|
||||
"project": task[7]
|
||||
}) for task in self.cursor.execute("SELECT * FROM task").fetchall()
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def get_vetro_projects(self) -> list[VetroProject]:
|
||||
"""Получает Проекты Ветро из бд"""
|
||||
try:
|
||||
return [
|
||||
VetroProject(**{
|
||||
"id": vetro_project[0],
|
||||
"title": vetro_project[1]
|
||||
}) for vetro_project in self.cursor.execute("SELECT * FROM vetro_project").fetchall()
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def get_activity_types(self) -> list[ActivityType]:
|
||||
"""Получает Виды деятельности из бд"""
|
||||
try:
|
||||
return [
|
||||
ActivityType(**{
|
||||
"id": activity_type[0],
|
||||
"title": activity_type[1]
|
||||
}) for activity_type in self.cursor.execute("SELECT * FROM activity_type").fetchall()
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def has_time_entry(self, activity_id: str) -> bool:
|
||||
"""Проверяет, есть ли запись о времени для активности"""
|
||||
try:
|
||||
result = self.cursor.execute(
|
||||
"SELECT time_entry_id FROM activity WHERE id = ? AND time_entry_id IS NOT NULL",
|
||||
(activity_id,),
|
||||
).fetchone()
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return False
|
||||
|
||||
95
main.py
Normal file
95
main.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
from config.redmine import REDMINE_ISSUE_URL_TEMPLATE
|
||||
|
||||
|
||||
class ConsoleFormatter(logging.Formatter):
|
||||
"""Форматирует консольные логи и делает номера задач Redmine кликабельными."""
|
||||
|
||||
ISSUE_PATTERN = re.compile(r"#(?P<issue_id>\d+)")
|
||||
|
||||
def format(self, record):
|
||||
message = super().format(record)
|
||||
return self.ISSUE_PATTERN.sub(self._format_issue_link, message)
|
||||
|
||||
def _format_issue_link(self, match):
|
||||
issue_id = match.group("issue_id")
|
||||
issue_text = match.group(0)
|
||||
issue_url = REDMINE_ISSUE_URL_TEMPLATE.format(issue_id=issue_id)
|
||||
return f"\033]8;;{issue_url}\033\\{issue_text}\033]8;;\033\\"
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO):
|
||||
"""Настраивает короткий консольный лог и подробный файловый лог."""
|
||||
file_handler = logging.FileHandler('app.log', encoding='utf-8')
|
||||
file_handler.setFormatter(
|
||||
logging.Formatter('%(asctime)s %(name)s:%(lineno)d %(levelname)s %(message)s')
|
||||
)
|
||||
file_handler.setLevel(level)
|
||||
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(ConsoleFormatter('%(message)s'))
|
||||
console_handler.setLevel(level)
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
handlers=[file_handler, console_handler],
|
||||
force=True,
|
||||
)
|
||||
|
||||
|
||||
from api.clockify import ClockifyAPI # noqa: E402
|
||||
from api.redmine import RedmineAPI # noqa: E402
|
||||
from config import arg_parser, config # noqa: E402
|
||||
from db.db import Database # noqa: E402
|
||||
from services.tracking import TrackingService # noqa: E402
|
||||
from utils import check_envs, db_update, get_start_end_dates # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
"""Запускает CLI-сценарий переноса трудочасов из Clockify в Redmine."""
|
||||
args = arg_parser.parse_args()
|
||||
log_level = logging.DEBUG if args.debug else logging.INFO
|
||||
configure_logging(log_level)
|
||||
|
||||
if is_debug := args.debug:
|
||||
config.MODE = "debug"
|
||||
if is_dry_run := args.dry_run:
|
||||
config.MODE = "debug"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
db = Database(config.DB_NAME, config.SQL_PATH)
|
||||
red = RedmineAPI(config.REDMINE_TOKEN)
|
||||
clock = ClockifyAPI(
|
||||
token=config.CLOCKIFY_TOKEN,
|
||||
workspace_id=config.WORKSPACE_ID,
|
||||
user_id=config.USER_ID,
|
||||
db=db,
|
||||
redmine_api=red,
|
||||
)
|
||||
|
||||
start, end = get_start_end_dates(args)
|
||||
|
||||
if not check_envs(clock):
|
||||
return
|
||||
|
||||
if args.init_db:
|
||||
db.run_migrations(config.MIGRATIONS_PATH)
|
||||
db_update(clock, db, red)
|
||||
return
|
||||
|
||||
try:
|
||||
TrackingService(
|
||||
clock=clock,
|
||||
red=red,
|
||||
db=db,
|
||||
is_debug=is_debug,
|
||||
is_dry_run=is_dry_run,
|
||||
).track_period(start=start, end=end, page_size=args.page_size)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
migrations/__init__.py
Normal file
1
migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Миграции локальной базы данных приложения."""
|
||||
46
migrations/a7f3c9e1_redmine_clockify_relation.py
Normal file
46
migrations/a7f3c9e1_redmine_clockify_relation.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Меняет связь Redmine и Clockify с один-к-одному на один-ко-многим."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
def upgrade(connection: sqlite3.Connection):
|
||||
"""Переносит внешний ключ записи Redmine в активности Clockify."""
|
||||
connection.execute(
|
||||
"""ALTER TABLE activity
|
||||
ADD COLUMN time_entry_id INTEGER REFERENCES time_entry(id)"""
|
||||
)
|
||||
connection.execute(
|
||||
"""UPDATE activity
|
||||
SET time_entry_id = (
|
||||
SELECT time_entry.id
|
||||
FROM time_entry
|
||||
WHERE time_entry.activity_id = activity.id
|
||||
LIMIT 1
|
||||
)"""
|
||||
)
|
||||
connection.execute(
|
||||
"""CREATE TABLE time_entry_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
description VARCHAR NOT NULL,
|
||||
date DATETIME NOT NULL,
|
||||
time_spent NUMERIC NOT NULL,
|
||||
task_id INTEGER NOT NULL,
|
||||
activity_type_id INTEGER NOT NULL,
|
||||
author_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (author_id) REFERENCES user(id),
|
||||
FOREIGN KEY (task_id) REFERENCES task(id),
|
||||
FOREIGN KEY (activity_type_id) REFERENCES activity_type(id)
|
||||
)"""
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT INTO time_entry_new(
|
||||
id, description, date, time_spent, task_id, activity_type_id, author_id
|
||||
)
|
||||
SELECT id, description, date, time_spent, task_id, activity_type_id, author_id
|
||||
FROM time_entry"""
|
||||
)
|
||||
connection.execute("DROP TABLE time_entry")
|
||||
connection.execute("ALTER TABLE time_entry_new RENAME TO time_entry")
|
||||
connection.execute(
|
||||
"CREATE INDEX activity_time_entry_id_idx ON activity(time_entry_id)"
|
||||
)
|
||||
27
pyproject.toml
Normal file
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", "migrations"]
|
||||
1
services/__init__.py
Normal file
1
services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Сервисы бизнес-сценариев приложения."""
|
||||
134
services/tracking.py
Normal file
134
services/tracking.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Сервис переноса трудочасов из Clockify в Redmine."""
|
||||
|
||||
import logging
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
from classes import Activity, AggregatedActivity
|
||||
from utils import format_date, format_hours
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def aggregate_activities(activities: list[Activity]) -> list[AggregatedActivity]:
|
||||
"""Объединяет одинаковые активности и суммирует их трудозатраты."""
|
||||
grouped_activities = {}
|
||||
for activity in activities:
|
||||
key = (
|
||||
activity.author_id,
|
||||
activity.task_id,
|
||||
activity.tag_id,
|
||||
activity.project_id,
|
||||
activity.vetro_project_id,
|
||||
activity.date_start.date(),
|
||||
)
|
||||
grouped_activities.setdefault(key, []).append(activity)
|
||||
|
||||
result = []
|
||||
for grouped in grouped_activities.values():
|
||||
descriptions = list(
|
||||
dict.fromkeys(
|
||||
description.rstrip(".")
|
||||
for activity in grouped
|
||||
if (description := activity.description.strip())
|
||||
)
|
||||
)
|
||||
result.append(
|
||||
AggregatedActivity(
|
||||
**{
|
||||
**grouped[0].__dict__,
|
||||
"description": ". ".join(descriptions),
|
||||
"time_spent": sum(activity.time_spent for activity in grouped),
|
||||
},
|
||||
source_activities=tuple(grouped),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class TrackingService:
|
||||
"""Координирует загрузку, сохранение и перенос активностей в Redmine."""
|
||||
|
||||
def __init__(self, clock, red, db, is_debug: bool, is_dry_run: bool = False):
|
||||
self.clock = clock
|
||||
self.red = red
|
||||
self.db = db
|
||||
self.is_debug = is_debug
|
||||
self.is_dry_run = is_dry_run
|
||||
|
||||
def track_period(self, start, end, page_size: int | None):
|
||||
"""Переносит активности за период и возвращает список найденных активностей."""
|
||||
self._save_current_user()
|
||||
activities = sorted(
|
||||
self.clock.get_activities(
|
||||
**{
|
||||
"start": format_date(start),
|
||||
"end": format_date(end),
|
||||
"page-size": page_size,
|
||||
},
|
||||
),
|
||||
key=lambda x: x.date_start,
|
||||
)
|
||||
if not activities:
|
||||
logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET)
|
||||
return []
|
||||
activities_to_track = aggregate_activities(
|
||||
[
|
||||
activity
|
||||
for activity in activities
|
||||
if not activity.is_tracked and activity.date_end
|
||||
]
|
||||
)
|
||||
if self.is_dry_run:
|
||||
self._log_preview(activities, activities_to_track)
|
||||
return activities
|
||||
|
||||
self.db.insert_activities(activities)
|
||||
self.red.track_activities(
|
||||
activities_to_track,
|
||||
on_success=None if self.is_debug else self._save_tracked_activity,
|
||||
)
|
||||
return activities
|
||||
|
||||
def _save_current_user(self):
|
||||
"""Сохраняет связку текущих пользователей Clockify и Redmine."""
|
||||
user = self.clock.get_user()
|
||||
clock_user = {
|
||||
"clockify_user_id": user["id"],
|
||||
"clockify_email": user["email"],
|
||||
"clockify_username": user["username"],
|
||||
}
|
||||
user = self.red.get_user()
|
||||
red_user = {
|
||||
"redmine_user_id": user["id"],
|
||||
"redmine_email": user["email"],
|
||||
"first_name": user["first_name"],
|
||||
"last_name": user["last_name"],
|
||||
"redmine_username": user["username"],
|
||||
}
|
||||
self.db.insert_user(**clock_user, **red_user)
|
||||
|
||||
def _save_tracked_activity(self, activity, redmine_time_entry_id: int):
|
||||
"""Сохраняет активность как успешно перенесенную в Redmine."""
|
||||
source_activities = list(activity.source_activities or (activity,))
|
||||
for source_activity in source_activities:
|
||||
source_activity.is_tracked = True
|
||||
self.db.insert_activities(source_activities)
|
||||
self.db.insert_time_entry(
|
||||
activity,
|
||||
source_activities,
|
||||
redmine_time_entry_id,
|
||||
)
|
||||
|
||||
def _log_preview(self, activities, activities_to_track):
|
||||
"""Выводит план переноса активностей без сохранения и отправки."""
|
||||
total_hours = sum(activity.time_spent for activity in activities_to_track)
|
||||
logger.info(f"{Fore.YELLOW}Предпросмотр переноса трудочасов{Fore.RESET}")
|
||||
logger.info(f"Найдено активностей: {len(activities)}")
|
||||
logger.info(f"Готово к переносу: {len(activities_to_track)}")
|
||||
logger.info(f"Всего часов: {format_hours(total_hours)}")
|
||||
for activity in activities_to_track:
|
||||
logger.info(
|
||||
f"{activity.date_start.date()} | #{activity.task_id} | "
|
||||
f"{format_hours(activity.time_spent)} | {activity.description}"
|
||||
)
|
||||
78
tests/test_migrations.py
Normal file
78
tests/test_migrations.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Тесты применения миграций локальной базы данных."""
|
||||
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import TestCase
|
||||
|
||||
from db.db import Database
|
||||
|
||||
|
||||
class MigrationTestCase(TestCase):
|
||||
"""Проверяет миграцию связи Redmine с активностями Clockify."""
|
||||
|
||||
def test_migration_creates_one_to_many_relation_and_runs_once(self):
|
||||
"""Миграция переносит связь и не выполняется повторно."""
|
||||
with TemporaryDirectory() as directory:
|
||||
db_path = Path(directory) / "tracker.db"
|
||||
db = Database(str(db_path), "config/RedmineTracker.sql")
|
||||
|
||||
db.run_migrations("migrations")
|
||||
db.run_migrations("migrations")
|
||||
|
||||
activity_columns = {
|
||||
row[1]
|
||||
for row in db.cursor.execute("PRAGMA table_info(activity)").fetchall()
|
||||
}
|
||||
time_entry_columns = {
|
||||
row[1]
|
||||
for row in db.cursor.execute("PRAGMA table_info(time_entry)").fetchall()
|
||||
}
|
||||
migration = db.cursor.execute(
|
||||
"SELECT name, executed_at FROM migration"
|
||||
).fetchall()
|
||||
activity_foreign_keys = {
|
||||
row[3]: row[2]
|
||||
for row in db.cursor.execute(
|
||||
"PRAGMA foreign_key_list(activity)"
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
self.assertIn("time_entry_id", activity_columns)
|
||||
self.assertNotIn("activity_id", time_entry_columns)
|
||||
self.assertEqual(activity_foreign_keys["time_entry_id"], "time_entry")
|
||||
self.assertEqual(len(migration), 1)
|
||||
self.assertEqual(
|
||||
migration[0][0],
|
||||
"a7f3c9e1_redmine_clockify_relation.py",
|
||||
)
|
||||
self.assertIsNotNone(migration[0][1])
|
||||
db.con.close()
|
||||
|
||||
def test_multiple_activities_can_reference_one_time_entry(self):
|
||||
"""Несколько активностей Clockify могут ссылаться на одну запись Redmine."""
|
||||
with TemporaryDirectory() as directory:
|
||||
db_path = Path(directory) / "tracker.db"
|
||||
db = Database(str(db_path), "config/RedmineTracker.sql")
|
||||
db.run_migrations("migrations")
|
||||
db.cursor.execute("PRAGMA foreign_keys = OFF")
|
||||
db.cursor.execute(
|
||||
"""INSERT INTO time_entry(
|
||||
id, description, date, time_spent,
|
||||
task_id, activity_type_id, author_id
|
||||
) VALUES(42, 'Работа', '2026-07-14', 1, 51906, 9, 1)"""
|
||||
)
|
||||
for activity_id in ("first", "second"):
|
||||
db.cursor.execute(
|
||||
"""INSERT INTO activity(
|
||||
id, description, author_id, time_spent, date_start,
|
||||
tag_id, time_entry_id
|
||||
) VALUES(?, 'Работа', 1, 0.5, '2026-07-14', 'tag', 42)""",
|
||||
(activity_id,),
|
||||
)
|
||||
|
||||
count = db.cursor.execute(
|
||||
"SELECT COUNT(*) FROM activity WHERE time_entry_id = 42"
|
||||
).fetchone()[0]
|
||||
|
||||
self.assertEqual(count, 2)
|
||||
db.con.close()
|
||||
43
tests/test_redmine.py
Normal file
43
tests/test_redmine.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Тесты выборочной загрузки задач Redmine."""
|
||||
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from api.redmine import RedmineAPI
|
||||
|
||||
|
||||
class RedmineAPITestCase(TestCase):
|
||||
"""Проверяет запрос только необходимых приложению задач."""
|
||||
|
||||
def test_get_issues_requests_only_given_issue_ids(self):
|
||||
"""Задачи загружаются отдельно и только по номерам тегов Clockify."""
|
||||
redmine = RedmineAPI.__new__(RedmineAPI)
|
||||
redmine.get_issue = Mock(side_effect=lambda issue_id: issue_id)
|
||||
|
||||
result = redmine.get_issues({60130, 51906})
|
||||
|
||||
self.assertEqual(result, [51906, 60130])
|
||||
self.assertEqual(
|
||||
[call.args[0] for call in redmine.get_issue.call_args_list],
|
||||
[51906, 60130],
|
||||
)
|
||||
|
||||
def test_get_issues_skips_unavailable_issue(self):
|
||||
"""Недоступная задача не мешает загрузить остальные указанные задачи."""
|
||||
redmine = RedmineAPI.__new__(RedmineAPI)
|
||||
redmine.get_issue = Mock(side_effect=[51906, None])
|
||||
|
||||
self.assertEqual(redmine.get_issues({51906, 60130}), [51906])
|
||||
|
||||
@patch("api.redmine.requests.get")
|
||||
def test_get_issue_returns_none_on_timeout(self, get):
|
||||
"""Сетевой таймаут одной задачи не прерывает обновление БД."""
|
||||
get.side_effect = requests.ConnectTimeout("Redmine недоступен")
|
||||
redmine = RedmineAPI.__new__(RedmineAPI)
|
||||
redmine.base_url = "https://redmine.example/"
|
||||
redmine.issue = "issues/{id}.json"
|
||||
redmine.token = "token"
|
||||
|
||||
self.assertIsNone(redmine.get_issue(38243))
|
||||
114
tests/test_tracking.py
Normal file
114
tests/test_tracking.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Тесты агрегации активностей Clockify перед переносом в Redmine."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
|
||||
from classes import Activity
|
||||
from services.tracking import TrackingService, aggregate_activities
|
||||
|
||||
|
||||
class TrackingServiceTestCase(TestCase):
|
||||
"""Проверяет объединение и сохранение активностей Clockify."""
|
||||
|
||||
def create_activity(self, activity_id: str, description: str, **changes) -> Activity:
|
||||
"""Создаёт активность с типовыми значениями для теста."""
|
||||
values = {
|
||||
"id": activity_id,
|
||||
"author_id": 1,
|
||||
"vetro_project_id": "vetro-project",
|
||||
"task_id": 51906,
|
||||
"tag_id": "tag",
|
||||
"description": description,
|
||||
"project_id": "project",
|
||||
"time_spent": 0.5,
|
||||
"date_start": datetime(2026, 7, 14, 9),
|
||||
"date_end": datetime(2026, 7, 14, 9, 30),
|
||||
}
|
||||
values.update(changes)
|
||||
return Activity(**values)
|
||||
|
||||
def test_fully_matching_activities_are_merged(self):
|
||||
"""Полностью одинаковые активности образуют одну запись без повтора описания."""
|
||||
activities = [
|
||||
self.create_activity("first", "Доработки"),
|
||||
self.create_activity(
|
||||
"second",
|
||||
"Доработки",
|
||||
date_start=datetime(2026, 7, 14, 10),
|
||||
date_end=datetime(2026, 7, 14, 10, 30),
|
||||
),
|
||||
]
|
||||
|
||||
result = aggregate_activities(activities)
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0].time_spent, 1)
|
||||
self.assertEqual(result[0].description, "Доработки")
|
||||
self.assertEqual(result[0].source_activities, tuple(activities))
|
||||
|
||||
def test_descriptions_are_joined_for_otherwise_matching_activities(self):
|
||||
"""Разные описания одинаковых активностей объединяются через точку."""
|
||||
result = aggregate_activities(
|
||||
[
|
||||
self.create_activity("first", "Доработки"),
|
||||
self.create_activity("second", "Влил в тест.", time_spent=0.25),
|
||||
self.create_activity("third", "Доработки", time_spent=0.25),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0].time_spent, 1)
|
||||
self.assertEqual(result[0].description, "Доработки. Влил в тест")
|
||||
|
||||
def test_activities_for_different_dates_are_not_merged(self):
|
||||
"""Активности за разные даты остаются отдельными записями Redmine."""
|
||||
result = aggregate_activities(
|
||||
[
|
||||
self.create_activity("first", "Доработки"),
|
||||
self.create_activity(
|
||||
"second",
|
||||
"Доработки",
|
||||
date_start=datetime(2026, 7, 15, 9),
|
||||
date_end=datetime(2026, 7, 15, 9, 30),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_all_source_activities_are_marked_as_tracked(self):
|
||||
"""После успешной отправки сохраняются все исходные таймеры Clockify."""
|
||||
activities = [
|
||||
self.create_activity("first", "Доработки"),
|
||||
self.create_activity("second", "Влил в тест"),
|
||||
]
|
||||
aggregated_activity = aggregate_activities(activities)[0]
|
||||
db = Mock()
|
||||
service = TrackingService(
|
||||
clock=Mock(),
|
||||
red=Mock(),
|
||||
db=db,
|
||||
is_debug=False,
|
||||
)
|
||||
|
||||
service._save_tracked_activity(aggregated_activity, 42)
|
||||
|
||||
self.assertTrue(all(activity.is_tracked for activity in activities))
|
||||
db.insert_activities.assert_called_once_with(activities)
|
||||
db.insert_time_entry.assert_called_once_with(
|
||||
aggregated_activity,
|
||||
activities,
|
||||
42,
|
||||
)
|
||||
|
||||
def test_activities_for_different_tasks_are_not_merged(self):
|
||||
"""Активности разных задач остаются отдельными записями Redmine."""
|
||||
result = aggregate_activities(
|
||||
[
|
||||
self.create_activity("first", "Доработки"),
|
||||
self.create_activity("second", "Доработки", task_id=60130),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(len(result), 2)
|
||||
138
track.py
138
track.py
@@ -1,141 +1,7 @@
|
||||
import logging
|
||||
import os.path
|
||||
import sys
|
||||
"""Совместимая точка входа для старых запусков через track.py."""
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('app.log', encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
from main import main
|
||||
|
||||
|
||||
from colorama import Fore # noqa: E402
|
||||
|
||||
from api import ClockifyAPI, RedmineAPI # noqa: E402
|
||||
from config import arg_parser, config, db # noqa: E402
|
||||
from utils import format_date, str_to_date, today_start, today_end, parse_task_id # noqa: E402
|
||||
|
||||
|
||||
WORKSPACE_ID = config.WORKSPACE_ID
|
||||
USER_ID = config.USER_ID
|
||||
|
||||
|
||||
def main():
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
start, end = None, None
|
||||
if args.start:
|
||||
start = str_to_date(args.start)
|
||||
if args.end:
|
||||
end = str_to_date(args.end)
|
||||
if args.start is None and args.end is None:
|
||||
logger.info("Парсим задачи за сегодняшний день")
|
||||
start = today_start()
|
||||
end = today_end()
|
||||
else:
|
||||
if start:
|
||||
from_ = f"{start.day} {config.MONTHS.get(start.month)} {start.year} г."
|
||||
|
||||
if end:
|
||||
to_ = f"{end.day} {config.MONTHS.get(end.month)} {end.year} г."
|
||||
|
||||
if start == end:
|
||||
logger.info(f"{Fore.YELLOW}Парсим задачи за {from_}{Fore.RESET}")
|
||||
else:
|
||||
logger.info(Fore.YELLOW + "Парсим задачи" + (f" с {from_} " if start else " ") + (f"до {to_}" if end else "") + Fore.RESET)
|
||||
|
||||
clock = ClockifyAPI(
|
||||
token=config.CLOCKIFY_TOKEN,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
user_id=USER_ID
|
||||
)
|
||||
if not WORKSPACE_ID or not USER_ID:
|
||||
if not os.path.exists(config.ENV_TXT):
|
||||
logging.info(f"{Fore.YELLOW}Необходимые переменные не были обнаружены. Попытка получения...{Fore.RESET}")
|
||||
clock.getenvs()
|
||||
else:
|
||||
logger.info(f'{Fore.YELLOW}Проверьте файл "{config.ENV_TXT}". Если необходимых переменных в нём нет, то просто удалите его.{Fore.RESET}')
|
||||
return
|
||||
red = RedmineAPI(config.REDMINE_TOKEN)
|
||||
user = clock.get_user()
|
||||
clock_user = {
|
||||
"clockify_user_id": user["id"],
|
||||
"clockify_email": user["email"],
|
||||
"clockify_username": user["username"],
|
||||
}
|
||||
user = red.get_user()
|
||||
red_user = {
|
||||
"redmine_user_id": user["id"],
|
||||
"redmine_email": user["email"],
|
||||
"first_name": user["first_name"],
|
||||
"last_name": user["last_name"],
|
||||
"redmine_username": user["username"]
|
||||
}
|
||||
db.insert_user(**clock_user, **red_user)
|
||||
if args.init_db:
|
||||
logger.info(Fore.YELLOW + "Обновление базы данных..." + Fore.RESET)
|
||||
try:
|
||||
logging.info(f"{Fore.YELLOW}Получение Тегов{Fore.RESET}")
|
||||
tags = clock.get_tags(is_updating=True, **{"page-size": clock.tags_count})
|
||||
|
||||
logging.info(f"{Fore.YELLOW}Получение Задач с Redmine{Fore.RESET}")
|
||||
issues = [red.get_issue(parse_task_id(tag.title)) for tag in tags]
|
||||
|
||||
logging.info(f"{Fore.YELLOW}Получение Проектов Ветро{Fore.RESET}")
|
||||
vetro_projects = clock.get_vetro_projects()
|
||||
|
||||
logging.info(f"{Fore.YELLOW}Получение Типов деятельности{Fore.RESET}")
|
||||
activity_types = red.get_time_entry_activities()
|
||||
|
||||
if tags:
|
||||
logging.info(f"{Fore.YELLOW}Сохранение Тегов{Fore.RESET}")
|
||||
db.insert_tags(tags)
|
||||
|
||||
if issues:
|
||||
logging.info(f"{Fore.YELLOW}Сохранение Задач Redmine{Fore.RESET}")
|
||||
db.insert_tasks(issues)
|
||||
|
||||
if vetro_projects:
|
||||
logging.info(f"{Fore.YELLOW}Сохранение Проектов Ветро{Fore.RESET}")
|
||||
db.insert_vetro_projects(vetro_projects)
|
||||
|
||||
if activity_types:
|
||||
logging.info(f"{Fore.YELLOW}Сохранение Типов деятелности{Fore.RESET}")
|
||||
db.insert_activity_types(activity_types)
|
||||
except Exception as e:
|
||||
logger.error(Fore.RED + f"Ошибка при обновлении базы данных: {e}" + Fore.RESET)
|
||||
else:
|
||||
logger.info(Fore.GREEN + "База данных теперь актуальна." + Fore.RESET)
|
||||
|
||||
activities = sorted(clock.get_activities(
|
||||
**{
|
||||
"start": format_date(start),
|
||||
"end": format_date(end),
|
||||
"page-size": args.page_size,
|
||||
},
|
||||
), key=lambda x: x.date_start)
|
||||
if not activities:
|
||||
logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET)
|
||||
return
|
||||
db.insert_activities(activities)
|
||||
if args.debug:
|
||||
logger.debug("Пропускаем этап загрузки трудочасов")
|
||||
return
|
||||
try:
|
||||
tracked_activities = red.track_activities(activities)
|
||||
clock.mark_tracked(tracked_activities)
|
||||
db.insert_time_entries(tracked_activities)
|
||||
db.insert_activities(tracked_activities)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
148
utils.py
148
utils.py
@@ -1,11 +1,30 @@
|
||||
from argparse import Namespace
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from colorama import Fore
|
||||
|
||||
from config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def time_to_hour(time: timedelta) -> float:
|
||||
return time.total_seconds() / 3600
|
||||
return round(time.total_seconds() / 3600, 3)
|
||||
|
||||
def format_hours(hours: float) -> str:
|
||||
"""Форматирует десятичные часы и длительность без нулевых старших единиц."""
|
||||
total_seconds = round(hours * 3600)
|
||||
hours_part, remaining_seconds = divmod(total_seconds, 3600)
|
||||
minutes_part, seconds_part = divmod(remaining_seconds, 60)
|
||||
if hours_part:
|
||||
duration = f"{hours_part}:{minutes_part:02}:{seconds_part:02}"
|
||||
elif minutes_part:
|
||||
duration = f"{minutes_part}:{seconds_part:02}"
|
||||
else:
|
||||
duration = str(seconds_part)
|
||||
return f"{hours:.3f} ч ({duration})"
|
||||
|
||||
def today_start() -> datetime:
|
||||
td = datetime.today()
|
||||
@@ -19,16 +38,133 @@ def today_end() -> datetime:
|
||||
year = td.year
|
||||
month = td.month
|
||||
day = td.day
|
||||
return datetime(year=year, month=month, day=day, hour=23)
|
||||
return datetime(year=year, month=month, day=day, hour=23, minute=59, second=59)
|
||||
|
||||
|
||||
def str_to_date(date: str) -> Optional[datetime]:
|
||||
def str_to_date(date: str) -> datetime | None:
|
||||
if date:
|
||||
return datetime.strptime(date + "T00:00:00Z", config.DATETIME_FORMAT)
|
||||
|
||||
def format_date(date: datetime) -> datetime:
|
||||
def format_date(date: datetime) -> str | None:
|
||||
if date:
|
||||
return date.strftime(config.DATETIME_FORMAT)
|
||||
|
||||
def parse_task_id(tag: str) -> int:
|
||||
return int(re.search(r"^(?P<task_id>\d{5})", tag).group("task_id"))
|
||||
if match := re.search(r"^(?P<task_id>\d{5})", tag):
|
||||
return int(match.group("task_id"))
|
||||
raise ValueError(f'Тег "{tag}" должен начинаться с номера задачи из 5 цифр')
|
||||
|
||||
def db_update(clock, db, red):
|
||||
"""Обновляет локальную БД данными из Clockify и Redmine."""
|
||||
logger.info(Fore.YELLOW + "Обновление базы данных..." + Fore.RESET)
|
||||
|
||||
try:
|
||||
# Обновление Тегов
|
||||
logger.info(f"{Fore.YELLOW}Обновление Тегов...\n{Fore.RESET}")
|
||||
db_tags = db.get_tags() or []
|
||||
new_tags = []
|
||||
clock_tags = clock.get_tags(is_updating=True, **{"page-size": clock.tags_count})
|
||||
if clock_tags:
|
||||
new_tags = [tag for tag in clock_tags if tag not in db_tags]
|
||||
|
||||
if new_tags:
|
||||
try:
|
||||
db.insert_tags(new_tags)
|
||||
logger.info(f"{Fore.GREEN}Обновлено тегов: {len(new_tags)}{Fore.RESET}")
|
||||
except Exception as e:
|
||||
logger.error(f"{Fore.RED}Ошибка при сохранении тегов!{Fore.RESET}")
|
||||
else:
|
||||
logger.info(f"{Fore.YELLOW}Обновление тегов не требуется.\n{Fore.RESET}")
|
||||
|
||||
|
||||
# Обновление Задач
|
||||
logger.info(f"{Fore.YELLOW}Обновление Задач...\n{Fore.RESET}")
|
||||
clock_task_ids = {
|
||||
parse_task_id(tag.title)
|
||||
for tag in [*db_tags, *new_tags]
|
||||
}
|
||||
red_tasks = red.get_issues(clock_task_ids)
|
||||
if red_tasks:
|
||||
db_tasks = db.get_tasks() or []
|
||||
new_tasks = [task for task in red_tasks if task not in db_tasks]
|
||||
|
||||
if new_tasks:
|
||||
try:
|
||||
db.insert_tasks(new_tasks)
|
||||
logger.info(f"{Fore.GREEN}Обновлено задач: {len(new_tasks)}{Fore.RESET}")
|
||||
except Exception as e:
|
||||
logger.error(f"{Fore.RED}Ошибка при сохранении задач!{Fore.RESET}")
|
||||
else:
|
||||
logger.info(f"{Fore.YELLOW}Обновление задач не требуется\n{Fore.RESET}")
|
||||
|
||||
# Обновление Проектов Ветро
|
||||
logger.info(f"{Fore.YELLOW}Обновление Проектов Ветро{Fore.RESET}")
|
||||
clock_projects = clock.get_vetro_projects()
|
||||
if clock_projects:
|
||||
db_projects = db.get_vetro_projects()
|
||||
new_projects = [project for project in clock_projects if project not in db_projects]
|
||||
|
||||
if new_projects:
|
||||
try:
|
||||
db.insert_vetro_projects(new_projects)
|
||||
logger.info(f"{Fore.GREEN}Обновлено Проектов Ветро: {len(new_projects)}{Fore.RESET}")
|
||||
except Exception as e:
|
||||
logger.error(f"{Fore.RED}Ошибка при сохранении Проектов Ветро!{Fore.RESET}")
|
||||
else:
|
||||
logger.info(f"{Fore.YELLOW}Обновление Проектов Ветро не требуется\n{Fore.RESET}")
|
||||
|
||||
# Обновление Типов деятельности
|
||||
logger.info(f"{Fore.YELLOW}Обновление Типов деятельности{Fore.RESET}")
|
||||
red_types = red.get_time_entry_activities()
|
||||
if red_types:
|
||||
db_types = db.get_activity_types()
|
||||
new_types = [type_ for type_ in red_types if type_ not in db_types]
|
||||
|
||||
if new_types:
|
||||
try:
|
||||
db.insert_activity_types(new_types)
|
||||
logger.info(f"{Fore.GREEN}Обновлено Типов деятельности: {len(new_types)}{Fore.RESET}")
|
||||
except Exception as e:
|
||||
logger.error(f"{Fore.RED}Ошибка при сохранении Типов деятельности!{Fore.RESET}")
|
||||
else:
|
||||
logger.info(f"{Fore.YELLOW}Обновление Типов деятельности не требуется\n{Fore.RESET}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(Fore.RED + f"Ошибка при обновлении базы данных: {e}" + Fore.RESET)
|
||||
else:
|
||||
logger.info(Fore.GREEN + "База данных теперь актуальна." + Fore.RESET)
|
||||
|
||||
def get_start_end_dates(args: Namespace):
|
||||
start, end = None, None
|
||||
if args.start:
|
||||
start = str_to_date(args.start)
|
||||
if args.end:
|
||||
end = str_to_date(args.end)
|
||||
if args.start is None and args.end is None:
|
||||
logger.info("Парсим задачи за сегодняшний день")
|
||||
start = today_start()
|
||||
end = today_end()
|
||||
else:
|
||||
if start:
|
||||
from_ = f"{start.day} {config.MONTHS.get(start.month)} {start.year} г."
|
||||
|
||||
if end:
|
||||
to_ = f"{end.day} {config.MONTHS.get(end.month)} {end.year} г."
|
||||
|
||||
if start == end:
|
||||
logger.info(f"{Fore.YELLOW}Парсим задачи за {from_}{Fore.RESET}")
|
||||
else:
|
||||
logger.info(Fore.YELLOW + "Парсим задачи" + (f" с {from_} " if start else " ") + (f"до {to_}" if end else "") + Fore.RESET)
|
||||
|
||||
return start, end
|
||||
|
||||
def check_envs(clock):
|
||||
"""Проверяет наличие обязательных переменных окружения Clockify."""
|
||||
if not config.WORKSPACE_ID or not config.USER_ID:
|
||||
if not os.path.exists(config.ENV_TXT):
|
||||
logger.info(f"{Fore.YELLOW}Необходимые переменные не были обнаружены. Попытка получения...{Fore.RESET}")
|
||||
clock.getenvs()
|
||||
else:
|
||||
logger.info(f'{Fore.YELLOW}Проверьте файл "{config.ENV_TXT}". Если необходимых переменных в нём нет, то просто удалите его.{Fore.RESET}')
|
||||
return False
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user