From 5dca3ae446bb03ff07a798cc61371eb33b851fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AD=D0=B4=D1=83=D0=B0=D1=80=D0=B4?= Date: Tue, 14 Jul 2026 15:23:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=B9=20Clockify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- classes/__init__.py | 8 ++- classes/classes.py | 9 ++++ services/tracking.py | 68 ++++++++++++++++++++----- tests/test_tracking.py | 110 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 tests/test_tracking.py diff --git a/classes/__init__.py b/classes/__init__.py index fb524b5..047ffe6 100644 --- a/classes/__init__.py +++ b/classes/__init__.py @@ -1 +1,7 @@ -from .classes import Activity, ActivityType, Tag, VetroProject # noqa: F401 +from .classes import ( # noqa: F401 + Activity, + ActivityType, + AggregatedActivity, + Tag, + VetroProject, +) diff --git a/classes/classes.py b/classes/classes.py index 0e14052..8e8e349 100644 --- a/classes/classes.py +++ b/classes/classes.py @@ -6,6 +6,8 @@ from enums import ClockifyProjects, VetroProjects @dataclass class Activity: + """Описывает одну активность, полученную из Clockify.""" + id: str author_id: int vetro_project_id: VetroProjects @@ -22,6 +24,13 @@ class Activity: return f'({self.task_id}) - "{self.description}"'[:60] +@dataclass +class AggregatedActivity(Activity): + """Представляет несколько активностей Clockify одной записью Redmine.""" + + source_activities: tuple[Activity, ...] = () + + @dataclass class Tag: id: str diff --git a/services/tracking.py b/services/tracking.py index 1afa9d1..53292dc 100644 --- a/services/tracking.py +++ b/services/tracking.py @@ -4,11 +4,48 @@ 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.""" @@ -35,13 +72,20 @@ class TrackingService: 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) + self._log_preview(activities, activities_to_track) return activities self.db.insert_activities(activities) self.red.track_activities( - activities, + activities_to_track, on_success=None if self.is_debug else self._save_tracked_activity, ) return activities @@ -66,22 +110,20 @@ class TrackingService: def _save_tracked_activity(self, activity): """Сохраняет активность как успешно перенесенную в Redmine.""" - self.db.insert_activities([activity]) - self.db.insert_time_entries([activity]) + 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_entries(source_activities) - def _log_preview(self, activities): + def _log_preview(self, activities, activities_to_track): """Выводит план переноса активностей без сохранения и отправки.""" - 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 - ] + 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(trackable_activities)}") + logger.info(f"Готово к переносу: {len(activities_to_track)}") logger.info(f"Всего часов: {format_hours(total_hours)}") - for activity in trackable_activities: + for activity in activities_to_track: logger.info( f"{activity.date_start.date()} | #{activity.task_id} | " f"{format_hours(activity.time_spent)} | {activity.description}" diff --git a/tests/test_tracking.py b/tests/test_tracking.py new file mode 100644 index 0000000..2b36c58 --- /dev/null +++ b/tests/test_tracking.py @@ -0,0 +1,110 @@ +"""Тесты агрегации активностей 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) + + self.assertTrue(all(activity.is_tracked for activity in activities)) + db.insert_activities.assert_called_once_with(activities) + db.insert_time_entries.assert_called_once_with(activities) + + 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)