добавил предпросмотр и защиту от повторной отправки трудочасов

This commit is contained in:
2026-07-06 16:28:00 +03:00
parent 0778cc23ae
commit ba9eafbb46
6 changed files with 87 additions and 5 deletions

View File

@@ -97,6 +97,7 @@ redmine-tracker
| `-e [--end]` | Дата окончания парсинга активностей в формате гггг-мм-дд | `redmine-tracker -e 2025-05-19` | | `-e [--end]` | Дата окончания парсинга активностей в формате гггг-мм-дд | `redmine-tracker -e 2025-05-19` |
| `-p [--page-size]`| Количество задач для вывода. Выведет последние n записей | `redmine-tracker --page-size 15` | | `-p [--page-size]`| Количество задач для вывода. Выведет последние n записей | `redmine-tracker --page-size 15` |
| `-d [--debug]` | Режим отладки. Не загружает трудочасы в Redmine | `redmine-tracker -d` | | `-d [--debug]` | Режим отладки. Не загружает трудочасы в Redmine | `redmine-tracker -d` |
| `--dry-run` | Предпросмотр без сохранения активностей и отправки трудочасов | `redmine-tracker --dry-run` |
| `-i [--init-db]` | Режим инициализации бд.<br>Подгружает с серверов теги, виды деятельности и Проекты (ДО) Ветро| `redmine-tracker -init-db` | | `-i [--init-db]` | Режим инициализации бд.<br>Подгружает с серверов теги, виды деятельности и Проекты (ДО) Ветро| `redmine-tracker -init-db` |
Также можно запускать напрямую из корня проекта: Также можно запускать напрямую из корня проекта:

View File

@@ -1,4 +1,10 @@
"""Константы API-клиентов.""" """Константы API-клиентов."""
from enums import ActivityTypes
REQUEST_TIMEOUT = 30 REQUEST_TIMEOUT = 30
REDMINE_ISSUES_LIMIT = 100 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

View File

@@ -6,9 +6,15 @@ import requests
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 .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__) logger = logging.getLogger(__name__)
@@ -37,6 +43,13 @@ class RedmineAPI(RedmineConfig):
] ]
for activity in activities_to_track: for activity in activities_to_track:
try: 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: if not config.is_debug:
resp = requests.post( resp = requests.post(
self.base_url + self.time_entries_url, self.base_url + self.time_entries_url,
@@ -45,8 +58,8 @@ class RedmineAPI(RedmineConfig):
"time_entry": { "time_entry": {
"issue_id": activity.task_id, "issue_id": activity.task_id,
"hours": activity.time_spent / (2 if is_beginner else 1), "hours": activity.time_spent / (2 if is_beginner else 1),
"activity_id": ActivityTypes.DEVELOPMENT, "activity_id": self.get_activity_type_id(activity),
"comments": activity.description, "comments": self.get_comment(activity),
"spent_on": activity.date_start.strftime(self.datetime_format) "spent_on": activity.date_start.strftime(self.datetime_format)
} }
}, },
@@ -80,6 +93,38 @@ class RedmineAPI(RedmineConfig):
logger.info(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "") logger.info(f"{Fore.RED}{activity}{Fore.RESET}".ljust(60) + "")
return tracked 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]: def get_time_entry_activities(self) -> list[ActivityType]:
"""Получает виды Деятельности с сервера Redmine""" """Получает виды Деятельности с сервера Redmine"""
resp = requests.get( resp = requests.get(

View File

@@ -74,6 +74,11 @@ 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',

View File

@@ -26,6 +26,8 @@ def main():
if is_debug := args.debug: if is_debug := args.debug:
config.MODE = "debug" config.MODE = "debug"
logging.getLogger().setLevel(logging.DEBUG) logging.getLogger().setLevel(logging.DEBUG)
if is_dry_run := args.dry_run:
config.MODE = "debug"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
db = Database(config.DB_NAME, config.SQL_PATH) db = Database(config.DB_NAME, config.SQL_PATH)
@@ -52,6 +54,7 @@ def main():
red=red, red=red,
db=db, db=db,
is_debug=is_debug, is_debug=is_debug,
is_dry_run=is_dry_run,
).track_period(start=start, end=end, page_size=args.page_size) ).track_period(start=start, end=end, page_size=args.page_size)
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)

View File

@@ -12,11 +12,12 @@ logger = logging.getLogger(__name__)
class TrackingService: class TrackingService:
"""Координирует загрузку, сохранение и перенос активностей в Redmine.""" """Координирует загрузку, сохранение и перенос активностей в 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.clock = clock
self.red = red self.red = red
self.db = db self.db = db
self.is_debug = is_debug self.is_debug = is_debug
self.is_dry_run = is_dry_run
def track_period(self, start, end, page_size: int | None): def track_period(self, start, end, page_size: int | None):
"""Переносит активности за период и возвращает список найденных активностей.""" """Переносит активности за период и возвращает список найденных активностей."""
@@ -34,6 +35,9 @@ class TrackingService:
if not activities: if not activities:
logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET) logger.info(Fore.YELLOW + "Нет задач для занесения трудочасов" + Fore.RESET)
return [] return []
if self.is_dry_run:
self._log_preview(activities)
return activities
self.db.insert_activities(activities) self.db.insert_activities(activities)
self.red.track_activities( self.red.track_activities(
@@ -64,3 +68,21 @@ class TrackingService:
"""Сохраняет активность как успешно перенесенную в Redmine.""" """Сохраняет активность как успешно перенесенную в Redmine."""
self.db.insert_activities([activity]) self.db.insert_activities([activity])
self.db.insert_time_entries([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}"
)