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

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

View File

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

View File

@@ -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)
}
},
@@ -80,6 +93,38 @@ class RedmineAPI(RedmineConfig):
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"""
resp = requests.get(

View File

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

View File

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

View File

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