Compare commits
46 Commits
f627f318a6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b1df6220a5 | |||
| bac0fcf216 | |||
| 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 | |||
| 572c123432 | |||
| 8a658a4cf3 | |||
| bcfadfdfb9 | |||
| 9a00a4cf24 |
36
README.MD
36
README.MD
@@ -1,4 +1,4 @@
|
|||||||
# [Redmine Tracker](https://git.sbps.ru/e.vlasov/redmine_tracker)
|
# Redmine Tracker
|
||||||
|
|
||||||
## Описание:
|
## Описание:
|
||||||
|
|
||||||
@@ -21,10 +21,8 @@
|
|||||||

|

|
||||||
|
|
||||||
### Запуск:
|
### Запуск:
|
||||||
Клонируйте [репозиторий](https://git.sbps.ru/e.vlasov/redmine_tracker) и перейдите в него в командной строке:
|
Склонируйте репозиторий командой из раздела «Code»/«Клонировать» на странице проекта, затем перейдите в его каталог:
|
||||||
```bash
|
```bash
|
||||||
git clone ssh://git@git.sbps.ru:4567/e.vlasov/redmine_tracker.git
|
|
||||||
|
|
||||||
cd redmine_tracker
|
cd redmine_tracker
|
||||||
```
|
```
|
||||||
Создайте виртуальное окружение и активируйте его
|
Создайте виртуальное окружение и активируйте его
|
||||||
@@ -51,6 +49,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 +84,27 @@ 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]` | Выполняет миграции и обновляет справочники без отправки трудочасов в Redmine | `redmine-tracker --init-db` |
|
||||||
|
|
||||||
[Власов Эдуард](https://github.com/QuickLike)
|
При запуске с `--init-db` файлы из каталога `migrations` выполняются по имени.
|
||||||
|
Успешно применённые миграции повторно не запускаются: их имя и время выполнения
|
||||||
|
сохраняются в таблице `migration`.
|
||||||
|
|
||||||
|
Также можно запускать напрямую из корня проекта:
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
[Власов Эдуард](https://github.com/QuickLike)
|
||||||
|
|||||||
188
api/clockify.py
188
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)}")
|
||||||
@@ -41,8 +52,9 @@ class ClockifyAPI(ClockifyConfig):
|
|||||||
|
|
||||||
tags = self.get_tags(is_updating=is_updating)
|
tags = self.get_tags(is_updating=is_updating)
|
||||||
if not tags:
|
if not tags:
|
||||||
logging.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,28 +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)
|
tag = self.get_tags(True, tag_id=tag_id, **{"page-size": self.tags_count})
|
||||||
if tag:
|
if isinstance(tag, Tag):
|
||||||
db.insert_tags([tag])
|
self.db.insert_tags([tag])
|
||||||
from config import red
|
tags_by_id[tag.id] = tag
|
||||||
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(e)
|
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
|
||||||
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(
|
||||||
@@ -89,87 +107,137 @@ 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=activity["billable"],
|
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]:
|
def get_tags(self, is_updating=False, tag_id="", **kwargs) -> list[Tag] | Tag | None:
|
||||||
"""
|
"""
|
||||||
Получает с сервера Clockify теги
|
Получает с сервера Clockify теги
|
||||||
name: str - optional
|
name: str - optional
|
||||||
archived: bool - optional
|
archived: bool - optional
|
||||||
page-size: int - optional
|
page-size: int - optional
|
||||||
|
page: int - optional
|
||||||
"""
|
"""
|
||||||
if is_updating:
|
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()
|
page = 1
|
||||||
if tag_id:
|
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:
|
for tag in tags:
|
||||||
if tag_id == tag["id"]:
|
clockify_tag = Tag(
|
||||||
return Tag(
|
id=tag["id"],
|
||||||
id=tag["id"],
|
title=tag["name"]
|
||||||
title=tag["name"]
|
)
|
||||||
)
|
if tag_id and tag_id == clockify_tag.id:
|
||||||
logger.debug(f"{Fore.YELLOW}Получено тегов: {len(tags)}.{Fore.RESET}")
|
return clockify_tag
|
||||||
return [Tag(
|
if not tag_id:
|
||||||
id=tag["id"],
|
result.append(clockify_tag)
|
||||||
title=tag["name"]
|
|
||||||
) for tag in tags]
|
if len(tags) < page_size:
|
||||||
return db.get_tags()
|
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]:
|
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"]
|
||||||
) for vetro_project in vetro_projects]
|
) for vetro_project in vetro_projects]
|
||||||
|
|
||||||
|
# DEPRECATED
|
||||||
def mark_tracked(self, activities: list[Activity]):
|
def mark_tracked(self, activities: list[Activity]):
|
||||||
"""
|
"""
|
||||||
Помечает активности затреканными (Billable) на сервере Clockify
|
Помечает активности затреканными (Billable) на сервере Clockify
|
||||||
"""
|
"""
|
||||||
for activity in activities:
|
for activity in activities:
|
||||||
try:
|
try:
|
||||||
resp = requests.put(
|
if not config.is_debug:
|
||||||
self.base_url + self.update_time_entry(
|
resp = requests.put(
|
||||||
activity_id=activity.id
|
self.base_url + self.update_time_entry(
|
||||||
), json={
|
activity_id=activity.id
|
||||||
"billable": True,
|
), json={
|
||||||
"start": activity.date_start.strftime(config.DATETIME_FORMAT),
|
"billable": True,
|
||||||
"end": activity.date_end.strftime(config.DATETIME_FORMAT) if activity.date_end else None,
|
"start": activity.date_start.strftime(config.DATETIME_FORMAT),
|
||||||
"description": activity.description,
|
"end": activity.date_end.strftime(config.DATETIME_FORMAT) if activity.date_end else None,
|
||||||
"tagIds": [activity.tag_id],
|
"description": activity.description,
|
||||||
"taskId": activity.vetro_project_id,
|
"tagIds": [activity.tag_id],
|
||||||
"projectId": activity.project_id
|
"taskId": activity.vetro_project_id,
|
||||||
}, headers={
|
"projectId": activity.project_id
|
||||||
"X-API-KEY": self.token
|
}, headers={
|
||||||
}
|
"X-API-KEY": self.token
|
||||||
)
|
},
|
||||||
if resp.ok:
|
timeout=REQUEST_TIMEOUT,
|
||||||
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:
|
else:
|
||||||
logger.debug(resp.json())
|
logger.debug(resp.text)
|
||||||
logger.error(f"{Fore.RED} {activity} не помечена как затреканная{Fore.RESET}")
|
logger.error(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "❌")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return
|
return
|
||||||
|
|
||||||
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"]})
|
||||||
@@ -177,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 {
|
||||||
|
|||||||
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
|
||||||
181
api/redmine.py
181
api/redmine.py
@@ -2,21 +2,21 @@ import logging
|
|||||||
|
|
||||||
from colorama import Fore
|
from colorama import Fore
|
||||||
import requests
|
import requests
|
||||||
from tqdm import tqdm
|
|
||||||
|
|
||||||
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,
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -27,61 +27,105 @@ class RedmineAPI(RedmineConfig):
|
|||||||
self.token = token
|
self.token = token
|
||||||
self._check_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"""
|
"""Заносит трудочасы на сервер Redmine"""
|
||||||
is_beginner = config.is_beginner
|
is_beginner = config.is_beginner
|
||||||
if is_beginner:
|
if is_beginner:
|
||||||
logging.info(f"{Fore.YELLOW}Делим трудочасы пополам{Fore.RESET}")
|
logger.info(f"{Fore.YELLOW}Делим трудочасы пополам{Fore.RESET}")
|
||||||
tracked = []
|
tracked = []
|
||||||
for activity in tqdm(activities, desc="Занесение трудочасов"):
|
failed_activities = []
|
||||||
if not(not activity.is_tracked and activity.date_end):
|
activities_to_track = [
|
||||||
continue
|
activity
|
||||||
|
for activity in activities
|
||||||
|
if not activity.is_tracked and activity.date_end
|
||||||
|
]
|
||||||
|
for activity in activities_to_track:
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
if not config.is_debug:
|
||||||
self.base_url + self.time_entries_url,
|
resp = requests.post(
|
||||||
headers={"X-Redmine-Api-Key": self.token},
|
self.base_url + self.time_entries_url,
|
||||||
json={
|
headers={"X-Redmine-Api-Key": self.token},
|
||||||
"time_entry": {
|
json={
|
||||||
"issue_id": activity.task_id,
|
"time_entry": {
|
||||||
"hours": activity.time_spent / (2 if is_beginner else 1),
|
"issue_id": activity.task_id,
|
||||||
"activity_id": ActivityTypes.DEVELOPMENT,
|
"hours": round(activity.time_spent / (2 if is_beginner else 1), 3),
|
||||||
"comments": activity.description,
|
"activity_id": self.get_activity_type_id(activity),
|
||||||
"spent_on": activity.date_start.strftime(self.datetime_format)
|
"comments": activity.description,
|
||||||
}
|
"spent_on": activity.date_start.strftime(self.datetime_format)
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
if resp.ok:
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
)
|
||||||
|
if config.is_debug or resp.ok:
|
||||||
activity.is_tracked = True
|
activity.is_tracked = True
|
||||||
|
if on_success:
|
||||||
|
on_success(activity, resp.json()["time_entry"]["id"])
|
||||||
tracked.append(activity)
|
tracked.append(activity)
|
||||||
logger.debug(activity)
|
logger.info(
|
||||||
|
f"{Fore.GREEN}{self.format_activity_result(activity, is_beginner)}"
|
||||||
|
f"{Fore.RESET}".ljust(100) + "✅"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(Fore.RED + f"{activity} Не была затрекана" + Fore.RED)
|
try:
|
||||||
logger.error(f"{resp.json()['errors']} {resp.status_code}")
|
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:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
failed_activities.append(activity)
|
||||||
continue
|
continue
|
||||||
if len(activities) == len(tracked):
|
if len(activities_to_track) == len(tracked):
|
||||||
logger.info(Fore.GREEN + "Все задачи были успешно занесены в Redmine" + Fore.RESET)
|
logger.info(Fore.GREEN + "Все задачи были успешно занесены в Redmine" + Fore.RESET)
|
||||||
else:
|
else:
|
||||||
logger.info(f"{Fore.YELLOW}Не все задачи были занесены в Redmine. Занесено {len(tracked)}{Fore.RESET}")
|
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
|
return tracked
|
||||||
|
|
||||||
|
def format_activity_result(self, activity: Activity, is_beginner: bool = False) -> str:
|
||||||
|
"""Форматирует строку результата переноса активности."""
|
||||||
|
hours = round(activity.time_spent / (2 if is_beginner else 1), 3)
|
||||||
|
return f"#{activity.task_id} | {format_hours(hours)} | {activity.description}"
|
||||||
|
|
||||||
|
def get_activity_type_id(self, activity: Activity) -> int:
|
||||||
|
"""Возвращает тип деятельности Redmine для активности Clockify."""
|
||||||
|
return ACTIVITY_TYPE_BY_VETRO_PROJECT.get(
|
||||||
|
activity.vetro_project_id,
|
||||||
|
DEFAULT_ACTIVITY_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
def get_time_entry_activities(self) -> list[ActivityTypes]:
|
def get_time_entry_activities(self) -> list[ActivityType]:
|
||||||
"""Получает виды Деятельности с сервера Redmine"""
|
"""Получает виды Деятельности с сервера Redmine"""
|
||||||
resp = requests.get(self.base_url + self.time_entry_activities, headers={"X-Redmine-API-KEY": self.token})
|
resp = requests.get(
|
||||||
logging.debug(resp.json())
|
self.base_url + self.time_entry_activities,
|
||||||
if resp.ok:
|
headers={"X-Redmine-API-KEY": self.token},
|
||||||
return [
|
timeout=REQUEST_TIMEOUT,
|
||||||
ActivityType(id=act.get("id"), title=act.get("name"))
|
)
|
||||||
for act in resp.json().get("time_entry_activities")
|
if not resp.ok:
|
||||||
if act.get("active")
|
logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}")
|
||||||
]
|
return []
|
||||||
|
data = resp.json()
|
||||||
|
logger.debug(data)
|
||||||
|
return [
|
||||||
|
ActivityType(id=act.get("id"), title=act.get("name"))
|
||||||
|
for act in data.get("time_entry_activities", [])
|
||||||
|
if act.get("active")
|
||||||
|
]
|
||||||
|
|
||||||
def get_user(self):
|
def get_user(self):
|
||||||
"""Проверяет токен и возвращает данные юзера"""
|
"""Проверяет токен и возвращает данные юзера"""
|
||||||
resp = requests.get(self.base_url + self.my_account_url, headers={"X-Redmine-Api-Key": self.token})
|
resp = requests.get(
|
||||||
|
self.base_url + self.my_account_url,
|
||||||
|
headers={"X-Redmine-Api-Key": self.token},
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
logger.error("Invalid Token for RedmineAPI")
|
logger.error(f"Invalid Token for RedmineAPI: {resp.text}")
|
||||||
raise InvalidToken
|
raise InvalidToken
|
||||||
json = resp.json()["user"]
|
json = resp.json()["user"]
|
||||||
return {
|
return {
|
||||||
@@ -92,16 +136,24 @@ 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(
|
try:
|
||||||
self.base_url + self.issue.format(**{"id": issue_id}),
|
resp = requests.get(
|
||||||
headers={"X-Redmine-Api-Key": self.token},
|
self.base_url + self.issue.format(**{"id": issue_id}),
|
||||||
timeout=None
|
headers={"X-Redmine-Api-Key": self.token},
|
||||||
)
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
)
|
||||||
|
except requests.RequestException as error:
|
||||||
|
logger.error(
|
||||||
|
"Не удалось загрузить задачу #%s из Redmine: %s",
|
||||||
|
issue_id,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return None
|
||||||
if not resp.ok:
|
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"],
|
||||||
@@ -114,31 +166,14 @@ class RedmineAPI(RedmineConfig):
|
|||||||
"project": issue["project"]["name"]
|
"project": issue["project"]["name"]
|
||||||
})
|
})
|
||||||
|
|
||||||
def get_issues(self) -> list[Task]:
|
def get_issues(self, issue_ids: set[int]) -> list[Task]:
|
||||||
"""Получает задачи с сервера Redmine"""
|
"""Получает с сервера Redmine только задачи с указанными номерами."""
|
||||||
issues = []
|
return [
|
||||||
for i in range(25):
|
task
|
||||||
resp = requests.get(
|
for issue_id in sorted(issue_ids)
|
||||||
self.base_url + self.issues,
|
if (task := self.get_issue(issue_id))
|
||||||
headers={"X-Redmine-Api-Key": self.token},
|
]
|
||||||
params={"limit": 100, "offset": 100 * i, "status_id": "*"},
|
|
||||||
timeout=None
|
|
||||||
)
|
|
||||||
if not resp.ok or not resp.json():
|
|
||||||
logger.error("Ошибка RedmineAPI")
|
|
||||||
return {}
|
|
||||||
issues.extend(resp.json()["issues"])
|
|
||||||
return [Task(**{
|
|
||||||
"id": issue["id"],
|
|
||||||
"subject": issue["subject"],
|
|
||||||
"description": issue["description"],
|
|
||||||
"status": issue["status"]["name"],
|
|
||||||
"priority": issue["priority"]["name"],
|
|
||||||
"author": issue["author"]["name"],
|
|
||||||
"tracker": issue["tracker"]["name"],
|
|
||||||
"project": issue["project"]["name"]
|
|
||||||
}) for issue in issues]
|
|
||||||
|
|
||||||
def _check_token(self):
|
def _check_token(self):
|
||||||
user = self.get_user()
|
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
|
@dataclass
|
||||||
class Activity:
|
class Activity:
|
||||||
|
"""Описывает одну активность, полученную из Clockify."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
author_id: int
|
author_id: int
|
||||||
vetro_project_id: VetroProjects
|
vetro_project_id: VetroProjects
|
||||||
@@ -19,7 +21,14 @@ class Activity:
|
|||||||
is_tracked: bool = False
|
is_tracked: bool = False
|
||||||
|
|
||||||
def __str__(self):
|
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
|
@dataclass
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -17,11 +15,14 @@ class Config(BaseSettings):
|
|||||||
USER_ID: str = None
|
USER_ID: str = None
|
||||||
WORKSPACE_ID: str = None
|
WORKSPACE_ID: str = None
|
||||||
|
|
||||||
|
MODE: str = "prod"
|
||||||
|
|
||||||
TRIAL_END_DATE: str = datetime.today().strftime("%Y-%m-%d")
|
TRIAL_END_DATE: str = datetime.today().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
ENV_TXT: str = ".env.txt"
|
ENV_TXT: str = ".env.txt"
|
||||||
DB_NAME: str = "db/RedmineTracker.db"
|
DB_NAME: str = "db/RedmineTracker.db"
|
||||||
SQL_PATH: str = "config/RedmineTracker.sql"
|
SQL_PATH: str = "config/RedmineTracker.sql"
|
||||||
|
MIGRATIONS_PATH: str = "migrations"
|
||||||
|
|
||||||
DATETIME_FORMAT: str = "%Y-%m-%dT%H:%M:%SZ"
|
DATETIME_FORMAT: str = "%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
|
||||||
@@ -44,6 +45,10 @@ class Config(BaseSettings):
|
|||||||
def is_beginner(self):
|
def is_beginner(self):
|
||||||
from utils import str_to_date
|
from utils import str_to_date
|
||||||
return datetime.today() < str_to_date(self.TRIAL_END_DATE)
|
return datetime.today() < str_to_date(self.TRIAL_END_DATE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_debug(self):
|
||||||
|
return self.MODE == "debug"
|
||||||
|
|
||||||
|
|
||||||
def configure_argument_parser():
|
def configure_argument_parser():
|
||||||
@@ -61,6 +66,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(
|
||||||
@@ -69,24 +75,21 @@ 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',
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help='Режим инициализации бд.\nПодгружает с серверов теги, виды деятельности и проекты Ветро'
|
help=(
|
||||||
|
'Режим инициализации БД. Выполняет миграции и обновляет справочники '
|
||||||
|
'без отправки трудочасов в Redmine'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return parser
|
return 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)
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ClockifyConfig:
|
|||||||
self.user_url = "/user"
|
self.user_url = "/user"
|
||||||
self.projects_url = f"{self.workspace_url}/projects"
|
self.projects_url = f"{self.workspace_url}/projects"
|
||||||
|
|
||||||
self.tags_count = 100
|
self.tags_count = 20
|
||||||
|
|
||||||
def update_time_entry(self, activity_id):
|
def update_time_entry(self, activity_id):
|
||||||
return f"{self.workspace_url}/time-entries/{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:
|
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",
|
||||||
|
}
|
||||||
|
)
|
||||||
301
db/db.py
301
db/db.py
@@ -1,10 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
import sqlite3 as sq
|
import sqlite3 as sq
|
||||||
|
|
||||||
from colorama import Fore
|
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__)
|
||||||
@@ -30,10 +33,44 @@ class Database:
|
|||||||
logger.error("Ошибка при инициализации БД: ", e)
|
logger.error("Ошибка при инициализации БД: ", e)
|
||||||
self.con.rollback()
|
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]):
|
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 +84,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,97 +110,102 @@ class Database:
|
|||||||
act.author_id,
|
act.author_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except sq.IntegrityError:
|
except sq.Error as e:
|
||||||
self.cursor.execute(
|
self.con.rollback()
|
||||||
"""UPDATE activity
|
logger.error(f"Не удалось сохранить активности: {e}")
|
||||||
SET description = ?, time_spent = ?, date_start = ?, date_end = ?, is_tracked = ?, vetro_project_id = ?, tag_id = ?, task_id = ?, project_id = ?
|
else:
|
||||||
WHERE id = ?""",
|
self.con.commit()
|
||||||
(
|
|
||||||
act.description,
|
|
||||||
act.time_spent,
|
|
||||||
act.date_start,
|
|
||||||
act.date_end,
|
|
||||||
act.is_tracked,
|
|
||||||
act.vetro_project_id,
|
|
||||||
act.tag_id,
|
|
||||||
act.task_id,
|
|
||||||
act.project_id,
|
|
||||||
act.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self.con.commit()
|
|
||||||
|
|
||||||
def insert_time_entries(self, activities: list[Activity]):
|
def insert_time_entry(
|
||||||
"""Сохраняет в базу данных трудочасы Redmine"""
|
self,
|
||||||
for act in activities:
|
activity: Activity,
|
||||||
try:
|
source_activities: list[Activity],
|
||||||
self.cursor.execute(
|
redmine_time_entry_id: int,
|
||||||
"""INSERT INTO time_entry
|
):
|
||||||
(date, description, activity_id, time_spent, task_id, activity_type_id, author_id)
|
"""Сохраняет запись Redmine и связывает с ней активности Clockify."""
|
||||||
VALUES(?,?,?,?,?,?,?)""",
|
try:
|
||||||
(
|
self.cursor.execute(
|
||||||
act.date_start.date(),
|
"""INSERT INTO time_entry
|
||||||
act.description,
|
(id, date, description, time_spent, task_id, activity_type_id, author_id)
|
||||||
act.id,
|
VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||||
act.time_spent,
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
act.task_id,
|
date = excluded.date,
|
||||||
ActivityTypes.DEVELOPMENT,
|
description = excluded.description,
|
||||||
act.author_id
|
time_spent = excluded.time_spent,
|
||||||
)
|
task_id = excluded.task_id,
|
||||||
|
activity_type_id = excluded.activity_type_id,
|
||||||
|
author_id = excluded.author_id""",
|
||||||
|
(
|
||||||
|
redmine_time_entry_id,
|
||||||
|
activity.date_start.date(),
|
||||||
|
activity.description,
|
||||||
|
activity.time_spent,
|
||||||
|
activity.task_id,
|
||||||
|
ActivityTypes.DEVELOPMENT,
|
||||||
|
activity.author_id,
|
||||||
)
|
)
|
||||||
self.con.commit()
|
)
|
||||||
except sq.IntegrityError:
|
self.cursor.executemany(
|
||||||
pass
|
"UPDATE activity SET time_entry_id = ? WHERE id = ?",
|
||||||
|
[
|
||||||
|
(redmine_time_entry_id, source_activity.id)
|
||||||
|
for source_activity in source_activities
|
||||||
|
],
|
||||||
|
)
|
||||||
|
except sq.IntegrityError as e:
|
||||||
|
self.con.rollback()
|
||||||
|
logger.error(f"Не удалось сохранить трудочасы: {e}")
|
||||||
|
else:
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
|
||||||
def insert_tags(self, tags: list[Tag]):
|
def insert_tags(self, tags: list[Tag]):
|
||||||
"""Сохраняет в базу данных теги Clockify"""
|
"""Сохраняет в базу данных теги Clockify"""
|
||||||
for tag in tags:
|
try:
|
||||||
try:
|
for tag in tags:
|
||||||
self.cursor.execute("INSERT INTO tag VALUES(?, ?)",(tag.id,tag.title))
|
|
||||||
except sq.IntegrityError:
|
|
||||||
self.cursor.execute(
|
self.cursor.execute(
|
||||||
"""UPDATE tag
|
"""INSERT INTO tag VALUES(?, ?)
|
||||||
SET title = ?
|
ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
|
||||||
WHERE id = ?""",
|
(tag.id, tag.title)
|
||||||
(tag.title, tag.id)
|
|
||||||
)
|
)
|
||||||
finally:
|
except sq.Error as e:
|
||||||
self.con.commit()
|
self.con.rollback()
|
||||||
|
logger.error(f"Не удалось сохранить теги: {e}")
|
||||||
|
else:
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
|
|
||||||
def insert_vetro_projects(self, vetro_projects: list[VetroProject]):
|
def insert_vetro_projects(self, vetro_projects: list[VetroProject]):
|
||||||
"""Сохраняет в базу данных Проекты (ДО) Ветро Clockify"""
|
"""Сохраняет в базу данных Проекты (ДО) Ветро Clockify"""
|
||||||
if not vetro_projects:
|
if not vetro_projects:
|
||||||
return
|
return
|
||||||
for vetro_project in vetro_projects:
|
try:
|
||||||
try:
|
for vetro_project in vetro_projects:
|
||||||
self.cursor.execute("INSERT INTO vetro_project VALUES(?, ?)", (vetro_project.id, vetro_project.title))
|
self.cursor.execute(
|
||||||
except sq.IntegrityError:
|
"""INSERT INTO vetro_project VALUES(?, ?)
|
||||||
self.cursor.execute("""
|
ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
|
||||||
UPDATE vetro_project
|
(vetro_project.id, vetro_project.title)
|
||||||
SET title = ?
|
|
||||||
WHERE id = ?""",
|
|
||||||
(vetro_project.title, vetro_project.id)
|
|
||||||
)
|
)
|
||||||
finally:
|
except sq.Error as e:
|
||||||
self.con.commit()
|
self.con.rollback()
|
||||||
|
logger.error(f"Не удалось сохранить проекты Ветро: {e}")
|
||||||
|
else:
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
def insert_activity_types(self, activity_types: list[ActivityType]):
|
def insert_activity_types(self, activity_types: list[ActivityType]):
|
||||||
"""Сохраняет в базу данных Деятельности Redmine"""
|
"""Сохраняет в базу данных Деятельности Redmine"""
|
||||||
for activity_type in activity_types:
|
try:
|
||||||
try:
|
for activity_type in activity_types:
|
||||||
self.cursor.execute("INSERT INTO activity_type VALUES(?, ?)", (activity_type.id, activity_type.title))
|
self.cursor.execute(
|
||||||
except sq.IntegrityError:
|
"""INSERT INTO activity_type VALUES(?, ?)
|
||||||
self.cursor.execute("""
|
ON CONFLICT(id) DO UPDATE SET title = excluded.title""",
|
||||||
UPDATE activity_type
|
(activity_type.id, activity_type.title)
|
||||||
SET title = ?
|
|
||||||
WHERE id = ?""",
|
|
||||||
(activity_type.title, activity_type.id)
|
|
||||||
)
|
)
|
||||||
finally:
|
except sq.Error as e:
|
||||||
self.con.commit()
|
self.con.rollback()
|
||||||
|
logger.error(f"Не удалось сохранить типы деятельности: {e}")
|
||||||
|
else:
|
||||||
|
self.con.commit()
|
||||||
|
|
||||||
def insert_user(self, **kwargs):
|
def insert_user(self, **kwargs):
|
||||||
"""Сохраняет в базу данных пользователя"""
|
"""Сохраняет в базу данных пользователя"""
|
||||||
@@ -160,35 +213,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 +253,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 +272,21 @@ class Database:
|
|||||||
task.tracker,
|
task.tracker,
|
||||||
task.project,
|
task.project,
|
||||||
])
|
])
|
||||||
except sq.IntegrityError:
|
except sq.Error as e:
|
||||||
self.cursor.execute("""
|
self.con.rollback()
|
||||||
UPDATE task
|
logger.error(f"Не удалось сохранить задачи: {e}")
|
||||||
SET "subject" = ?,
|
else:
|
||||||
"description" = ?,
|
self.con.commit()
|
||||||
"status" = ?,
|
|
||||||
"priority" = ?,
|
|
||||||
"author" = ?,
|
|
||||||
"tracker" = ?,
|
|
||||||
"project" = ?
|
|
||||||
WHERE "id" = ?""",
|
|
||||||
(
|
|
||||||
task.subject,
|
|
||||||
task.description,
|
|
||||||
task.status,
|
|
||||||
task.priority,
|
|
||||||
task.author,
|
|
||||||
task.tracker,
|
|
||||||
task.project,
|
|
||||||
task.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self.con.commit()
|
|
||||||
|
|
||||||
def get_user_by(self, by_field: str, value: str):
|
def get_user_by(self, by_field: str, value: str):
|
||||||
"""Получает пользователя из бд"""
|
"""Получает пользователя из бд"""
|
||||||
|
if by_field not in USER_LOOKUP_FIELDS:
|
||||||
|
raise ValueError(f"Недопустимое поле поиска пользователя: {by_field}")
|
||||||
try:
|
try:
|
||||||
user = self.cursor.execute(f"SELECT * FROM user WHERE {by_field} = ?", (value,)).fetchone()
|
user = self.cursor.execute(f"SELECT * FROM user WHERE {by_field} = ?", (value,)).fetchone()
|
||||||
|
if not user:
|
||||||
|
logger.error(f"Пользователь не найден: {by_field}={value}")
|
||||||
|
return None
|
||||||
return {
|
return {
|
||||||
"id": user[0],
|
"id": user[0],
|
||||||
"first_name": user[1],
|
"first_name": user[1],
|
||||||
@@ -252,7 +299,7 @@ class Database:
|
|||||||
"redmine_username": user[8]
|
"redmine_username": user[8]
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logger.error(e)
|
||||||
|
|
||||||
|
|
||||||
def get_tags(self) -> list[Tag]:
|
def get_tags(self) -> list[Tag]:
|
||||||
@@ -266,7 +313,7 @@ class Database:
|
|||||||
for tag in self.cursor.execute("SELECT * FROM tag").fetchall()
|
for tag in self.cursor.execute("SELECT * FROM tag").fetchall()
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logger.error(e)
|
||||||
|
|
||||||
def get_tasks(self) -> list[Task]:
|
def get_tasks(self) -> list[Task]:
|
||||||
"""Получает задачи из бд"""
|
"""Получает задачи из бд"""
|
||||||
@@ -284,7 +331,7 @@ class Database:
|
|||||||
}) for task in self.cursor.execute("SELECT * FROM task").fetchall()
|
}) for task in self.cursor.execute("SELECT * FROM task").fetchall()
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logger.error(e)
|
||||||
|
|
||||||
def get_vetro_projects(self) -> list[VetroProject]:
|
def get_vetro_projects(self) -> list[VetroProject]:
|
||||||
"""Получает Проекты Ветро из бд"""
|
"""Получает Проекты Ветро из бд"""
|
||||||
@@ -296,7 +343,7 @@ class Database:
|
|||||||
}) for vetro_project in self.cursor.execute("SELECT * FROM vetro_project").fetchall()
|
}) for vetro_project in self.cursor.execute("SELECT * FROM vetro_project").fetchall()
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logger.error(e)
|
||||||
|
|
||||||
def get_activity_types(self) -> list[ActivityType]:
|
def get_activity_types(self) -> list[ActivityType]:
|
||||||
"""Получает Виды деятельности из бд"""
|
"""Получает Виды деятельности из бд"""
|
||||||
@@ -308,4 +355,16 @@ class Database:
|
|||||||
}) for activity_type in self.cursor.execute("SELECT * FROM activity_type").fetchall()
|
}) for activity_type in self.cursor.execute("SELECT * FROM activity_type").fetchall()
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(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)
|
||||||
71
track.py
71
track.py
@@ -1,74 +1,7 @@
|
|||||||
import logging
|
"""Совместимая точка входа для старых запусков через track.py."""
|
||||||
import sys
|
|
||||||
|
|
||||||
logging.basicConfig(
|
from main import main
|
||||||
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 colorama import Fore # noqa: E402
|
|
||||||
|
|
||||||
from config import arg_parser, clock, 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 args.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)
|
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
129
utils.py
129
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,88 +38,96 @@ 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:
|
||||||
# Обновление Тегов
|
# Обновление Тегов
|
||||||
logger.info(f"{Fore.YELLOW}Обновление Тегов...\n{Fore.RESET}")
|
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})
|
clock_tags = clock.get_tags(is_updating=True, **{"page-size": clock.tags_count})
|
||||||
db_tags = db.get_tags()
|
if clock_tags:
|
||||||
new_tags = [tag for tag in clock_tags if tag not in db_tags]
|
new_tags = [tag for tag in clock_tags if tag not in db_tags]
|
||||||
|
|
||||||
if new_tags:
|
if new_tags:
|
||||||
try:
|
try:
|
||||||
db.insert_tags(new_tags)
|
db.insert_tags(new_tags)
|
||||||
logger.info(f"{Fore.GREEN}Обновлено тегов: {len(new_tags)}{Fore.RESET}")
|
logger.info(f"{Fore.GREEN}Обновлено тегов: {len(new_tags)}{Fore.RESET}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{Fore.RED}Ошибка при сохранении тегов!{Fore.RESET}")
|
logger.error(f"{Fore.RED}Ошибка при сохранении тегов!{Fore.RESET}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"{Fore.YELLOW}Обновление тегов не требуется.\n{Fore.RESET}")
|
logger.info(f"{Fore.YELLOW}Обновление тегов не требуется.\n{Fore.RESET}")
|
||||||
|
|
||||||
|
|
||||||
# Обновление Задач
|
# Обновление Задач
|
||||||
logger.info(f"{Fore.YELLOW}Обновление Задач...\n{Fore.RESET}")
|
logger.info(f"{Fore.YELLOW}Обновление Задач...\n{Fore.RESET}")
|
||||||
|
clock_task_ids = {
|
||||||
db_tasks = db.get_tasks()
|
parse_task_id(tag.title)
|
||||||
red_tasks = red.get_issues()
|
for tag in [*db_tags, *new_tags]
|
||||||
clock_tasks = [task for task in red_tasks if task.id in [parse_task_id(tag.title) for tag in [*db_tags, *new_tags]]]
|
}
|
||||||
new_tasks = [task for task in clock_tasks if task not in db_tasks]
|
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:
|
if new_tasks:
|
||||||
try:
|
try:
|
||||||
db.insert_tasks(new_tasks)
|
db.insert_tasks(new_tasks)
|
||||||
logger.info(f"{Fore.GREEN}Обновлено задач: {len(new_tasks)}{Fore.RESET}")
|
logger.info(f"{Fore.GREEN}Обновлено задач: {len(new_tasks)}{Fore.RESET}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{Fore.RED}Ошибка при сохранении задач!{Fore.RESET}")
|
logger.error(f"{Fore.RED}Ошибка при сохранении задач!{Fore.RESET}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"{Fore.YELLOW}Обновление задач не требуется\n{Fore.RESET}")
|
logger.info(f"{Fore.YELLOW}Обновление задач не требуется\n{Fore.RESET}")
|
||||||
|
|
||||||
# Обновление Проектов Ветро
|
# Обновление Проектов Ветро
|
||||||
logger.info(f"{Fore.YELLOW}Обновление Проектов Ветро{Fore.RESET}")
|
logger.info(f"{Fore.YELLOW}Обновление Проектов Ветро{Fore.RESET}")
|
||||||
clock_projects = clock.get_vetro_projects()
|
clock_projects = clock.get_vetro_projects()
|
||||||
db_projects = db.get_vetro_projects()
|
if clock_projects:
|
||||||
new_projects = [project for project in clock_projects if project not in db_projects]
|
db_projects = db.get_vetro_projects()
|
||||||
|
new_projects = [project for project in clock_projects if project not in db_projects]
|
||||||
|
|
||||||
if new_projects:
|
if new_projects:
|
||||||
try:
|
try:
|
||||||
db.insert_vetro_projects(new_projects)
|
db.insert_vetro_projects(new_projects)
|
||||||
logger.info(f"{Fore.GREEN}Обновлено Проектов Ветро: {len(new_projects)}{Fore.RESET}")
|
logger.info(f"{Fore.GREEN}Обновлено Проектов Ветро: {len(new_projects)}{Fore.RESET}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{Fore.RED}Ошибка при сохранении Проектов Ветро!{Fore.RESET}")
|
logger.error(f"{Fore.RED}Ошибка при сохранении Проектов Ветро!{Fore.RESET}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"{Fore.YELLOW}Обновление Проектов Ветро не требуется\n{Fore.RESET}")
|
logger.info(f"{Fore.YELLOW}Обновление Проектов Ветро не требуется\n{Fore.RESET}")
|
||||||
|
|
||||||
# Обновление Типов деятельности
|
# Обновление Типов деятельности
|
||||||
logger.info(f"{Fore.YELLOW}Обновление Типов деятельности{Fore.RESET}")
|
logger.info(f"{Fore.YELLOW}Обновление Типов деятельности{Fore.RESET}")
|
||||||
red_types = red.get_time_entry_activities()
|
red_types = red.get_time_entry_activities()
|
||||||
db_types = db.get_activity_types()
|
if red_types:
|
||||||
new_types = [type_ for type_ in red_types if type_ not in db_types]
|
db_types = db.get_activity_types()
|
||||||
|
new_types = [type_ for type_ in red_types if type_ not in db_types]
|
||||||
|
|
||||||
if new_types:
|
if new_types:
|
||||||
try:
|
try:
|
||||||
db.insert_activity_types(new_types)
|
db.insert_activity_types(new_types)
|
||||||
logger.info(f"{Fore.GREEN}Обновлено Типов деятельности: {len(new_types)}{Fore.RESET}")
|
logger.info(f"{Fore.GREEN}Обновлено Типов деятельности: {len(new_types)}{Fore.RESET}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{Fore.RED}Ошибка при сохранении Типов деятельности!{Fore.RESET}")
|
logger.error(f"{Fore.RED}Ошибка при сохранении Типов деятельности!{Fore.RESET}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"{Fore.YELLOW}Обновление Типов деятельности не требуется\n{Fore.RESET}")
|
logger.info(f"{Fore.YELLOW}Обновление Типов деятельности не требуется\n{Fore.RESET}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(Fore.RED + f"Ошибка при обновлении базы данных: {e}" + Fore.RESET)
|
logger.error(Fore.RED + f"Ошибка при обновлении базы данных: {e}" + Fore.RESET)
|
||||||
@@ -115,6 +135,7 @@ def db_update():
|
|||||||
logger.info(Fore.GREEN + "База данных теперь актуальна." + Fore.RESET)
|
logger.info(Fore.GREEN + "База данных теперь актуальна." + Fore.RESET)
|
||||||
|
|
||||||
def get_start_end_dates(args: Namespace):
|
def get_start_end_dates(args: Namespace):
|
||||||
|
start, end = None, None
|
||||||
if args.start:
|
if args.start:
|
||||||
start = str_to_date(args.start)
|
start = str_to_date(args.start)
|
||||||
if args.end:
|
if args.end:
|
||||||
@@ -137,8 +158,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