Compare commits

...

44 Commits

Author SHA1 Message Date
49286d0fcf добавил систему миграций и связь одной записи Redmine с несколькими активностями Clockify и запретил отправку трудочасов при инициализации базы данных 2026-07-14 15:54:26 +03:00
5dca3ae446 добавил объединение повторных активностей Clockify 2026-07-14 15:23:02 +03:00
9352a84723 убрал служебный id Clockify из комментариев Redmine 2026-07-06 16:56:15 +03:00
a4a4178880 исправил кликабельность номера задачи в цветном выводе 2026-07-06 16:47:05 +03:00
eca2f6aa51 обновил вывод успешного переноса активности 2026-07-06 16:45:11 +03:00
c35b479a0b добавил человекочитаемый формат длительности трудозатрат 2026-07-06 16:41:32 +03:00
4da65657d8 сделал номера задач кликабельными в консоли 2026-07-06 16:35:59 +03:00
c94a31a5c1 округлил вывод трудозатрат до трех знаков 2026-07-06 16:32:42 +03:00
9cf4cc75f0 сократил формат логов в консоли 2026-07-06 16:30:21 +03:00
ba9eafbb46 добавил предпросмотр и защиту от повторной отправки трудочасов 2026-07-06 16:28:00 +03:00
0778cc23ae добавил пакетную установку и консольную команду запуска 2026-07-06 16:23:47 +03:00
baf6e2f3a4 разделил создание зависимостей и сценарий переноса трудочасов, переименовал основную точку входа в main.py 2026-07-06 16:18:55 +03:00
9a0ce15160 оптимизировал сохранение данных и загрузку задач из Redmine 2026-07-06 16:11:41 +03:00
64b347e966 повысил надежность обработки API-ответов и ошибок БД 2026-07-06 16:07:08 +03:00
bd20ef4d48 Добавил таймауты для API-запросов, уточнил парсинг тегов и типизацию аргументов 2026-07-06 16:01:24 +03:00
8d19e1d43f Слегка изменил отображение процесса занесения трудочасов 2026-07-01 16:25:12 +03:00
fa97f75fbe Merge branch 'dev' 2026-06-26 17:09:15 +03:00
4e2aea78fe Сохранять трудочасы в БД сразу после успешного Redmine
- убрать tqdm из занесения трудочасов в Redmine
- логировать успех и ошибку по каждой активности
- обновлять is_tracked и time_entry сразу после успешного занесения
- выводить в итоговом логе список незанесённых задач
2026-06-19 16:45:24 +03:00
3683925e14 Merge branch 'dev' 2026-06-19 16:28:17 +03:00
e7cbffcd70 Убрал billable и заменил проверку о "затреканности" на запись из бд 2026-06-16 17:44:20 +03:00
e7837cc0f3 Merge branch 'dev' 2026-06-05 16:14:21 +03:00
20fa3eb8a1 Поправил ошибку с list has no attribute 'title' 2026-06-05 16:14:07 +03:00
63044be106 Revert "Бот + прокси"
This reverts commit 5dc9c8f0d1.
2026-06-04 16:00:35 +03:00
5dc9c8f0d1 Бот + прокси 2026-04-30 16:34:57 +03:00
ddec6094c2 Добавили пагинацию для тегов 2025-10-29 16:07:06 +03:00
129c2377e8 Merge pull request 'Изменил отображение в консоли и проработал режим отладки' (#15) from dev into master
Reviewed-on: #15
2025-10-03 18:22:45 +03:00
e14972c8a2 Изменил отображение в консоли 2025-10-03 18:20:30 +03:00
9688bd644d Проработал режим отладки 2025-10-03 18:15:45 +03:00
8fc8c97597 merge dev into master 2025-09-30 11:49:22 +03:00
8c603cc7ab Изменил вывод информации об успешном/неудачном затрекивании задачи 2025-09-30 11:48:36 +03:00
0bac6a5b5f Merge pull request 'Добавил более подробное описание ошибки при обновлении тега и номера строк в логи' (#14) from dev into master
Reviewed-on: #14
2025-08-06 11:16:58 +03:00
ee4fc1b8f8 Добавил более подробное описание ошибки при обновлении тега 2025-08-06 11:15:55 +03:00
4581142ac6 Добавил номера строк в логи 2025-08-06 11:12:50 +03:00
e0d90b4e59 Merge pull request 'Обработал случай, когда Теги приходят списком, Ошибка cannot access local variable 'end'' (#13) from dev into master
Reviewed-on: #13
2025-08-05 16:53:18 +03:00
2333c6165b Обработал случай, когда Теги приходят списком 2025-08-05 16:52:03 +03:00
f9e14fb7af Ошибка cannot access local variable 'end' 2025-08-04 16:55:53 +03:00
97c2a7aa37 Merge pull request 'Заменил logging на logger' (#12) from dev into master
Reviewed-on: #12
2025-07-04 09:23:27 +03:00
ea9fa5b472 Заменил logging на logger 2025-07-04 09:23:01 +03:00
6f54c6d3ac Ошибка при отсутствии Проектов и проектов Ветро 2025-07-03 17:02:08 +03:00
d3c0a6a8c2 Merge pull request 'Большой рефактор. Обновление тегов, задач и типов деятельности при сохранении активностей в БД' (#11) from dev into master
Reviewed-on: #11
2025-07-03 15:51:59 +03:00
572c123432 Merge pull request 'dev' (#10) from dev into master
Reviewed-on: #10
2025-07-02 11:16:03 +03:00
8a658a4cf3 Merge pull request 'Строгий порядок полей activity при загрузке в бд' (#9) from dev into master
Reviewed-on: #9
2025-06-24 10:45:22 +03:00
bcfadfdfb9 Merge pull request 'dev' (#8) from dev into master
Reviewed-on: #8
2025-06-23 17:53:43 +03:00
9a00a4cf24 Merge pull request 'Поправил ошибку с пустым проектом Ветро' (#7) from dev into master
Reviewed-on: #7
2025-06-23 14:25:11 +03:00
22 changed files with 1114 additions and 400 deletions

View File

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

View File

@@ -1,15 +1,15 @@
from datetime import datetime from datetime import datetime
import logging import logging
from typing import Union
from colorama import Fore from colorama import Fore
import requests import requests
from tqdm import tqdm from tqdm import tqdm
from config import ClockifyConfig, config, db from config import ClockifyConfig, config
from classes import Activity, Tag, VetroProject from classes import Activity, Tag, VetroProject
from exceptions import InvalidToken from exceptions import InvalidToken
from utils import parse_task_id, time_to_hour from utils import format_hours, parse_task_id, time_to_hour
from .constants import REQUEST_TIMEOUT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,8 +20,10 @@ class ClockifyAPI(ClockifyConfig):
Позволяет получать активности, теги, проекты ветро и помечать активности затрканными. Позволяет получать активности, теги, проекты ветро и помечать активности затрканными.
""" """
def __init__(self, token: str, *args, **kwargs): def __init__(self, token: str, db, redmine_api=None, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.db = db
self.redmine_api = redmine_api
self.token = self._check_token(token) self.token = self._check_token(token)
def get_activities(self, is_updating=False, **kwargs) -> list[Activity]: def get_activities(self, is_updating=False, **kwargs) -> list[Activity]:
@@ -33,7 +35,16 @@ class ClockifyAPI(ClockifyConfig):
page-size: int - Размер страницы (по умолчанию 50) page-size: int - Размер страницы (по умолчанию 50)
""" """
time_entries_url = self.time_entries_url time_entries_url = self.time_entries_url
activities = requests.get(self.base_url + time_entries_url, params=kwargs, headers={"X-API-KEY": self.token}).json() resp = requests.get(
self.base_url + time_entries_url,
params=kwargs,
headers={"X-API-KEY": self.token},
timeout=REQUEST_TIMEOUT,
)
if not resp.ok:
logger.error(f"Ошибка ClockifyAPI: {resp.text} {resp.status_code}")
resp.raise_for_status()
activities = resp.json()
if not activities: if not activities:
return [] return []
logger.info(f"Получено задач: {len(activities)}") logger.info(f"Получено задач: {len(activities)}")
@@ -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
View File

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

View File

@@ -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 get_time_entry_activities(self) -> list[ActivityTypes]: def format_activity_result(self, activity: Activity, is_beginner: bool = False) -> str:
"""Форматирует строку результата переноса активности."""
hours = round(activity.time_spent / (2 if is_beginner else 1), 3)
return f"#{activity.task_id} | {format_hours(hours)} | {activity.description}"
def get_activity_type_id(self, activity: Activity) -> int:
"""Возвращает тип деятельности Redmine для активности Clockify."""
return ACTIVITY_TYPE_BY_VETRO_PROJECT.get(
activity.vetro_project_id,
DEFAULT_ACTIVITY_TYPE,
)
def get_time_entry_activities(self) -> list[ActivityType]:
"""Получает виды Деятельности с сервера Redmine""" """Получает виды Деятельности с сервера Redmine"""
resp = requests.get(self.base_url + self.time_entry_activities, headers={"X-Redmine-API-KEY": self.token}) resp = requests.get(
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)

View File

@@ -1 +1,7 @@
from .classes import Activity, ActivityType, Tag, VetroProject # noqa: F401 from .classes import ( # noqa: F401
Activity,
ActivityType,
AggregatedActivity,
Tag,
VetroProject,
)

View File

@@ -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

View File

@@ -3,8 +3,6 @@ from datetime import datetime
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from dotenv import load_dotenv from dotenv import load_dotenv
from db.db import Database
from .clockify import ClockifyConfig # noqa: F401 from .clockify import ClockifyConfig # noqa: F401
from .redmine import RedmineConfig # noqa: F401 from .redmine import RedmineConfig # noqa: F401
@@ -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"
@@ -45,6 +46,10 @@ class Config(BaseSettings):
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():
parser = argparse.ArgumentParser(description='Трекер трудочасов с Clockify в Redmine') parser = argparse.ArgumentParser(description='Трекер трудочасов с Clockify в Redmine')
@@ -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)

View File

@@ -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}"

View File

@@ -1,3 +1,6 @@
REDMINE_ISSUE_URL_TEMPLATE = "https://redmine.sbps.ru/issues/{issue_id}"
class RedmineConfig: class RedmineConfig:
def __init__(self): def __init__(self):
self.base_url = "https://redmine.sbps.ru" self.base_url = "https://redmine.sbps.ru"

13
db/constants.py Normal file
View File

@@ -0,0 +1,13 @@
"""Константы слоя базы данных."""
USER_LOOKUP_FIELDS = frozenset(
{
"id",
"clockify_user_id",
"redmine_user_id",
"clockify_email",
"clockify_username",
"redmine_email",
"redmine_username",
}
)

299
db/db.py
View File

@@ -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,
):
"""Сохраняет запись Redmine и связывает с ней активности Clockify."""
try:
self.cursor.execute(
"""INSERT INTO time_entry """INSERT INTO time_entry
(date, description, activity_id, time_spent, task_id, activity_type_id, author_id) (id, date, description, time_spent, task_id, activity_type_id, author_id)
VALUES(?,?,?,?,?,?,?)""", VALUES(?, ?, ?, ?, ?, ?, ?)
( ON CONFLICT(id) DO UPDATE SET
act.date_start.date(), date = excluded.date,
act.description, description = excluded.description,
act.id, time_spent = excluded.time_spent,
act.time_spent, task_id = excluded.task_id,
act.task_id, activity_type_id = excluded.activity_type_id,
ActivityTypes.DEVELOPMENT, author_id = excluded.author_id""",
act.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
View 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
View File

@@ -0,0 +1 @@
"""Миграции локальной базы данных приложения."""

View 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
View File

@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "redmine-tracker"
version = "0.1.0"
description = "CLI для переноса трудочасов из Clockify в Redmine"
readme = "README.MD"
requires-python = ">=3.12"
dependencies = [
"colorama==0.4.6",
"pydantic==2.11.4",
"pydantic-settings==2.9.1",
"python-dotenv==1.1.0",
"requests==2.32.3",
"tqdm==4.67.1",
]
[project.scripts]
redmine-tracker = "main:main"
[tool.setuptools]
py-modules = ["main", "track", "utils", "enums", "exceptions"]
[tool.setuptools.packages.find]
include = ["api", "classes", "config", "db", "services", "migrations"]

1
services/__init__.py Normal file
View File

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

134
services/tracking.py Normal file
View 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
View 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
View 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
View 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)

View File

@@ -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
View File

@@ -3,7 +3,6 @@ from datetime import datetime, timedelta
import logging import logging
import os import os
import re import re
from typing import Optional
from colorama import Fore from colorama import Fore
@@ -12,7 +11,20 @@ from config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def time_to_hour(time: timedelta) -> float: def time_to_hour(time: timedelta) -> float:
return time.total_seconds() / 3600 return round(time.total_seconds() / 3600, 3)
def format_hours(hours: float) -> str:
"""Форматирует десятичные часы и длительность без нулевых старших единиц."""
total_seconds = round(hours * 3600)
hours_part, remaining_seconds = divmod(total_seconds, 3600)
minutes_part, seconds_part = divmod(remaining_seconds, 60)
if hours_part:
duration = f"{hours_part}:{minutes_part:02}:{seconds_part:02}"
elif minutes_part:
duration = f"{minutes_part}:{seconds_part:02}"
else:
duration = str(seconds_part)
return f"{hours:.3f} ч ({duration})"
def today_start() -> datetime: def today_start() -> datetime:
td = datetime.today() td = datetime.today()
@@ -26,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 = {
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]
db_tasks = db.get_tasks() if new_tasks:
red_tasks = red.get_issues() try:
clock_tasks = [task for task in red_tasks if task.id in [parse_task_id(tag.title) for tag in [*db_tags, *new_tags]]] db.insert_tasks(new_tasks)
new_tasks = [task for task in clock_tasks if task not in db_tasks] logger.info(f"{Fore.GREEN}Обновлено задач: {len(new_tasks)}{Fore.RESET}")
except Exception as e:
if new_tasks: logger.error(f"{Fore.RED}Ошибка при сохранении задач!{Fore.RESET}")
try: else:
db.insert_tasks(new_tasks) logger.info(f"{Fore.YELLOW}Обновление задач не требуется\n{Fore.RESET}")
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}") 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}")