diff --git a/README.MD b/README.MD index 9e6c164..cfc7f3d 100644 --- a/README.MD +++ b/README.MD @@ -97,6 +97,7 @@ redmine-tracker | `-e [--end]` | Дата окончания парсинга активностей в формате гггг-мм-дд | `redmine-tracker -e 2025-05-19` | | `-p [--page-size]`| Количество задач для вывода. Выведет последние n записей | `redmine-tracker --page-size 15` | | `-d [--debug]` | Режим отладки. Не загружает трудочасы в Redmine | `redmine-tracker -d` | +| `--dry-run` | Предпросмотр без сохранения активностей и отправки трудочасов | `redmine-tracker --dry-run` | | `-i [--init-db]` | Режим инициализации бд.
Подгружает с серверов теги, виды деятельности и Проекты (ДО) Ветро| `redmine-tracker -init-db` | Также можно запускать напрямую из корня проекта: diff --git a/api/constants.py b/api/constants.py index 76497af..fea382b 100644 --- a/api/constants.py +++ b/api/constants.py @@ -1,4 +1,10 @@ """Константы API-клиентов.""" +from enums import ActivityTypes + REQUEST_TIMEOUT = 30 REDMINE_ISSUES_LIMIT = 100 +REDMINE_TIME_ENTRIES_LIMIT = 100 +CLOCKIFY_ACTIVITY_COMMENT_PREFIX = "Clockify activity ID:" +ACTIVITY_TYPE_BY_VETRO_PROJECT = {} +DEFAULT_ACTIVITY_TYPE = ActivityTypes.DEVELOPMENT diff --git a/api/redmine.py b/api/redmine.py index e0393ea..383f6bf 100644 --- a/api/redmine.py +++ b/api/redmine.py @@ -6,9 +6,15 @@ import requests from classes.classes import Task from config import RedmineConfig, config from classes import Activity, ActivityType -from enums import ActivityTypes from exceptions import InvalidToken -from .constants import REDMINE_ISSUES_LIMIT, REQUEST_TIMEOUT +from .constants import ( + ACTIVITY_TYPE_BY_VETRO_PROJECT, + CLOCKIFY_ACTIVITY_COMMENT_PREFIX, + DEFAULT_ACTIVITY_TYPE, + REDMINE_ISSUES_LIMIT, + REDMINE_TIME_ENTRIES_LIMIT, + REQUEST_TIMEOUT, +) logger = logging.getLogger(__name__) @@ -37,6 +43,13 @@ class RedmineAPI(RedmineConfig): ] for activity in activities_to_track: try: + if not config.is_debug and self.has_time_entry_for_activity(activity): + activity.is_tracked = True + if on_success: + on_success(activity) + tracked.append(activity) + logger.info(f"{Fore.GREEN}{activity}: уже есть в Redmine {Fore.RESET}".ljust(100) + "✅") + continue if not config.is_debug: resp = requests.post( self.base_url + self.time_entries_url, @@ -45,8 +58,8 @@ class RedmineAPI(RedmineConfig): "time_entry": { "issue_id": activity.task_id, "hours": activity.time_spent / (2 if is_beginner else 1), - "activity_id": ActivityTypes.DEVELOPMENT, - "comments": activity.description, + "activity_id": self.get_activity_type_id(activity), + "comments": self.get_comment(activity), "spent_on": activity.date_start.strftime(self.datetime_format) } }, @@ -79,6 +92,38 @@ class RedmineAPI(RedmineConfig): for activity in failed_activities: logger.info(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "❌") return tracked + + def get_comment(self, activity: Activity) -> str: + """Формирует комментарий Redmine с внешним id активности Clockify.""" + return f"{activity.description}\n{CLOCKIFY_ACTIVITY_COMMENT_PREFIX} {activity.id}" + + 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 has_time_entry_for_activity(self, activity: Activity) -> bool: + """Проверяет, была ли активность Clockify уже перенесена в Redmine.""" + resp = requests.get( + self.base_url + self.time_entries_url, + headers={"X-Redmine-Api-Key": self.token}, + params={ + "issue_id": activity.task_id, + "spent_on": activity.date_start.strftime(self.datetime_format), + "limit": REDMINE_TIME_ENTRIES_LIMIT, + }, + timeout=REQUEST_TIMEOUT, + ) + if not resp.ok: + logger.error(f"Ошибка RedmineAPI: {resp.text} {resp.status_code}") + return False + marker = f"{CLOCKIFY_ACTIVITY_COMMENT_PREFIX} {activity.id}" + return any( + marker in time_entry.get("comments", "") + for time_entry in resp.json().get("time_entries", []) + ) def get_time_entry_activities(self) -> list[ActivityType]: """Получает виды Деятельности с сервера Redmine""" diff --git a/config/__init__.py b/config/__init__.py index aeb2e26..ee251eb 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -74,6 +74,11 @@ def configure_argument_parser(): action="store_true", help='Режим отладки. Не загружает трудочасы в Redmine' ) + parser.add_argument( + '--dry-run', + action="store_true", + help='Предпросмотр. Показывает активности без их сохранения и отправки в Redmine' + ) parser.add_argument( '-i', '--init-db', diff --git a/main.py b/main.py index da1320a..3a80f8c 100644 --- a/main.py +++ b/main.py @@ -26,6 +26,8 @@ def main(): if is_debug := args.debug: config.MODE = "debug" logging.getLogger().setLevel(logging.DEBUG) + if is_dry_run := args.dry_run: + config.MODE = "debug" logger = logging.getLogger(__name__) db = Database(config.DB_NAME, config.SQL_PATH) @@ -52,6 +54,7 @@ def main(): 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) diff --git a/services/tracking.py b/services/tracking.py index 74c8067..5a8f29e 100644 --- a/services/tracking.py +++ b/services/tracking.py @@ -12,11 +12,12 @@ logger = logging.getLogger(__name__) class TrackingService: """Координирует загрузку, сохранение и перенос активностей в Redmine.""" - def __init__(self, clock, red, db, is_debug: bool): + 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): """Переносит активности за период и возвращает список найденных активностей.""" @@ -34,6 +35,9 @@ class TrackingService: if not activities: logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET) return [] + if self.is_dry_run: + self._log_preview(activities) + return activities self.db.insert_activities(activities) self.red.track_activities( @@ -64,3 +68,21 @@ class TrackingService: """Сохраняет активность как успешно перенесенную в Redmine.""" self.db.insert_activities([activity]) self.db.insert_time_entries([activity]) + + def _log_preview(self, activities): + """Выводит план переноса активностей без сохранения и отправки.""" + total_hours = sum(activity.time_spent for activity in activities if activity.date_end) + trackable_activities = [ + activity + for activity in activities + if not activity.is_tracked and activity.date_end + ] + logger.info(f"{Fore.YELLOW}Предпросмотр переноса трудочасов{Fore.RESET}") + logger.info(f"Найдено активностей: {len(activities)}") + logger.info(f"Готово к переносу: {len(trackable_activities)}") + logger.info(f"Всего часов: {total_hours}") + for activity in trackable_activities: + logger.info( + f"{activity.date_start.date()} | #{activity.task_id} | " + f"{activity.time_spent} ч | {activity.description}" + )