Stage 06.1 - journal management UI, export and system menu redesign

This commit is contained in:
2026-04-27 15:02:56 +03:00
parent 1fb72ced58
commit f6fc300e84
19 changed files with 1935 additions and 421 deletions

View File

@@ -0,0 +1,231 @@
# app/src/trading/journal/exporter.py
from __future__ import annotations
import csv
import json
from datetime import datetime
from io import BytesIO, StringIO
from zoneinfo import ZoneInfo
from openpyxl import Workbook
from openpyxl.styles import Font
from src.core.config import load_settings
EVENT_TITLES = {
"app_start": "Запуск приложения",
"journal_open_requested": "Открытие журнала",
"journal_export_csv_success": "Экспорт CSV",
"journal_export_csv_error": "Ошибка экспорта CSV",
"journal_export_xlsx_success": "Экспорт Excel",
"journal_export_xlsx_error": "Ошибка экспорта Excel",
"journal_cleared": "Журнал очищен",
"journal_cleared_old": "Очистка старых записей",
"system_open_alert": "Система загружена с предупреждениями",
"system_open_success": "Система загружена",
"market_open_requested": "Открытие рынка",
"market_open_success": "Рынок загружен",
"market_open_error": "Ошибка открытия рынка",
"portfolio_open_requested": "Открытие портфеля",
"portfolio_open_success": "Портфель загружен",
"portfolio_open_error": "Ошибка открытия портфеля",
"portfolio_partial_estimate": "Частичная оценка портфеля",
"exchange_request_error": "Ошибка запроса к бирже",
"balance_summary_loaded": "Баланс загружен",
"balance_summary_error": "Ошибка загрузки баланса",
}
def _now_local() -> datetime:
settings = load_settings()
try:
return datetime.now(ZoneInfo(settings.tz))
except Exception:
return datetime.utcnow()
def _format_datetime(value: object) -> str:
text = str(value or "").strip()
if not text:
return ""
try:
settings = load_settings()
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(ZoneInfo(settings.tz)).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
return text
def _event_title(event_type: object) -> str:
value = str(event_type or "").strip()
return EVENT_TITLES.get(value, value.replace("_", " ").strip().capitalize())
def _payload(row: dict) -> dict:
payload = row.get("payload")
return payload if isinstance(payload, dict) else {}
def _payload_json(payload: dict) -> str:
if not payload:
return ""
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
def _export_row(row: dict) -> list[str]:
payload = _payload(row)
return [
_format_datetime(row.get("created_at")),
str(row.get("level") or ""),
str(row.get("event_type") or ""),
_event_title(row.get("event_type")),
str(row.get("message") or ""),
str(payload.get("account_mode") or "").upper(),
str(payload.get("screen") or ""),
str(payload.get("action") or ""),
str(payload.get("error_type") or ""),
str(payload.get("raw_error") or ""),
_payload_json(payload),
]
def _headers() -> list[str]:
return [
"Дата",
"Уровень",
"Событие",
"Заголовок",
"Сообщение",
"Аккаунт",
"Экран",
"Действие",
"Тип ошибки",
"Техническая ошибка",
"Payload",
]
def _levels_summary(rows: list[dict]) -> str:
levels = sorted(
{str(row.get("level") or "").upper() for row in rows if row.get("level")}
)
return ", ".join(levels) if levels else ""
def _period_summary(rows: list[dict]) -> str:
dates = [_format_datetime(row.get("created_at")) for row in rows if row.get("created_at")]
dates = [value for value in dates if value]
if not dates:
return ""
return f"{min(dates)}{max(dates)}"
def _metadata_rows(
*,
rows: list[dict],
total_count: int,
export_limit: int,
account_mode: str,
journal_level: str,
) -> list[list[str]]:
exported_count = len(rows)
is_limited = total_count > exported_count
return [
["Экспорт журнала"],
["Дата экспорта", _now_local().strftime("%Y-%m-%d %H:%M:%S")],
["Аккаунт", account_mode.upper()],
["Уровень журнала", journal_level],
["Всего записей в журнале", str(total_count)],
["Записей в файле", str(exported_count)],
["Лимит экспорта", str(export_limit)],
["Экспорт ограничен", "да" if is_limited else "нет"],
["Уровни в файле", _levels_summary(rows)],
["Период", _period_summary(rows)],
[],
]
def build_csv(
rows: list[dict],
*,
total_count: int,
export_limit: int,
account_mode: str,
journal_level: str,
) -> bytes:
output = StringIO()
writer = csv.writer(
output,
delimiter=";",
quoting=csv.QUOTE_ALL,
lineterminator="\n",
)
for metadata_row in _metadata_rows(
rows=rows,
total_count=total_count,
export_limit=export_limit,
account_mode=account_mode,
journal_level=journal_level,
):
writer.writerow(metadata_row)
writer.writerow(_headers())
for row in rows:
writer.writerow(_export_row(row))
return output.getvalue().encode("utf-8-sig")
def build_xlsx(
rows: list[dict],
*,
total_count: int,
export_limit: int,
account_mode: str,
journal_level: str,
) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "Journal"
for metadata_row in _metadata_rows(
rows=rows,
total_count=total_count,
export_limit=export_limit,
account_mode=account_mode,
journal_level=journal_level,
):
ws.append(metadata_row)
header_row_index = ws.max_row + 1
ws.append(_headers())
for cell in ws[1]:
cell.font = Font(bold=True)
for cell in ws[header_row_index]:
cell.font = Font(bold=True)
for row in rows:
ws.append(_export_row(row))
for column_cells in ws.columns:
max_length = max(len(str(cell.value or "")) for cell in column_cells)
ws.column_dimensions[column_cells[0].column_letter].width = min(max_length + 2, 60)
stream = BytesIO()
wb.save(stream)
return stream.getvalue()

View File

@@ -1,15 +1,67 @@
# app/src/trading/journal/service.py
from __future__ import annotations
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
from src.core.config import load_settings
from src.storage.repositories.journal import JournalRepository
from src.storage.session import check_database_health
from src.trading.journal.exporter import build_csv, build_xlsx
EXPORT_LIMIT = 5000
class JournalService:
def __init__(self) -> None:
self.repository = JournalRepository()
def _account_mode(self) -> str:
settings = load_settings()
return "demo" if "demo" in settings.exchange_base_url.lower() else "live"
def _account_prefix(self) -> str:
return f"[{self._account_mode().upper()}]"
def _build_message(self, message: str) -> str:
return f"{self._account_prefix()} {message}".strip()
def _build_payload(
self,
*,
user_id: int | None = None,
chat_id: int | None = None,
screen: str | None = None,
action: str | None = None,
error_type: str | None = None,
raw_error: str | None = None,
payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
result: dict[str, Any] = dict(payload or {})
result.setdefault("account_mode", self._account_mode())
if user_id is not None:
result.setdefault("user_id", user_id)
if chat_id is not None:
result.setdefault("chat_id", chat_id)
if screen is not None:
result.setdefault("screen", screen)
if action is not None:
result.setdefault("action", action)
if error_type is not None:
result.setdefault("error_type", error_type)
if raw_error is not None:
result.setdefault("raw_error", raw_error)
return result
def log_info(
self,
event_type: str,
@@ -62,16 +114,155 @@ class JournalService:
payload=payload,
)
def get_recent(self, limit: int = 10) -> list[dict[str, str]]:
def log_ui_info(
self,
*,
event_type: str,
message: str,
screen: str,
action: str,
user_id: int | None = None,
chat_id: int | None = None,
payload: dict[str, Any] | None = None,
) -> None:
self.log_info(
event_type=event_type,
message=self._build_message(message),
payload=self._build_payload(
user_id=user_id,
chat_id=chat_id,
screen=screen,
action=action,
payload=payload,
),
)
def log_ui_warning(
self,
*,
event_type: str,
message: str,
screen: str,
action: str,
user_id: int | None = None,
chat_id: int | None = None,
payload: dict[str, Any] | None = None,
error_type: str | None = None,
raw_error: str | None = None,
) -> None:
self.log_warning(
event_type=event_type,
message=self._build_message(message),
payload=self._build_payload(
user_id=user_id,
chat_id=chat_id,
screen=screen,
action=action,
error_type=error_type,
raw_error=raw_error,
payload=payload,
),
)
def log_ui_error(
self,
*,
event_type: str,
message: str,
screen: str,
action: str,
user_id: int | None = None,
chat_id: int | None = None,
payload: dict[str, Any] | None = None,
error_type: str | None = None,
raw_error: str | None = None,
) -> None:
self.log_error(
event_type=event_type,
message=self._build_message(message),
payload=self._build_payload(
user_id=user_id,
chat_id=chat_id,
screen=screen,
action=action,
error_type=error_type,
raw_error=raw_error,
payload=payload,
),
)
def get_recent(self, limit: int = 10) -> list[dict[str, Any]]:
return self.repository.list_recent_events(limit=limit)
def get_page(self, page: int = 1, page_size: int = 3) -> list[dict[str, str]]:
def get_page(self, page: int = 1, page_size: int = 5) -> list[dict[str, Any]]:
offset = (page - 1) * page_size
return self.repository.list_recent_with_offset(limit=page_size, offset=offset)
def get_total_count(self) -> int:
return self.repository.count_events()
def get_export_rows(self, limit: int = EXPORT_LIMIT) -> list[dict[str, Any]]:
return self.repository.list_export_rows(limit=limit)
def _journal_level(self) -> str:
return "INFO+"
def _export_timestamp(self) -> str:
settings = load_settings()
try:
now = datetime.now(ZoneInfo(settings.tz))
except Exception:
now = datetime.utcnow()
return now.strftime("%Y-%m-%d_%H-%M-%S")
def build_export_filename(self, extension: str) -> str:
safe_extension = extension.lower().strip().lstrip(".")
safe_level = self._journal_level().lower().replace("+", "_plus")
return (
f"journal_"
f"{self._account_mode()}_"
f"{safe_level}_"
f"{self._export_timestamp()}."
f"{safe_extension}"
)
def export_csv(self, limit: int = EXPORT_LIMIT) -> bytes:
rows = self.get_export_rows(limit=limit)
return build_csv(
rows,
total_count=self.get_total_count(),
export_limit=limit,
account_mode=self._account_mode(),
journal_level=self._journal_level(),
)
def export_xlsx(self, limit: int = EXPORT_LIMIT) -> bytes:
rows = self.get_export_rows(limit=limit)
return build_xlsx(
rows,
total_count=self.get_total_count(),
export_limit=limit,
account_mode=self._account_mode(),
journal_level=self._journal_level(),
)
def clear_all(self) -> int:
deleted_count = self.repository.delete_all()
self.log_ui_warning(
event_type="journal_cleared",
message="Журнал очищен.",
screen="journal",
action="clear",
payload={"deleted_count": deleted_count},
)
return deleted_count
def get_journal_health(self) -> tuple[bool, str]:
db_ok, db_message = check_database_health()
if not db_ok:
@@ -82,4 +273,20 @@ class JournalService:
except Exception as exc:
return False, f"Ошибка чтения журнала: {exc}"
return True, f"Журнал работает. Событий: {total}"
return True, f"Журнал работает. Событий: {total}"
def clear_older_than_days(self, days: int) -> int:
deleted_count = self.repository.delete_older_than_days(days)
self.log_ui_warning(
event_type="journal_cleared_old",
message=f"Журнал очищен старше {days} дней.",
screen="journal",
action="clear_old",
payload={
"days": days,
"deleted_count": deleted_count,
},
)
return deleted_count