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