Stage 06.1 - journal management UI, export and system menu redesign
This commit is contained in:
231
app/src/trading/journal/exporter.py
Normal file
231
app/src/trading/journal/exporter.py
Normal 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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user