добавил предпросмотр и защиту от повторной отправки трудочасов
This commit is contained in:
@@ -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` |
|
||||
|
||||
Также можно запускать напрямую из корня проекта:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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',
|
||||
|
||||
3
main.py
3
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)
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user