добавил объединение повторных активностей Clockify
This commit is contained in:
@@ -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
|
||||||
@@ -22,6 +24,13 @@ class Activity:
|
|||||||
return f'({self.task_id}) - "{self.description}"'[:60]
|
return f'({self.task_id}) - "{self.description}"'[:60]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AggregatedActivity(Activity):
|
||||||
|
"""Представляет несколько активностей Clockify одной записью Redmine."""
|
||||||
|
|
||||||
|
source_activities: tuple[Activity, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Tag:
|
class Tag:
|
||||||
id: str
|
id: str
|
||||||
|
|||||||
@@ -4,11 +4,48 @@ import logging
|
|||||||
|
|
||||||
from colorama import Fore
|
from colorama import Fore
|
||||||
|
|
||||||
|
from classes import Activity, AggregatedActivity
|
||||||
from utils import format_date, format_hours
|
from utils import format_date, format_hours
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class TrackingService:
|
||||||
"""Координирует загрузку, сохранение и перенос активностей в Redmine."""
|
"""Координирует загрузку, сохранение и перенос активностей в Redmine."""
|
||||||
|
|
||||||
@@ -35,13 +72,20 @@ class TrackingService:
|
|||||||
if not activities:
|
if not activities:
|
||||||
logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET)
|
logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET)
|
||||||
return []
|
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:
|
if self.is_dry_run:
|
||||||
self._log_preview(activities)
|
self._log_preview(activities, activities_to_track)
|
||||||
return activities
|
return activities
|
||||||
|
|
||||||
self.db.insert_activities(activities)
|
self.db.insert_activities(activities)
|
||||||
self.red.track_activities(
|
self.red.track_activities(
|
||||||
activities,
|
activities_to_track,
|
||||||
on_success=None if self.is_debug else self._save_tracked_activity,
|
on_success=None if self.is_debug else self._save_tracked_activity,
|
||||||
)
|
)
|
||||||
return activities
|
return activities
|
||||||
@@ -66,22 +110,20 @@ class TrackingService:
|
|||||||
|
|
||||||
def _save_tracked_activity(self, activity):
|
def _save_tracked_activity(self, activity):
|
||||||
"""Сохраняет активность как успешно перенесенную в Redmine."""
|
"""Сохраняет активность как успешно перенесенную в Redmine."""
|
||||||
self.db.insert_activities([activity])
|
source_activities = list(activity.source_activities or (activity,))
|
||||||
self.db.insert_time_entries([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)
|
total_hours = sum(activity.time_spent for activity in activities_to_track)
|
||||||
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"{Fore.YELLOW}Предпросмотр переноса трудочасов{Fore.RESET}")
|
||||||
logger.info(f"Найдено активностей: {len(activities)}")
|
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)}")
|
logger.info(f"Всего часов: {format_hours(total_hours)}")
|
||||||
for activity in trackable_activities:
|
for activity in activities_to_track:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{activity.date_start.date()} | #{activity.task_id} | "
|
f"{activity.date_start.date()} | #{activity.task_id} | "
|
||||||
f"{format_hours(activity.time_spent)} | {activity.description}"
|
f"{format_hours(activity.time_spent)} | {activity.description}"
|
||||||
|
|||||||
110
tests/test_tracking.py
Normal file
110
tests/test_tracking.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user