Stage 06.1 - journal management UI, export and system menu redesign
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
# app/requirements.txt
|
||||
|
||||
aiogram==3.13.1
|
||||
python-dotenv==1.0.1
|
||||
psycopg[binary]==3.2.9
|
||||
psycopg[binary]==3.2.9
|
||||
openpyxl==3.1.5
|
||||
@@ -114,11 +114,11 @@ def _build_journal_status() -> ComponentStatus:
|
||||
|
||||
def get_runtime_mode_key() -> str:
|
||||
settings = load_settings()
|
||||
return "demo" if "demo" in settings.exchange_base_url.lower() else "real"
|
||||
return "demo" if "demo" in settings.exchange_base_url.lower() else "live"
|
||||
|
||||
|
||||
def get_runtime_mode_label() -> str:
|
||||
return "ДЕМО аккаунт" if get_runtime_mode_key() == "demo" else "РЕАЛЬНЫЙ аккаунт"
|
||||
return "DEMO аккаунт" if get_runtime_mode_key() == "demo" else "LIVE аккаунт"
|
||||
|
||||
|
||||
def get_system_snapshot() -> SystemSnapshot:
|
||||
@@ -182,7 +182,7 @@ def build_system_text(*, include_updated_at: bool = False) -> str:
|
||||
)
|
||||
|
||||
text = (
|
||||
"<b>⚙️ Система</b>\n"
|
||||
"<b>🖥️ Система</b>\n"
|
||||
f"🔸 <b>{snapshot.mode_label}</b>\n"
|
||||
f"⏱️ {snapshot.timezone_name}\n\n"
|
||||
f"{components_block}"
|
||||
|
||||
@@ -50,6 +50,89 @@ class ExchangeService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _classify_error(self, exc: Exception) -> str:
|
||||
text = str(exc).lower()
|
||||
|
||||
if any(
|
||||
marker in text
|
||||
for marker in [
|
||||
"invalid api key",
|
||||
"api key",
|
||||
"api-key",
|
||||
"signature",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"private api error",
|
||||
"expired",
|
||||
]
|
||||
):
|
||||
return "auth"
|
||||
|
||||
if any(
|
||||
marker in text
|
||||
for marker in [
|
||||
"timeout",
|
||||
"timed out",
|
||||
"connection error",
|
||||
"network error",
|
||||
"name or service not known",
|
||||
"nodename nor servname",
|
||||
]
|
||||
):
|
||||
return "network"
|
||||
|
||||
if any(
|
||||
marker in text
|
||||
for marker in [
|
||||
"-1021",
|
||||
"server time",
|
||||
"doesn't match server time",
|
||||
]
|
||||
):
|
||||
return "time"
|
||||
|
||||
return "generic"
|
||||
|
||||
def _log_exchange_error(
|
||||
self,
|
||||
*,
|
||||
endpoint: str,
|
||||
exc: Exception,
|
||||
symbol: str | None = None,
|
||||
extra_payload: dict | None = None,
|
||||
) -> None:
|
||||
payload = {
|
||||
"endpoint": endpoint,
|
||||
"symbol": symbol,
|
||||
"exchange_name": self.settings.exchange_name,
|
||||
"error_type": self._classify_error(exc),
|
||||
"raw_error": str(exc),
|
||||
}
|
||||
|
||||
if extra_payload:
|
||||
payload.update(extra_payload)
|
||||
|
||||
self._log_error(
|
||||
"exchange_request_error",
|
||||
str(exc),
|
||||
payload,
|
||||
)
|
||||
|
||||
def _format_exchange_time(self, raw_timestamp: object) -> str:
|
||||
if not raw_timestamp:
|
||||
return "n/a"
|
||||
|
||||
dt_utc = datetime.fromtimestamp(int(raw_timestamp) / 1000, tz=ZoneInfo("UTC"))
|
||||
dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz))
|
||||
return dt_local.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
def _source_name(self) -> str:
|
||||
return (
|
||||
"dzengi-demo-api"
|
||||
if "demo" in self.settings.exchange_base_url.lower()
|
||||
else "dzengi-api"
|
||||
)
|
||||
|
||||
def get_health(self) -> ExchangeHealth:
|
||||
if not self.settings.exchange_enabled:
|
||||
return mock_exchange_health()
|
||||
@@ -94,6 +177,10 @@ class ExchangeService:
|
||||
payload = ExchangePrivateClient().get_account_info(show_zero_balance=False)
|
||||
balances = parse_account_balances(payload)
|
||||
except Exception as exc:
|
||||
self._log_exchange_error(
|
||||
endpoint="private/account_info",
|
||||
exc=exc,
|
||||
)
|
||||
return PrivateAuthHealth(
|
||||
ok=False,
|
||||
message=f"Private API error: {exc}",
|
||||
@@ -134,33 +221,40 @@ class ExchangeService:
|
||||
raise ExchangeError(validation.message)
|
||||
|
||||
client = ExchangeRestClient()
|
||||
payload = client.get_json(
|
||||
"/api/v2/ticker/24hr",
|
||||
params={"symbol": validation.normalized_symbol},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = client.get_json(
|
||||
"/api/v2/ticker/24hr",
|
||||
params={"symbol": validation.normalized_symbol},
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log_exchange_error(
|
||||
endpoint="ticker/24hr",
|
||||
exc=exc,
|
||||
symbol=validation.normalized_symbol,
|
||||
)
|
||||
raise ExchangeError(str(exc)) from exc
|
||||
|
||||
last_raw = payload.get("lastPrice")
|
||||
if last_raw is None:
|
||||
raise ExchangeError("Field 'lastPrice' is missing in ticker response.")
|
||||
exc = ExchangeError("Field 'lastPrice' is missing in ticker response.")
|
||||
self._log_exchange_error(
|
||||
endpoint="ticker/24hr",
|
||||
exc=exc,
|
||||
symbol=validation.normalized_symbol,
|
||||
)
|
||||
raise exc
|
||||
|
||||
bid_raw = payload.get("bidPrice") or last_raw
|
||||
ask_raw = payload.get("askPrice") or last_raw
|
||||
|
||||
close_time = payload.get("closeTime") or payload.get("eventTime") or ""
|
||||
|
||||
if close_time:
|
||||
dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC"))
|
||||
dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz))
|
||||
updated_at = dt_local.strftime("%d.%m.%Y %H:%M:%S")
|
||||
else:
|
||||
updated_at = "n/a"
|
||||
|
||||
return {
|
||||
"symbol": validation.normalized_symbol,
|
||||
"last_price": float(last_raw),
|
||||
"bid_price": float(bid_raw),
|
||||
"ask_price": float(ask_raw),
|
||||
"updated_at": updated_at,
|
||||
"updated_at": self._format_exchange_time(close_time),
|
||||
}
|
||||
|
||||
def get_balance_summary(self) -> list[BalanceSummary]:
|
||||
@@ -169,25 +263,24 @@ class ExchangeService:
|
||||
|
||||
auth_health = self.get_private_auth_health()
|
||||
if not auth_health.ok:
|
||||
self._log_error(
|
||||
"balance_summary_error",
|
||||
auth_health.message,
|
||||
{
|
||||
"exchange_name": self.settings.exchange_name,
|
||||
auth_exc = ExchangeError(auth_health.message)
|
||||
self._log_exchange_error(
|
||||
endpoint="private/account_info",
|
||||
exc=auth_exc,
|
||||
extra_payload={
|
||||
"default_symbol": self.settings.default_symbol,
|
||||
},
|
||||
)
|
||||
raise ExchangeError(auth_health.message)
|
||||
raise auth_exc
|
||||
|
||||
try:
|
||||
payload = ExchangePrivateClient().get_account_info(show_zero_balance=False)
|
||||
balances = parse_account_balances(payload)
|
||||
except Exception as exc:
|
||||
self._log_error(
|
||||
"balance_summary_error",
|
||||
f"Не удалось получить баланс: {exc}",
|
||||
{
|
||||
"exchange_name": self.settings.exchange_name,
|
||||
self._log_exchange_error(
|
||||
endpoint="private/account_info",
|
||||
exc=exc,
|
||||
extra_payload={
|
||||
"default_symbol": self.settings.default_symbol,
|
||||
},
|
||||
)
|
||||
@@ -220,7 +313,15 @@ class ExchangeService:
|
||||
return []
|
||||
|
||||
client = ExchangeRestClient()
|
||||
payload = client.get_json("/api/v2/exchangeInfo")
|
||||
|
||||
try:
|
||||
payload = client.get_json("/api/v2/exchangeInfo")
|
||||
except Exception as exc:
|
||||
self._log_exchange_error(
|
||||
endpoint="exchangeInfo",
|
||||
exc=exc,
|
||||
)
|
||||
raise ExchangeError(str(exc)) from exc
|
||||
|
||||
if isinstance(payload.get("symbols"), list):
|
||||
symbols_raw = payload["symbols"]
|
||||
@@ -229,7 +330,12 @@ class ExchangeService:
|
||||
if isinstance(inner, dict) and isinstance(inner.get("symbols"), list):
|
||||
symbols_raw = inner["symbols"]
|
||||
else:
|
||||
raise ExchangeError("Field 'symbols' is missing in exchangeInfo response.")
|
||||
exc = ExchangeError("Field 'symbols' is missing in exchangeInfo response.")
|
||||
self._log_exchange_error(
|
||||
endpoint="exchangeInfo",
|
||||
exc=exc,
|
||||
)
|
||||
raise exc
|
||||
|
||||
def _safe_str(value: object, default: str = "") -> str:
|
||||
if value is None:
|
||||
@@ -394,46 +500,28 @@ class ExchangeService:
|
||||
params={"symbol": symbol},
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log_error(
|
||||
"market_price_error",
|
||||
f"Не удалось получить цену инструмента {symbol}: {exc}",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"exchange_name": self.settings.exchange_name,
|
||||
},
|
||||
self._log_exchange_error(
|
||||
endpoint="ticker/24hr",
|
||||
exc=exc,
|
||||
symbol=symbol,
|
||||
)
|
||||
raise
|
||||
raise ExchangeError(str(exc)) from exc
|
||||
|
||||
price_raw = payload.get("lastPrice")
|
||||
if price_raw is None:
|
||||
self._log_error(
|
||||
"market_price_error",
|
||||
"Field 'lastPrice' is missing in ticker response.",
|
||||
{
|
||||
"symbol": symbol,
|
||||
"exchange_name": self.settings.exchange_name,
|
||||
},
|
||||
exc = ExchangeError("Field 'lastPrice' is missing in ticker response.")
|
||||
self._log_exchange_error(
|
||||
endpoint="ticker/24hr",
|
||||
exc=exc,
|
||||
symbol=symbol,
|
||||
)
|
||||
raise ExchangeError("Field 'lastPrice' is missing in ticker response.")
|
||||
raise exc
|
||||
|
||||
close_time = payload.get("closeTime") or payload.get("eventTime") or ""
|
||||
|
||||
if close_time:
|
||||
dt_utc = datetime.fromtimestamp(int(close_time) / 1000, tz=ZoneInfo("UTC"))
|
||||
dt_local = dt_utc.astimezone(ZoneInfo(self.settings.tz))
|
||||
updated_at = dt_local.strftime("%d.%m.%Y %H:%M:%S")
|
||||
else:
|
||||
updated_at = "n/a"
|
||||
|
||||
source = (
|
||||
"dzengi-demo-api"
|
||||
if "demo" in self.settings.exchange_base_url.lower()
|
||||
else "dzengi-api"
|
||||
)
|
||||
|
||||
return TickerPrice(
|
||||
symbol=symbol,
|
||||
price=float(price_raw),
|
||||
source=source,
|
||||
updated_at=updated_at,
|
||||
source=self._source_name(),
|
||||
updated_at=self._format_exchange_time(close_time),
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
# app/src/storage/repositories/journal.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -20,10 +22,10 @@ class JournalRepository:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
'''
|
||||
"""
|
||||
INSERT INTO journal_events (level, event_type, message, payload_json)
|
||||
VALUES (%s, %s, %s, %s::jsonb)
|
||||
''',
|
||||
""",
|
||||
(
|
||||
level.upper().strip(),
|
||||
event_type.strip(),
|
||||
@@ -32,32 +34,69 @@ class JournalRepository:
|
||||
),
|
||||
)
|
||||
|
||||
def list_recent_events(self, limit: int = 10) -> list[dict[str, str]]:
|
||||
def _parse_payload(self, raw_payload: Any) -> dict[str, Any] | None:
|
||||
if raw_payload is None:
|
||||
return None
|
||||
|
||||
if isinstance(raw_payload, dict):
|
||||
return raw_payload
|
||||
|
||||
if isinstance(raw_payload, str):
|
||||
try:
|
||||
parsed = json.loads(raw_payload)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def list_recent_events(self, limit: int = 10) -> list[dict[str, Any]]:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
'''
|
||||
SELECT id, created_at, level, event_type, message
|
||||
"""
|
||||
SELECT id, created_at, level, event_type, message, payload_json
|
||||
FROM journal_events
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
''',
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
items: list[dict[str, str]] = []
|
||||
for row in rows:
|
||||
items.append(
|
||||
{
|
||||
"id": str(row[0]),
|
||||
"created_at": str(row[1]),
|
||||
"level": str(row[2]),
|
||||
"event_type": str(row[3]),
|
||||
"message": str(row[4]),
|
||||
}
|
||||
)
|
||||
return items
|
||||
return [self._row_to_dict(row) for row in rows]
|
||||
|
||||
def list_recent_with_offset(self, limit: int, offset: int) -> list[dict[str, Any]]:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, created_at, level, event_type, message, payload_json
|
||||
FROM journal_events
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(limit, offset),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [self._row_to_dict(row) for row in rows]
|
||||
|
||||
def list_export_rows(self, limit: int = 5000) -> list[dict[str, Any]]:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, created_at, level, event_type, message, payload_json
|
||||
FROM journal_events
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [self._row_to_dict(row) for row in rows]
|
||||
|
||||
def count_events(self) -> int:
|
||||
with get_connection() as connection:
|
||||
@@ -67,26 +106,35 @@ class JournalRepository:
|
||||
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
def list_recent_with_offset(self, limit: int, offset: int):
|
||||
def delete_all(self) -> int:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("DELETE FROM journal_events")
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
return int(deleted_count or 0)
|
||||
|
||||
def _row_to_dict(self, row: tuple[Any, ...]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(row[0]),
|
||||
"created_at": str(row[1]),
|
||||
"level": str(row[2]),
|
||||
"event_type": str(row[3]),
|
||||
"message": str(row[4]),
|
||||
"payload": self._parse_payload(row[5]),
|
||||
}
|
||||
|
||||
|
||||
def delete_older_than_days(self, days: int) -> int:
|
||||
with get_connection() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT created_at, level, event_type, message
|
||||
FROM journal_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
DELETE FROM journal_events
|
||||
WHERE created_at < NOW() - (%s * INTERVAL '1 day')
|
||||
""",
|
||||
(limit, offset),
|
||||
(days,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
return [
|
||||
{
|
||||
"created_at": str(r[0]),
|
||||
"level": r[1],
|
||||
"event_type": r[2],
|
||||
"message": r[3],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return deleted_count
|
||||
@@ -4,88 +4,51 @@ from __future__ import annotations
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.types import BufferedInputFile, CallbackQuery, Message
|
||||
|
||||
from src.telegram.handlers.journal_ui import (
|
||||
PAGE_SIZE,
|
||||
build_clear_confirm_keyboard,
|
||||
build_keyboard,
|
||||
render,
|
||||
render_clear_confirm,
|
||||
build_actions_keyboard,
|
||||
render_actions,
|
||||
)
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
router = Router(name="journal")
|
||||
|
||||
PAGE_SIZE = 3
|
||||
|
||||
LEVEL_ICONS = {
|
||||
"INFO": "ℹ️",
|
||||
"WARNING": "⚠️",
|
||||
"ERROR": "❌",
|
||||
"CRITICAL": "🚨",
|
||||
}
|
||||
def _user_id_from_message(message: Message) -> int | None:
|
||||
return message.from_user.id if message.from_user else None
|
||||
|
||||
|
||||
def build_keyboard(page: int, total_pages: int):
|
||||
kb = InlineKeyboardBuilder()
|
||||
|
||||
if page > 1:
|
||||
kb.button(text="⏮️", callback_data="journal:1")
|
||||
|
||||
if page > 1:
|
||||
kb.button(text="⬅️", callback_data=f"journal:{page - 1}")
|
||||
|
||||
kb.button(text=f"{page}/{total_pages}", callback_data="noop")
|
||||
|
||||
if page < total_pages:
|
||||
kb.button(text="➡️", callback_data=f"journal:{page + 1}")
|
||||
|
||||
return kb.as_markup()
|
||||
def _chat_id_from_message(message: Message) -> int | None:
|
||||
return message.chat.id if message.chat else None
|
||||
|
||||
|
||||
def render(events, page, total_pages):
|
||||
lines = ["<b>📒 Журнал</b>", "", "<b>Последние события</b>", ""]
|
||||
|
||||
for e in events:
|
||||
level = str(e.get("level", "INFO")).upper()
|
||||
icon = LEVEL_ICONS.get(level, "•")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
f"{icon} <b>{e['event_type']}</b>",
|
||||
f"• уровень: {level}",
|
||||
f"• время: {e['created_at']}",
|
||||
f"• сообщение: {e['message']}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines).rstrip()
|
||||
def _user_id_from_callback(callback: CallbackQuery) -> int | None:
|
||||
return callback.from_user.id if callback.from_user else None
|
||||
|
||||
|
||||
@router.message(F.text == "📒 Журнал")
|
||||
async def open_journal(message: Message, state: FSMContext):
|
||||
# Глобальный экран: всегда выходим из текущего FSM-сценария.
|
||||
await state.clear()
|
||||
def _chat_id_from_callback(callback: CallbackQuery) -> int | None:
|
||||
if callback.message and callback.message.chat:
|
||||
return callback.message.chat.id
|
||||
return None
|
||||
|
||||
|
||||
async def _show_journal_page(
|
||||
target_message: Message,
|
||||
*,
|
||||
page: int,
|
||||
edit_mode: bool,
|
||||
) -> None:
|
||||
service = JournalService()
|
||||
|
||||
total = service.get_total_count()
|
||||
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
|
||||
events = service.get_page(1, PAGE_SIZE)
|
||||
|
||||
text = render(events, 1, total_pages)
|
||||
kb = build_keyboard(1, total_pages)
|
||||
|
||||
await message.answer(text, reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("journal:"))
|
||||
async def paginate(callback: CallbackQuery):
|
||||
page = int(callback.data.split(":")[1])
|
||||
|
||||
service = JournalService()
|
||||
|
||||
total = service.get_total_count()
|
||||
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
|
||||
page = max(1, min(page, total_pages))
|
||||
|
||||
events = service.get_page(page, PAGE_SIZE)
|
||||
@@ -93,5 +56,200 @@ async def paginate(callback: CallbackQuery):
|
||||
text = render(events, page, total_pages)
|
||||
kb = build_keyboard(page, total_pages)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=kb)
|
||||
if edit_mode:
|
||||
await target_message.edit_text(text, reply_markup=kb)
|
||||
else:
|
||||
await target_message.answer(text, reply_markup=kb)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "journal:actions")
|
||||
async def journal_actions(callback: CallbackQuery) -> None:
|
||||
if callback.message is None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
render_actions(),
|
||||
reply_markup=build_actions_keyboard(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(F.text == "📒 Журнал")
|
||||
async def open_journal(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="journal_open_requested",
|
||||
message="Запрошено открытие журнала.",
|
||||
screen="journal",
|
||||
action="open",
|
||||
user_id=_user_id_from_message(message),
|
||||
chat_id=_chat_id_from_message(message),
|
||||
)
|
||||
|
||||
await _show_journal_page(
|
||||
message,
|
||||
page=1,
|
||||
edit_mode=False,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "journal:noop")
|
||||
async def journal_noop(callback: CallbackQuery) -> None:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "journal:export_csv")
|
||||
async def export_journal_csv(callback: CallbackQuery) -> None:
|
||||
service = JournalService()
|
||||
|
||||
try:
|
||||
data = service.export_csv()
|
||||
document = BufferedInputFile(
|
||||
data,
|
||||
filename=service.build_export_filename("csv"),
|
||||
)
|
||||
|
||||
if callback.message is not None:
|
||||
await callback.message.answer_document(document=document)
|
||||
|
||||
service.log_ui_info(
|
||||
event_type="journal_export_csv_success",
|
||||
message="Журнал экспортирован в CSV.",
|
||||
screen="journal",
|
||||
action="export_csv",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={"format": "csv"},
|
||||
)
|
||||
|
||||
await callback.answer("CSV экспортирован")
|
||||
except Exception as exc:
|
||||
service.log_ui_error(
|
||||
event_type="journal_export_csv_error",
|
||||
message="Не удалось экспортировать журнал в CSV.",
|
||||
screen="journal",
|
||||
action="export_csv",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
await callback.answer("Не удалось экспортировать CSV", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "journal:export_xlsx")
|
||||
async def export_journal_xlsx(callback: CallbackQuery) -> None:
|
||||
service = JournalService()
|
||||
|
||||
try:
|
||||
data = service.export_xlsx()
|
||||
document = BufferedInputFile(
|
||||
data,
|
||||
filename=service.build_export_filename("xlsx"),
|
||||
)
|
||||
|
||||
if callback.message is not None:
|
||||
await callback.message.answer_document(document=document)
|
||||
|
||||
service.log_ui_info(
|
||||
event_type="journal_export_xlsx_success",
|
||||
message="Журнал экспортирован в Excel.",
|
||||
screen="journal",
|
||||
action="export_xlsx",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={"format": "xlsx"},
|
||||
)
|
||||
|
||||
await callback.answer("Excel экспортирован")
|
||||
except Exception as exc:
|
||||
service.log_ui_error(
|
||||
event_type="journal_export_xlsx_error",
|
||||
message="Не удалось экспортировать журнал в Excel.",
|
||||
screen="journal",
|
||||
action="export_xlsx",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
await callback.answer("Не удалось экспортировать Excel", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "journal:clear_confirm")
|
||||
async def clear_journal_confirm(callback: CallbackQuery) -> None:
|
||||
if callback.message is None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
service = JournalService()
|
||||
total_count = service.get_total_count()
|
||||
|
||||
await callback.message.edit_text(
|
||||
render_clear_confirm(total_count=total_count),
|
||||
reply_markup=build_clear_confirm_keyboard(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "journal:clear")
|
||||
async def clear_journal(callback: CallbackQuery) -> None:
|
||||
if callback.message is None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
service = JournalService()
|
||||
deleted_count = service.clear_all()
|
||||
total_count = service.get_total_count()
|
||||
|
||||
await callback.message.edit_text(
|
||||
render_clear_confirm(
|
||||
total_count=total_count,
|
||||
deleted_count=deleted_count,
|
||||
),
|
||||
reply_markup=build_clear_confirm_keyboard(),
|
||||
)
|
||||
await callback.answer(f"Удалено: {deleted_count}")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "journal:clear_older:90")
|
||||
async def clear_journal_older_90(callback: CallbackQuery) -> None:
|
||||
if callback.message is None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
service = JournalService()
|
||||
deleted_count = service.clear_older_than_days(90)
|
||||
total_count = service.get_total_count()
|
||||
|
||||
await callback.message.edit_text(
|
||||
render_clear_confirm(
|
||||
total_count=total_count,
|
||||
deleted_count=deleted_count if deleted_count > 0 else None,
|
||||
no_old_records_days=90 if deleted_count == 0 else None,
|
||||
),
|
||||
reply_markup=build_clear_confirm_keyboard(),
|
||||
)
|
||||
await callback.answer(f"Удалено: {deleted_count}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("journal:"))
|
||||
async def paginate(callback: CallbackQuery) -> None:
|
||||
if callback.message is None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
page_raw = callback.data.split(":", 1)[1]
|
||||
|
||||
try:
|
||||
page = int(page_raw)
|
||||
except ValueError:
|
||||
await callback.answer("Неизвестное действие", show_alert=True)
|
||||
return
|
||||
|
||||
await _show_journal_page(
|
||||
callback.message,
|
||||
page=page,
|
||||
edit_mode=True,
|
||||
)
|
||||
await callback.answer()
|
||||
217
app/src/telegram/handlers/journal_ui.py
Normal file
217
app/src/telegram/handlers/journal_ui.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# app/src/telegram/handlers/journal_ui.py
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.core.config import load_settings
|
||||
|
||||
|
||||
PAGE_SIZE = 5
|
||||
|
||||
LEVEL_ICONS = {
|
||||
"INFO": "ℹ️",
|
||||
"WARNING": "⚠️",
|
||||
"ERROR": "⛔",
|
||||
"CRITICAL": "🆘",
|
||||
}
|
||||
|
||||
EVENT_TITLES = {
|
||||
"app_start": "Запуск приложения",
|
||||
"system_open_alert": "Система загружена с предупреждениями",
|
||||
"system_open_requested": "Открытие системы",
|
||||
"system_open_success": "Система загружена",
|
||||
"system_retry": "Система обновлена",
|
||||
"market_open_requested": "Открытие рынка",
|
||||
"market_open_success": "Рынок загружен",
|
||||
"market_open_error": "Ошибка открытия рынка",
|
||||
"market_retry_error": "Ошибка обновления рынка",
|
||||
"market_symbol_invalid": "Некорректный инструмент",
|
||||
"market_price_error": "Ошибка загрузки цены",
|
||||
"portfolio_open_requested": "Открытие портфеля",
|
||||
"portfolio_open_success": "Портфель загружен",
|
||||
"portfolio_open_error": "Ошибка открытия портфеля",
|
||||
"portfolio_retry_error": "Ошибка обновления портфеля",
|
||||
"portfolio_empty": "Портфель пуст",
|
||||
"portfolio_zero_balances": "Нет активов с балансом",
|
||||
"portfolio_partial_estimate": "Частичная оценка портфеля",
|
||||
"balance_summary_loaded": "Баланс загружен",
|
||||
"balance_summary_empty": "Баланс пуст",
|
||||
"balance_summary_error": "Ошибка загрузки баланса",
|
||||
"exchange_request_error": "Ошибка запроса к бирже",
|
||||
"trade_drafts_open": "Открытие списка черновиков",
|
||||
"trade_drafts_paginate": "Переключение страницы черновиков",
|
||||
"trade_draft_open_success": "Черновик открыт",
|
||||
"trade_draft_open_not_found": "Черновик не найден",
|
||||
"trade_draft_edit_start": "Начато редактирование черновика",
|
||||
"trade_draft_edit_error": "Ошибка редактирования черновика",
|
||||
"trade_draft_edit_not_found": "Черновик не найден",
|
||||
"trade_order_create_start": "Начато создание ордера",
|
||||
"trade_order_create_start_error": "Ошибка создания ордера",
|
||||
"trade_order_create_cancelled": "Создание ордера отменено",
|
||||
"trade_order_side_selected": "Выбрана сторона ордера",
|
||||
"trade_order_side_select_error": "Ошибка выбора стороны",
|
||||
"trade_order_type_selected": "Выбран тип ордера",
|
||||
"trade_order_type_select_error": "Ошибка выбора типа",
|
||||
"trade_order_quantity_selected": "Выбрано количество",
|
||||
"trade_order_quantity_select_error": "Ошибка выбора количества",
|
||||
"trade_order_quantity_manual_open": "Ручной ввод количества",
|
||||
"trade_order_quantity_manual_success": "Количество введено",
|
||||
"trade_order_quantity_manual_error": "Ошибка ввода количества",
|
||||
"trade_order_price_selected": "Выбрана цена",
|
||||
"trade_order_price_select_error": "Ошибка выбора цены",
|
||||
"trade_order_price_manual_open": "Ручной ввод цены",
|
||||
"trade_order_price_manual_success": "Цена введена",
|
||||
"trade_order_price_manual_error": "Ошибка ввода цены",
|
||||
"trade_order_confirm_success": "Черновик сохранён",
|
||||
"trade_order_confirm_error": "Ошибка сохранения",
|
||||
"trade_order_confirm_validation_error": "Ошибка проверки",
|
||||
"trade_order_confirm_state_error": "Ошибка состояния",
|
||||
"journal_cleared": "Журнал очищен",
|
||||
"journal_cleared_old": "Журнал очищен по сроку",
|
||||
"journal_export_csv_success": "Экспорт CSV",
|
||||
"journal_export_csv_error": "Ошибка экспорта CSV",
|
||||
"journal_export_xlsx_success": "Экспорт Excel",
|
||||
"journal_export_xlsx_error": "Ошибка экспорта Excel",
|
||||
"journal_open_requested": "Открытие журнала",
|
||||
}
|
||||
|
||||
TECH_TO_HUMAN_MESSAGES = {
|
||||
"invalid api key": "Неверный API Key.",
|
||||
"unauthorized": "Нет доступа к аккаунту.",
|
||||
"forbidden": "Доступ запрещён.",
|
||||
"network error": "Нет связи с биржей.",
|
||||
"timeout": "Биржа не ответила вовремя.",
|
||||
}
|
||||
|
||||
|
||||
def build_keyboard(page: int, total_pages: int) -> InlineKeyboardMarkup:
|
||||
kb = InlineKeyboardBuilder()
|
||||
|
||||
if page > 1:
|
||||
kb.button(text="⏮️", callback_data="journal:1")
|
||||
kb.button(text="⬅️", callback_data=f"journal:{page - 1}")
|
||||
|
||||
kb.button(text=f"{page}/{total_pages}", callback_data="journal:noop")
|
||||
|
||||
if page < total_pages:
|
||||
kb.button(text="➡️", callback_data=f"journal:{page + 1}")
|
||||
|
||||
kb.button(text="📤 Экспорт", callback_data="journal:actions")
|
||||
kb.button(text="🛠️ Настройки", callback_data="settings:journal")
|
||||
kb.button(text="⬅️ Назад", callback_data="system:back")
|
||||
|
||||
nav_count = 1
|
||||
if page > 1:
|
||||
nav_count += 2
|
||||
if page < total_pages:
|
||||
nav_count += 1
|
||||
|
||||
kb.adjust(nav_count, 2, 1)
|
||||
return kb.as_markup()
|
||||
|
||||
|
||||
def build_actions_keyboard() -> InlineKeyboardMarkup:
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="📄 CSV", callback_data="journal:export_csv")
|
||||
kb.button(text="📊 Excel", callback_data="journal:export_xlsx")
|
||||
kb.button(text="⬅️ Назад", callback_data="journal:1")
|
||||
kb.adjust(2, 1)
|
||||
return kb.as_markup()
|
||||
|
||||
|
||||
def render_actions() -> str:
|
||||
return (
|
||||
"<b>📤 Экспорт</b>\n\n"
|
||||
"<b>СИСТЕМА · Журнал</b>\n\n"
|
||||
"Выберите формат:"
|
||||
)
|
||||
|
||||
|
||||
def build_clear_confirm_keyboard() -> InlineKeyboardMarkup:
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="Очистить всё", callback_data="journal:clear")
|
||||
kb.button(text="Старше 90 дней", callback_data="journal:clear_older:90")
|
||||
kb.button(text="⬅️ Назад", callback_data="settings:journal")
|
||||
kb.button(text="📒 Журнал", callback_data="journal:1")
|
||||
kb.adjust(2, 2)
|
||||
return kb.as_markup()
|
||||
|
||||
|
||||
def render_clear_confirm(
|
||||
*,
|
||||
total_count: int,
|
||||
deleted_count: int | None = None,
|
||||
no_old_records_days: int | None = None,
|
||||
) -> str:
|
||||
lines = [
|
||||
"<b>⚠️ Очистить журнал</b>",
|
||||
"",
|
||||
"<b>СИСТЕМА</b> · Настройки · Журнал",
|
||||
"",
|
||||
f"📄 Записей: {total_count}",
|
||||
]
|
||||
|
||||
if deleted_count is not None:
|
||||
lines.append(f"🧹 Удалено записей: {deleted_count}")
|
||||
|
||||
if no_old_records_days is not None:
|
||||
lines.append(f"📭 Нет записей старше {no_old_records_days} дней")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize_datetime(value: str) -> str:
|
||||
try:
|
||||
settings = load_settings()
|
||||
dt = datetime.fromisoformat(value)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
dt = dt.astimezone(ZoneInfo(settings.tz))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
|
||||
def _event_title(event_type: str) -> str:
|
||||
return EVENT_TITLES.get(event_type, event_type)
|
||||
|
||||
|
||||
def _humanize_message(message: str) -> str:
|
||||
lower = message.lower()
|
||||
for k, v in TECH_TO_HUMAN_MESSAGES.items():
|
||||
if k in lower:
|
||||
return v
|
||||
return message
|
||||
|
||||
|
||||
def render(events, page, total_pages):
|
||||
lines = [
|
||||
"<b>📒 Журнал</b>",
|
||||
"",
|
||||
"<b>СИСТЕМА</b>",
|
||||
"",
|
||||
"<b>Последние события:</b>",
|
||||
"",
|
||||
]
|
||||
|
||||
if not events:
|
||||
lines.append("Событий пока нет.")
|
||||
return "\n".join(lines)
|
||||
|
||||
for event in events:
|
||||
level = str(event.get("level", "INFO")).upper()
|
||||
icon = LEVEL_ICONS.get(level, "•")
|
||||
title = _event_title(str(event.get("event_type", "")))
|
||||
created_at = _normalize_datetime(str(event.get("created_at", "")))
|
||||
message = _humanize_message(str(event.get("message", "")))
|
||||
|
||||
lines.append(f"{icon} [ <b>{level}</b> ] <b>{title}</b>")
|
||||
lines.append(f"• {created_at}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip()
|
||||
@@ -11,6 +11,7 @@ from src.integrations.exchange.exceptions import ExchangeError
|
||||
from src.integrations.exchange.service import ExchangeService
|
||||
from src.telegram.ui.common import mode_line
|
||||
from src.telegram.ui.exchange_error import (
|
||||
classify_exchange_error,
|
||||
show_callback_exchange_error,
|
||||
show_message_exchange_error,
|
||||
)
|
||||
@@ -20,42 +21,6 @@ from src.trading.journal.service import JournalService
|
||||
router = Router(name="market")
|
||||
|
||||
|
||||
def _safe_log_info(
|
||||
journal: JournalService,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
journal.log_info(event_type, message, payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _safe_log_warning(
|
||||
journal: JournalService,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
journal.log_warning(event_type, message, payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _safe_log_error(
|
||||
journal: JournalService,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
journal.log_error(event_type, message, payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _market_keyboard() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||
@@ -105,33 +70,35 @@ async def _render_market_screen(
|
||||
user_id: int | None,
|
||||
chat_id: int | None,
|
||||
edit_mode: bool,
|
||||
action: str,
|
||||
) -> None:
|
||||
service = ExchangeService()
|
||||
journal = JournalService()
|
||||
requested_symbol = service.settings.default_symbol
|
||||
|
||||
_safe_log_info(
|
||||
journal,
|
||||
"user_open_market",
|
||||
"Пользователь открыл экран рынка.",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
"symbol": requested_symbol,
|
||||
},
|
||||
journal.log_ui_info(
|
||||
event_type="market_open_requested",
|
||||
message="Запрошено открытие экрана рынка.",
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={"symbol": requested_symbol},
|
||||
)
|
||||
|
||||
validation = service.validate_symbol(requested_symbol)
|
||||
|
||||
if not validation.is_valid:
|
||||
_safe_log_warning(
|
||||
journal,
|
||||
"market_symbol_invalid",
|
||||
f"Символ не прошел проверку: {validation.message}",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
journal.log_ui_warning(
|
||||
event_type="market_symbol_invalid",
|
||||
message="Инструмент недоступен.",
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={
|
||||
"symbol": requested_symbol,
|
||||
"validation_message": validation.message,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -172,13 +139,14 @@ async def _render_market_screen(
|
||||
tick_size=tick_size,
|
||||
)
|
||||
|
||||
_safe_log_info(
|
||||
journal,
|
||||
"market_open_success",
|
||||
"Экран рынка успешно показан пользователю.",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
journal.log_ui_info(
|
||||
event_type="market_open_success",
|
||||
message="Экран рынка загружен.",
|
||||
screen="market",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={
|
||||
"symbol": ticker.symbol,
|
||||
"price": ticker.price,
|
||||
},
|
||||
@@ -203,13 +171,18 @@ async def open_market(message: Message, state: FSMContext) -> None:
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=False,
|
||||
action="open",
|
||||
)
|
||||
except ExchangeError as exc:
|
||||
_safe_log_error(
|
||||
JournalService(),
|
||||
"market_open_error",
|
||||
f"Не удалось открыть экран рынка: {exc}",
|
||||
{"user_id": user_id, "chat_id": chat_id},
|
||||
JournalService().log_ui_error(
|
||||
event_type="market_open_error",
|
||||
message="Не удалось загрузить экран рынка.",
|
||||
screen="market",
|
||||
action="open",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_message_exchange_error(
|
||||
@@ -239,14 +212,19 @@ async def retry_market(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=True,
|
||||
action="retry",
|
||||
)
|
||||
await callback.answer()
|
||||
await callback.answer()
|
||||
except ExchangeError as exc:
|
||||
_safe_log_error(
|
||||
JournalService(),
|
||||
"market_retry_error",
|
||||
f"Не удалось повторно открыть рынок: {exc}",
|
||||
{"user_id": user_id, "chat_id": chat_id},
|
||||
JournalService().log_ui_error(
|
||||
event_type="market_retry_error",
|
||||
message="Не удалось обновить экран рынка.",
|
||||
screen="market",
|
||||
action="retry",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_callback_exchange_error(
|
||||
|
||||
@@ -19,6 +19,7 @@ from src.telegram.ui.currency_ui import (
|
||||
is_zero_balance,
|
||||
)
|
||||
from src.telegram.ui.exchange_error import (
|
||||
classify_exchange_error,
|
||||
show_callback_exchange_error,
|
||||
show_message_exchange_error,
|
||||
)
|
||||
@@ -52,42 +53,6 @@ def _portfolio_warning_keyboard() -> InlineKeyboardMarkup:
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def _safe_log_info(
|
||||
journal: JournalService,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
journal.log_info(event_type, message, payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _safe_log_warning(
|
||||
journal: JournalService,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
journal.log_warning(event_type, message, payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _safe_log_error(
|
||||
journal: JournalService,
|
||||
event_type: str,
|
||||
message: str,
|
||||
payload: dict | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
journal.log_error(event_type, message, payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def sort_balances(items: list[BalanceSummary]) -> list[BalanceSummary]:
|
||||
def sort_key(item: BalanceSummary) -> tuple[int, str]:
|
||||
currency = item.currency.upper()
|
||||
@@ -103,32 +68,31 @@ async def _render_portfolio_screen(
|
||||
user_id: int | None,
|
||||
chat_id: int | None,
|
||||
edit_mode: bool,
|
||||
action: str,
|
||||
) -> None:
|
||||
service = AccountsService()
|
||||
exchange_service = ExchangeService()
|
||||
journal = JournalService()
|
||||
|
||||
_safe_log_info(
|
||||
journal,
|
||||
"user_open_portfolio",
|
||||
"Пользователь открыл экран портфеля.",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
},
|
||||
journal.log_ui_info(
|
||||
event_type="portfolio_open_requested",
|
||||
message="Запрошено открытие экрана портфеля.",
|
||||
screen="portfolio",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
balances = service.get_live_balance_summary()
|
||||
|
||||
if not balances:
|
||||
_safe_log_warning(
|
||||
journal,
|
||||
"portfolio_empty",
|
||||
"Портфель открыт, но баланс пуст.",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
},
|
||||
journal.log_ui_warning(
|
||||
event_type="portfolio_empty",
|
||||
message="Нет данных по балансу.",
|
||||
screen="portfolio",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
text = (
|
||||
@@ -147,15 +111,14 @@ async def _render_portfolio_screen(
|
||||
visible_balances = sort_balances(visible_balances)
|
||||
|
||||
if not visible_balances:
|
||||
_safe_log_warning(
|
||||
journal,
|
||||
"portfolio_zero_balances",
|
||||
"Портфель открыт, но все балансы нулевые.",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
"assets_count": len(balances),
|
||||
},
|
||||
journal.log_ui_warning(
|
||||
event_type="portfolio_zero_balances",
|
||||
message="Нет активов с балансом.",
|
||||
screen="portfolio",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={"assets_count": len(balances)},
|
||||
)
|
||||
|
||||
text = (
|
||||
@@ -211,30 +174,25 @@ async def _render_portfolio_screen(
|
||||
has_partial_data = len(missing_estimate_assets) > 0
|
||||
|
||||
if missing_estimate_assets:
|
||||
lines.extend(
|
||||
[
|
||||
"🟡 <b>Данные загружены частично</b>",
|
||||
]
|
||||
)
|
||||
lines.append("🟡 <b>Данные загружены частично</b>")
|
||||
|
||||
if has_any_estimate:
|
||||
lines.append(f"ОЦЕНКА · ~${format_usd_amount(total_estimated_usd)}")
|
||||
|
||||
if missing_estimate_assets:
|
||||
lines.append(
|
||||
f"Нет оценки: {', '.join(missing_estimate_assets)}"
|
||||
)
|
||||
lines.append(f"Нет оценки: {', '.join(missing_estimate_assets)}")
|
||||
|
||||
for block in asset_blocks:
|
||||
lines.extend(block)
|
||||
|
||||
_safe_log_info(
|
||||
journal,
|
||||
"portfolio_open_success",
|
||||
"Портфель успешно показан пользователю.",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
journal.log_ui_info(
|
||||
event_type="portfolio_open_success",
|
||||
message="Портфель загружен.",
|
||||
screen="portfolio",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={
|
||||
"assets_count": len(visible_balances),
|
||||
"estimated_usd": round(total_estimated_usd, 2) if has_any_estimate else None,
|
||||
"missing_estimate_assets": missing_estimate_assets,
|
||||
@@ -242,25 +200,23 @@ async def _render_portfolio_screen(
|
||||
)
|
||||
|
||||
if missing_estimate_assets:
|
||||
_safe_log_warning(
|
||||
journal,
|
||||
"portfolio_partial_estimate",
|
||||
"Портфель показан частично: не для всех активов доступна USD-оценка.",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
"missing_estimate_assets": missing_estimate_assets,
|
||||
journal.log_ui_warning(
|
||||
event_type="portfolio_partial_estimate",
|
||||
message="Портфель загружен частично.",
|
||||
screen="portfolio",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={
|
||||
"assets_count": len(visible_balances),
|
||||
"estimated_assets": len(visible_balances) - len(missing_estimate_assets),
|
||||
"failed_assets": missing_estimate_assets,
|
||||
},
|
||||
)
|
||||
|
||||
if has_partial_data:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
now_line(),
|
||||
]
|
||||
)
|
||||
|
||||
lines.extend(["", now_line()])
|
||||
|
||||
text = "\n".join(lines).rstrip()
|
||||
|
||||
reply_markup = (
|
||||
@@ -288,18 +244,20 @@ async def open_portfolio(message: Message, state: FSMContext) -> None:
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=False,
|
||||
action="open",
|
||||
)
|
||||
except ExchangeError as exc:
|
||||
journal = JournalService()
|
||||
_safe_log_error(
|
||||
journal,
|
||||
"portfolio_open_error",
|
||||
f"Не удалось открыть портфель: {exc}",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
},
|
||||
JournalService().log_ui_error(
|
||||
event_type="portfolio_open_error",
|
||||
message="Не удалось загрузить портфель.",
|
||||
screen="portfolio",
|
||||
action="open",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_message_exchange_error(
|
||||
message,
|
||||
title="<b>💼 Портфель</b>",
|
||||
@@ -327,19 +285,21 @@ async def retry_portfolio(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
edit_mode=True,
|
||||
action="retry",
|
||||
)
|
||||
await callback.answer()
|
||||
except ExchangeError as exc:
|
||||
journal = JournalService()
|
||||
_safe_log_error(
|
||||
journal,
|
||||
"portfolio_retry_error",
|
||||
f"Не удалось повторно открыть портфель: {exc}",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"chat_id": chat_id,
|
||||
},
|
||||
JournalService().log_ui_error(
|
||||
event_type="portfolio_retry_error",
|
||||
message="Не удалось обновить портфель.",
|
||||
screen="portfolio",
|
||||
action="retry",
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title="<b>💼 Портфель</b>",
|
||||
|
||||
@@ -8,6 +8,7 @@ from aiogram.types import CallbackQuery, InlineKeyboardMarkup, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from src.core.system_status import build_system_text, get_system_snapshot, has_system_alerts
|
||||
from src.trading.journal.service import JournalService
|
||||
|
||||
|
||||
router = Router(name="system")
|
||||
@@ -15,16 +16,20 @@ router = Router(name="system")
|
||||
|
||||
def _system_keyboard() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||
builder.adjust(1)
|
||||
builder.button(text="📒 Журнал", callback_data="journal:1")
|
||||
builder.button(text="🛠️ Настройки", callback_data="system:management")
|
||||
builder.button(text="ℹ️ Информация", callback_data="system:about")
|
||||
builder.adjust(2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def _system_alert_keyboard() -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🔁 Обновить", callback_data="system:retry")
|
||||
builder.button(text="🏠 К торговле", callback_data="trade:home")
|
||||
builder.adjust(1, 1)
|
||||
builder.button(text="📒 Журнал", callback_data="journal:1")
|
||||
builder.button(text="🛠️ Настройки", callback_data="system:management")
|
||||
builder.button(text="ℹ️ Информация", callback_data="system:about")
|
||||
builder.adjust(1, 2, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -32,10 +37,56 @@ async def _render_system_screen(
|
||||
target_message: Message,
|
||||
*,
|
||||
edit_mode: bool,
|
||||
user_id: int | None,
|
||||
chat_id: int | None,
|
||||
action: str,
|
||||
) -> None:
|
||||
journal = JournalService()
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="system_open_requested",
|
||||
message="Запрошено открытие экрана системы.",
|
||||
screen="system",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
snapshot = get_system_snapshot()
|
||||
is_alert = has_system_alerts(snapshot)
|
||||
|
||||
if is_alert:
|
||||
journal.log_ui_warning(
|
||||
event_type="system_open_alert",
|
||||
message="Система загружена с предупреждениями.",
|
||||
screen="system",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={
|
||||
"has_alerts": True,
|
||||
"components": [
|
||||
{
|
||||
"name": component.name,
|
||||
"state": component.state,
|
||||
"details": component.details,
|
||||
}
|
||||
for component in snapshot.components
|
||||
if component.state != "🟢"
|
||||
],
|
||||
},
|
||||
)
|
||||
else:
|
||||
journal.log_ui_info(
|
||||
event_type="system_open_success",
|
||||
message="Экран системы загружен.",
|
||||
screen="system",
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
payload={"has_alerts": False},
|
||||
)
|
||||
|
||||
text = build_system_text(include_updated_at=is_alert)
|
||||
reply_markup = _system_alert_keyboard() if is_alert else _system_keyboard()
|
||||
|
||||
@@ -45,12 +96,19 @@ async def _render_system_screen(
|
||||
await target_message.answer(text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.message(F.text.in_({"⚙️ Система", "⚙ Система"}))
|
||||
@router.message(F.text.in_({"🖥️ Система", "⚙️ Система", "⚙ Система"}))
|
||||
async def open_system(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
|
||||
user_id = message.from_user.id if message.from_user else None
|
||||
chat_id = message.chat.id if message.chat else None
|
||||
|
||||
await _render_system_screen(
|
||||
message,
|
||||
edit_mode=False,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
action="open",
|
||||
)
|
||||
|
||||
|
||||
@@ -62,8 +120,100 @@ async def retry_system(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id if callback.from_user else None
|
||||
chat_id = callback.message.chat.id if callback.message.chat else None
|
||||
|
||||
await _render_system_screen(
|
||||
callback.message,
|
||||
edit_mode=True,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
action="retry",
|
||||
)
|
||||
await callback.answer()
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "system:management")
|
||||
async def open_system_management(callback: CallbackQuery) -> None:
|
||||
if callback.message is None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
text = (
|
||||
"<b>🛠️ Настройки</b>\n\n"
|
||||
"<b>СИСТЕМА</b>\n\n"
|
||||
"Выберите раздел:"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🤖 Автоторговля", callback_data="settings:auto")
|
||||
builder.button(text="📊 Торговля", callback_data="settings:trade")
|
||||
builder.button(text="🌍 Общие", callback_data="settings:general")
|
||||
builder.button(text="📒 Журнал", callback_data="settings:journal")
|
||||
builder.button(text="⬅️ Назад", callback_data="system:back")
|
||||
|
||||
builder.adjust(2, 2, 1)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "settings:journal")
|
||||
async def open_journal_settings(callback: CallbackQuery) -> None:
|
||||
if callback.message is None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
service = JournalService()
|
||||
total = service.get_total_count()
|
||||
|
||||
text = (
|
||||
"<b>📒 Журнал</b>\n\n"
|
||||
"<b>СИСТЕМА</b> · Настройки\n\n"
|
||||
f"📄 Записей: {total}\n"
|
||||
"📦 Лимит: —\n"
|
||||
"⏳ Хранение: —\n"
|
||||
"🗄 Архив: —\n\n"
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="🗑 Очистка", callback_data="journal:clear_confirm")
|
||||
builder.button(text="🗄 Архив", callback_data="settings:journal_archive")
|
||||
builder.button(text="📦 Лимит", callback_data="settings:journal_limit")
|
||||
builder.button(text="⏳ Хранение", callback_data="settings:journal_retention")
|
||||
builder.button(text="⬅️ Назад", callback_data="system:control")
|
||||
builder.button(text="📒 Журнал", callback_data="journal:1")
|
||||
builder.adjust(2, 2, 2)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "system:back")
|
||||
async def back_to_system(callback: CallbackQuery) -> None:
|
||||
if callback.message is None:
|
||||
await callback.answer("Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id if callback.from_user else None
|
||||
chat_id = callback.message.chat.id if callback.message.chat else None
|
||||
|
||||
await _render_system_screen(
|
||||
callback.message,
|
||||
edit_mode=True,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
action="back",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "system:about")
|
||||
async def open_system_about(callback: CallbackQuery) -> None:
|
||||
await callback.answer("О продукте скоро появится")
|
||||
@@ -7,11 +7,6 @@ from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from src.telegram.ui.exchange_error import (
|
||||
show_callback_exchange_error,
|
||||
show_message_exchange_error,
|
||||
)
|
||||
|
||||
from src.integrations.exchange.exceptions import ExchangeError
|
||||
from src.telegram.handlers.trade.new_order_core import router
|
||||
from src.telegram.handlers.trade.new_order_ui import (
|
||||
@@ -42,6 +37,12 @@ from src.telegram.handlers.trade.new_order_ui import (
|
||||
mode_line,
|
||||
show_recent_drafts,
|
||||
)
|
||||
from src.telegram.ui.exchange_error import (
|
||||
classify_exchange_error,
|
||||
show_callback_exchange_error,
|
||||
show_message_exchange_error,
|
||||
)
|
||||
from src.trading.journal.service import JournalService
|
||||
from src.trading.orders.service import OrderDraftsService
|
||||
from src.trading.orders.states import NewOrderDraftStates
|
||||
|
||||
@@ -61,6 +62,24 @@ MAIN_MENU_BUTTONS = {
|
||||
}
|
||||
|
||||
|
||||
def _user_id_from_message(message: Message) -> int | None:
|
||||
return message.from_user.id if message.from_user else None
|
||||
|
||||
|
||||
def _chat_id_from_message(message: Message) -> int | None:
|
||||
return message.chat.id if message.chat else None
|
||||
|
||||
|
||||
def _user_id_from_callback(callback: CallbackQuery) -> int | None:
|
||||
return callback.from_user.id if callback.from_user else None
|
||||
|
||||
|
||||
def _chat_id_from_callback(callback: CallbackQuery) -> int | None:
|
||||
if callback.message and callback.message.chat:
|
||||
return callback.message.chat.id
|
||||
return None
|
||||
|
||||
|
||||
@router.callback_query(F.data == "drafts:noop")
|
||||
async def drafts_noop(callback: CallbackQuery) -> None:
|
||||
await callback.answer()
|
||||
@@ -75,7 +94,17 @@ async def paginate_drafts(callback: CallbackQuery) -> None:
|
||||
|
||||
page = int(value)
|
||||
await callback.answer()
|
||||
|
||||
if callback.message is not None:
|
||||
JournalService().log_ui_info(
|
||||
event_type="trade_drafts_paginate",
|
||||
message="Открыта страница черновиков.",
|
||||
screen="trade",
|
||||
action="drafts_paginate",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={"page": page},
|
||||
)
|
||||
await show_recent_drafts(callback.message, edit_mode=True, page=page)
|
||||
|
||||
|
||||
@@ -87,9 +116,28 @@ async def open_draft(callback: CallbackQuery) -> None:
|
||||
|
||||
draft = service.get_draft_by_id(draft_id)
|
||||
if not draft:
|
||||
JournalService().log_ui_warning(
|
||||
event_type="trade_draft_open_not_found",
|
||||
message="Черновик не найден.",
|
||||
screen="trade",
|
||||
action="draft_open",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={"draft_id": draft_id, "page": page},
|
||||
)
|
||||
await callback.answer("Черновик не найден", show_alert=True)
|
||||
return
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="trade_draft_open_success",
|
||||
message="Черновик открыт.",
|
||||
screen="trade",
|
||||
action="draft_open",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={"draft_id": draft_id, "page": page},
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
_render_draft_detail(draft),
|
||||
reply_markup=_draft_detail_keyboard(draft_id, page),
|
||||
@@ -100,11 +148,22 @@ async def open_draft(callback: CallbackQuery) -> None:
|
||||
@router.callback_query(F.data.startswith("draft_edit:"))
|
||||
async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
service = OrderDraftsService()
|
||||
journal = JournalService()
|
||||
|
||||
_, draft_id, page_raw = callback.data.split(":", 2)
|
||||
page = int(page_raw)
|
||||
|
||||
draft = service.get_draft_by_id(draft_id)
|
||||
if not draft:
|
||||
journal.log_ui_warning(
|
||||
event_type="trade_draft_edit_not_found",
|
||||
message="Черновик не найден.",
|
||||
screen="trade",
|
||||
action="draft_edit",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={"draft_id": draft_id, "page": page},
|
||||
)
|
||||
await callback.answer("Черновик не найден", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -150,8 +209,34 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
drafts_page=page,
|
||||
),
|
||||
)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_draft_edit_requested",
|
||||
message="Запрошено редактирование черновика.",
|
||||
screen="trade",
|
||||
action="draft_edit",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={
|
||||
"draft_id": draft_id,
|
||||
"page": page,
|
||||
"side": side,
|
||||
"order_type": order_type,
|
||||
},
|
||||
)
|
||||
await callback.answer()
|
||||
except (ExchangeError, ValueError) as exc:
|
||||
journal.log_ui_error(
|
||||
event_type="trade_draft_edit_error",
|
||||
message="Не удалось открыть редактирование черновика.",
|
||||
screen="trade",
|
||||
action="draft_edit",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
error_type=classify_exchange_error(exc) if isinstance(exc, ExchangeError) else "generic",
|
||||
raw_error=str(exc),
|
||||
payload={"draft_id": draft_id, "page": page},
|
||||
)
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title=_screen_title(is_edit_mode=True),
|
||||
@@ -160,17 +245,35 @@ async def edit_draft(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
back_callback_data=f"draft_open:{draft_id}:{page}",
|
||||
drafts_page=page,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("draft_delete:"))
|
||||
async def delete_draft_stub(callback: CallbackQuery) -> None:
|
||||
JournalService().log_ui_info(
|
||||
event_type="trade_draft_delete_requested",
|
||||
message="Запрошено удаление черновика.",
|
||||
screen="trade",
|
||||
action="draft_delete",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={"callback_data": callback.data},
|
||||
)
|
||||
await callback.answer("Удаление скоро появится")
|
||||
|
||||
|
||||
@router.message(Command("cancel_order"))
|
||||
async def cancel_order_builder(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
|
||||
JournalService().log_ui_info(
|
||||
event_type="trade_order_create_cancelled",
|
||||
message="Создание черновика ордера отменено.",
|
||||
screen="trade",
|
||||
action="order_cancel",
|
||||
user_id=_user_id_from_message(message),
|
||||
chat_id=_chat_id_from_message(message),
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
"<b>📊 Торговля — Новый ордер</b>\n"
|
||||
f"{mode_line()}"
|
||||
@@ -189,6 +292,7 @@ async def start_new_order_draft(
|
||||
await state.set_state(NewOrderDraftStates.waiting_side)
|
||||
|
||||
service = OrderDraftsService()
|
||||
journal = JournalService()
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(side="BUY", order_type="MARKET")
|
||||
@@ -200,11 +304,31 @@ async def start_new_order_draft(
|
||||
"Шаг 1/4. Выбери сторону"
|
||||
)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_create_requested",
|
||||
message="Запрошено создание черновика ордера.",
|
||||
screen="trade",
|
||||
action="order_create",
|
||||
user_id=_user_id_from_message(message),
|
||||
chat_id=_chat_id_from_message(message),
|
||||
payload={"symbol": context.symbol},
|
||||
)
|
||||
|
||||
if edit_mode:
|
||||
await message.edit_text(text, reply_markup=_side_keyboard())
|
||||
else:
|
||||
await message.answer(text, reply_markup=_side_keyboard())
|
||||
except ExchangeError as exc:
|
||||
journal.log_ui_error(
|
||||
event_type="trade_order_create_error",
|
||||
message="Не удалось открыть создание черновика ордера.",
|
||||
screen="trade",
|
||||
action="order_create",
|
||||
user_id=_user_id_from_message(message),
|
||||
chat_id=_chat_id_from_message(message),
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
)
|
||||
await show_message_exchange_error(
|
||||
message,
|
||||
title="<b>📊 Торговля — Новый ордер</b>",
|
||||
@@ -227,6 +351,7 @@ async def process_order_side_callback(
|
||||
|
||||
path = _render_order_path(side=side)
|
||||
service = OrderDraftsService()
|
||||
journal = JournalService()
|
||||
|
||||
try:
|
||||
context = service.get_entry_context(side=side, order_type="MARKET")
|
||||
@@ -239,9 +364,30 @@ async def process_order_side_callback(
|
||||
"Шаг 2/4. Выбери тип ордера"
|
||||
)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_side_selected",
|
||||
message="Выбрана сторона ордера.",
|
||||
screen="trade",
|
||||
action="order_side",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={"side": side, "symbol": context.symbol},
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=_type_keyboard())
|
||||
await callback.answer()
|
||||
except ExchangeError as exc:
|
||||
journal.log_ui_error(
|
||||
event_type="trade_order_side_error",
|
||||
message="Не удалось обработать выбор стороны ордера.",
|
||||
screen="trade",
|
||||
action="order_side",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
payload={"side": side},
|
||||
)
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title="<b>📊 Торговля — Новый ордер</b>",
|
||||
@@ -270,6 +416,7 @@ async def process_order_type_callback(
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
service = OrderDraftsService()
|
||||
journal = JournalService()
|
||||
order_type = callback.data.split(":", 1)[1]
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -290,6 +437,21 @@ async def process_order_type_callback(
|
||||
base_currency=context.base_currency,
|
||||
)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_type_selected",
|
||||
message="Пользователь выбрал тип ордера.",
|
||||
screen="trade",
|
||||
action="order_select_type",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={
|
||||
"side": side,
|
||||
"order_type": order_type,
|
||||
"symbol": context.symbol,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
_render_quantity_step_screen(
|
||||
title=_screen_title(is_edit_mode),
|
||||
@@ -307,6 +469,21 @@ async def process_order_type_callback(
|
||||
)
|
||||
await callback.answer()
|
||||
except ExchangeError as exc:
|
||||
journal.log_ui_error(
|
||||
event_type="trade_order_type_select_error",
|
||||
message="Не удалось обработать выбор типа ордера.",
|
||||
screen="trade",
|
||||
action="order_select_type",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
payload={
|
||||
"side": side,
|
||||
"order_type": order_type,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title=_screen_title(is_edit_mode),
|
||||
@@ -327,6 +504,10 @@ async def process_order_type_text(message: Message) -> None:
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
NewOrderDraftStates.waiting_quantity,
|
||||
F.data.startswith("order_qty:"),
|
||||
)
|
||||
@router.callback_query(
|
||||
NewOrderDraftStates.waiting_quantity,
|
||||
F.data.startswith("order_qty:"),
|
||||
@@ -336,6 +517,7 @@ async def process_quantity_callback(
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
service = OrderDraftsService()
|
||||
journal = JournalService()
|
||||
value = callback.data.split(":", 1)[1]
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -360,6 +542,20 @@ async def process_quantity_callback(
|
||||
base_currency=context.base_currency,
|
||||
)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_quantity_manual_open",
|
||||
message="Открыт ручной ввод количества.",
|
||||
screen="trade",
|
||||
action="order_quantity_manual",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={
|
||||
"side": side,
|
||||
"order_type": order_type,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
_render_manual_quantity_screen(
|
||||
title=title,
|
||||
@@ -388,6 +584,21 @@ async def process_quantity_callback(
|
||||
|
||||
await state.update_data(quantity=quantity)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_quantity_selected",
|
||||
message="Выбрано количество ордера.",
|
||||
screen="trade",
|
||||
action="order_quantity",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={
|
||||
"side": side,
|
||||
"order_type": order_type,
|
||||
"quantity": quantity,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
|
||||
if order_type == "LIMIT":
|
||||
path = _render_order_path(
|
||||
side=side,
|
||||
@@ -458,6 +669,22 @@ async def process_quantity_callback(
|
||||
)
|
||||
await callback.answer()
|
||||
except ExchangeError as exc:
|
||||
journal.log_ui_error(
|
||||
event_type="trade_order_quantity_error",
|
||||
message="Не удалось обработать выбор количества ордера.",
|
||||
screen="trade",
|
||||
action="order_quantity",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
payload={
|
||||
"side": side,
|
||||
"order_type": order_type,
|
||||
"value": value,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title=title,
|
||||
@@ -473,6 +700,7 @@ async def process_quantity_callback(
|
||||
)
|
||||
async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
||||
service = OrderDraftsService()
|
||||
journal = JournalService()
|
||||
raw_quantity = message.text or ""
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -504,17 +732,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
||||
)
|
||||
|
||||
if quantity is None:
|
||||
path = _render_order_path(
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
base_currency=context.base_currency,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
_render_quantity_inline_error(
|
||||
title=title,
|
||||
symbol=context.symbol,
|
||||
order_path=path,
|
||||
order_path=_render_order_path(
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
base_currency=context.base_currency,
|
||||
),
|
||||
errors=["Количество должно быть числом больше нуля."],
|
||||
help_text=help_text,
|
||||
),
|
||||
@@ -529,17 +755,15 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
||||
price=None,
|
||||
)
|
||||
if quantity_errors:
|
||||
path = _render_order_path(
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
base_currency=context.base_currency,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
_render_quantity_inline_error(
|
||||
title=title,
|
||||
symbol=context.symbol,
|
||||
order_path=path,
|
||||
order_path=_render_order_path(
|
||||
side=side,
|
||||
order_type=order_type,
|
||||
base_currency=context.base_currency,
|
||||
),
|
||||
errors=quantity_errors,
|
||||
help_text=help_text,
|
||||
),
|
||||
@@ -549,6 +773,21 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
||||
|
||||
await state.update_data(quantity=quantity)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_quantity_manual_success",
|
||||
message="Количество ордера введено вручную.",
|
||||
screen="trade",
|
||||
action="order_quantity_manual",
|
||||
user_id=_user_id_from_message(message),
|
||||
chat_id=_chat_id_from_message(message),
|
||||
payload={
|
||||
"side": side,
|
||||
"order_type": order_type,
|
||||
"quantity": quantity,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
|
||||
if order_type == "LIMIT":
|
||||
path = _render_order_path(
|
||||
side=side,
|
||||
@@ -615,6 +854,22 @@ async def process_order_quantity(message: Message, state: FSMContext) -> None:
|
||||
reply_markup=_confirm_keyboard(drafts_page=drafts_page),
|
||||
)
|
||||
except ExchangeError as exc:
|
||||
journal.log_ui_error(
|
||||
event_type="trade_order_quantity_manual_error",
|
||||
message="Не удалось обработать ручной ввод количества.",
|
||||
screen="trade",
|
||||
action="order_quantity_manual",
|
||||
user_id=_user_id_from_message(message),
|
||||
chat_id=_chat_id_from_message(message),
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
payload={
|
||||
"side": side,
|
||||
"order_type": order_type,
|
||||
"raw_quantity": raw_quantity,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
await show_message_exchange_error(
|
||||
message,
|
||||
title=title,
|
||||
@@ -632,6 +887,7 @@ async def process_price_callback(
|
||||
state: FSMContext,
|
||||
) -> None:
|
||||
service = OrderDraftsService()
|
||||
journal = JournalService()
|
||||
value = callback.data.split(":", 1)[1]
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -657,6 +913,16 @@ async def process_price_callback(
|
||||
base_currency=context.base_currency,
|
||||
)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_price_manual_open",
|
||||
message="Открыт ручной ввод цены.",
|
||||
screen="trade",
|
||||
action="order_price_manual",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={"is_edit_mode": is_edit_mode},
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
_render_manual_price_screen(
|
||||
title=title,
|
||||
@@ -700,6 +966,19 @@ async def process_price_callback(
|
||||
|
||||
await state.set_state(NewOrderDraftStates.waiting_confirm)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_price_selected",
|
||||
message="Выбрана цена ордера.",
|
||||
screen="trade",
|
||||
action="order_price",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={
|
||||
"price": price,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
_render_confirm(
|
||||
symbol=draft.symbol,
|
||||
@@ -716,6 +995,20 @@ async def process_price_callback(
|
||||
)
|
||||
await callback.answer()
|
||||
except ExchangeError as exc:
|
||||
journal.log_ui_error(
|
||||
event_type="trade_order_price_error",
|
||||
message="Не удалось обработать выбор цены ордера.",
|
||||
screen="trade",
|
||||
action="order_price",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
payload={
|
||||
"value": value,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title=title,
|
||||
@@ -731,6 +1024,7 @@ async def process_price_callback(
|
||||
)
|
||||
async def process_order_price(message: Message, state: FSMContext) -> None:
|
||||
service = OrderDraftsService()
|
||||
journal = JournalService()
|
||||
raw_price = message.text or ""
|
||||
price = service.normalize_price(raw_price)
|
||||
|
||||
@@ -754,18 +1048,16 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
||||
)
|
||||
|
||||
if price is None:
|
||||
path = _render_order_path(
|
||||
side=data.get("side"),
|
||||
order_type=data.get("order_type"),
|
||||
quantity=data.get("quantity"),
|
||||
base_currency=context.base_currency,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
_render_price_inline_error(
|
||||
title=title,
|
||||
symbol=context.symbol,
|
||||
order_path=path,
|
||||
order_path=_render_order_path(
|
||||
side=data.get("side"),
|
||||
order_type=data.get("order_type"),
|
||||
quantity=data.get("quantity"),
|
||||
base_currency=context.base_currency,
|
||||
),
|
||||
errors=["Цена должна быть числом больше нуля."],
|
||||
help_text=help_text,
|
||||
),
|
||||
@@ -782,18 +1074,16 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
||||
|
||||
validation = service.validate_draft(draft)
|
||||
if not validation.is_valid:
|
||||
path = _render_order_path(
|
||||
side=draft.side,
|
||||
order_type=draft.order_type,
|
||||
quantity=draft.quantity,
|
||||
base_currency=context.base_currency,
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
_render_price_inline_error(
|
||||
title=title,
|
||||
symbol=context.symbol,
|
||||
order_path=path,
|
||||
order_path=_render_order_path(
|
||||
side=draft.side,
|
||||
order_type=draft.order_type,
|
||||
quantity=draft.quantity,
|
||||
base_currency=context.base_currency,
|
||||
),
|
||||
errors=validation.errors,
|
||||
help_text=help_text,
|
||||
),
|
||||
@@ -817,6 +1107,19 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
||||
)
|
||||
await state.set_state(NewOrderDraftStates.waiting_confirm)
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_price_manual_success",
|
||||
message="Цена ордера введена вручную.",
|
||||
screen="trade",
|
||||
action="order_price_manual",
|
||||
user_id=_user_id_from_message(message),
|
||||
chat_id=_chat_id_from_message(message),
|
||||
payload={
|
||||
"price": price,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
_render_confirm(
|
||||
symbol=draft.symbol,
|
||||
@@ -832,6 +1135,20 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
||||
reply_markup=_confirm_keyboard(drafts_page=drafts_page),
|
||||
)
|
||||
except ExchangeError as exc:
|
||||
journal.log_ui_error(
|
||||
event_type="trade_order_price_manual_error",
|
||||
message="Не удалось обработать ручной ввод цены.",
|
||||
screen="trade",
|
||||
action="order_price_manual",
|
||||
user_id=_user_id_from_message(message),
|
||||
chat_id=_chat_id_from_message(message),
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
payload={
|
||||
"raw_price": raw_price,
|
||||
"is_edit_mode": is_edit_mode,
|
||||
},
|
||||
)
|
||||
await show_message_exchange_error(
|
||||
message,
|
||||
title=title,
|
||||
@@ -842,17 +1159,34 @@ async def process_order_price(message: Message, state: FSMContext) -> None:
|
||||
|
||||
@router.message(Command("drafts"))
|
||||
async def drafts_command(message: Message) -> None:
|
||||
JournalService().log_ui_info(
|
||||
event_type="trade_drafts_open_requested",
|
||||
message="Запрошено открытие списка черновиков.",
|
||||
screen="trade",
|
||||
action="drafts_open",
|
||||
user_id=_user_id_from_message(message),
|
||||
chat_id=_chat_id_from_message(message),
|
||||
)
|
||||
await show_recent_drafts(message, edit_mode=False, page=1)
|
||||
|
||||
|
||||
@router.callback_query(NewOrderDraftStates.waiting_confirm, F.data == "order_confirm")
|
||||
async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
service = OrderDraftsService()
|
||||
journal = JournalService()
|
||||
data = await state.get_data()
|
||||
|
||||
raw = data.get("confirm_draft")
|
||||
if not raw:
|
||||
await state.clear()
|
||||
journal.log_ui_warning(
|
||||
event_type="trade_order_confirm_state_error",
|
||||
message="Состояние подтверждения черновика не найдено.",
|
||||
screen="trade",
|
||||
action="order_confirm",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
)
|
||||
await callback.answer("Ошибка состояния", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -874,6 +1208,21 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
edit_page = data.get("draft_edit_page")
|
||||
await state.clear()
|
||||
errors = [item.strip() for item in str(exc).split(";") if item.strip()]
|
||||
|
||||
journal.log_ui_warning(
|
||||
event_type="trade_order_confirm_validation_error",
|
||||
message="Черновик не прошёл проверку при сохранении.",
|
||||
screen="trade",
|
||||
action="order_confirm",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
raw_error=str(exc),
|
||||
payload={
|
||||
"errors": errors,
|
||||
"edit_page": edit_page,
|
||||
},
|
||||
)
|
||||
|
||||
reply_markup = (
|
||||
_drafts_back_keyboard(int(edit_page))
|
||||
if edit_page
|
||||
@@ -887,6 +1236,19 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
return
|
||||
except ExchangeError as exc:
|
||||
await state.clear()
|
||||
|
||||
journal.log_ui_error(
|
||||
event_type="trade_order_confirm_error",
|
||||
message="Не удалось сохранить черновик ордера.",
|
||||
screen="trade",
|
||||
action="order_confirm",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
error_type=classify_exchange_error(exc),
|
||||
raw_error=str(exc),
|
||||
payload={"draft_edit_page": data.get("draft_edit_page")},
|
||||
)
|
||||
|
||||
await show_callback_exchange_error(
|
||||
callback,
|
||||
title="<b>📊 Торговля — Подтверждение черновика</b>",
|
||||
@@ -899,6 +1261,23 @@ async def confirm_order(callback: CallbackQuery, state: FSMContext) -> None:
|
||||
edit_page = data.get("draft_edit_page")
|
||||
await state.clear()
|
||||
|
||||
journal.log_ui_info(
|
||||
event_type="trade_order_confirm_success",
|
||||
message="Черновик ордера сохранён.",
|
||||
screen="trade",
|
||||
action="order_confirm",
|
||||
user_id=_user_id_from_callback(callback),
|
||||
chat_id=_chat_id_from_callback(callback),
|
||||
payload={
|
||||
"symbol": draft.symbol,
|
||||
"side": draft.side,
|
||||
"order_type": draft.order_type,
|
||||
"quantity": draft.quantity,
|
||||
"price": draft.price,
|
||||
"is_edit_mode": bool(edit_page),
|
||||
},
|
||||
)
|
||||
|
||||
reply_markup = (
|
||||
_drafts_back_keyboard(int(edit_page))
|
||||
if edit_page
|
||||
|
||||
@@ -7,19 +7,17 @@ def build_main_menu_keyboard() -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="🏠 Главная"),
|
||||
KeyboardButton(text="📈 Рынок"),
|
||||
KeyboardButton(text="💼 Портфель"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🤖 Автоторговля"),
|
||||
KeyboardButton(text="📊 Торговля"),
|
||||
KeyboardButton(text="🤖 Авто"),
|
||||
KeyboardButton(text="📒 Журнал"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="⚙️ Система"),
|
||||
KeyboardButton(text="💼 Портфель"),
|
||||
KeyboardButton(text="📈 Рынок"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🖥️ Система"),
|
||||
],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
input_field_placeholder="Выбери раздел...",
|
||||
)
|
||||
)
|
||||
@@ -14,6 +14,36 @@ def mode_line() -> str:
|
||||
return f"🔸 <b>{label}</b>\n\n"
|
||||
|
||||
|
||||
def breadcrumb_line(*items: str) -> str:
|
||||
if not items:
|
||||
return ""
|
||||
|
||||
first = items[0].upper()
|
||||
rest = " · ".join(items[1:])
|
||||
|
||||
if rest:
|
||||
return f"<b>{first}</b> · {rest}\n\n"
|
||||
|
||||
return f"<b>{first}</b>\n\n"
|
||||
|
||||
|
||||
def screen_header(
|
||||
*,
|
||||
title: str,
|
||||
path: list[str] | None = None,
|
||||
show_mode: bool = False,
|
||||
) -> str:
|
||||
parts: list[str] = [f"<b>{title}</b>"]
|
||||
|
||||
if show_mode:
|
||||
parts.append(f"🔸 <b>{get_runtime_mode_label()}</b>")
|
||||
|
||||
if path:
|
||||
parts.append(f"<b>{' → '.join(path).upper()}</b>")
|
||||
|
||||
return "\n".join(parts) + "\n\n"
|
||||
|
||||
|
||||
def now_line() -> str:
|
||||
settings = load_settings()
|
||||
tz_name = settings.tz or "UTC"
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
|
||||
from src.core.config import load_settings
|
||||
from src.telegram.ui.common import mode_line, now_line
|
||||
|
||||
|
||||
@@ -19,7 +16,16 @@ class ExchangeErrorView:
|
||||
details: str
|
||||
|
||||
|
||||
def _classify_exchange_error(exc: Exception) -> str:
|
||||
DEFAULT_NETWORK_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
|
||||
DEFAULT_AUTH_DETAILS = "Не удалось выполнить запрос к аккаунту.\nПроверь API ключи."
|
||||
DEFAULT_TIME_DETAILS = (
|
||||
"Не удалось выполнить запрос к бирже.\n"
|
||||
"Проверь синхронизацию времени и обнови экран."
|
||||
)
|
||||
DEFAULT_GENERIC_DETAILS = "Не удалось получить данные с биржи.\nОбнови экран."
|
||||
|
||||
|
||||
def classify_exchange_error(exc: Exception) -> str:
|
||||
text = str(exc).lower()
|
||||
|
||||
network_markers = [
|
||||
@@ -64,7 +70,7 @@ def _build_exchange_error_view(
|
||||
time_details: str | None = None,
|
||||
generic_details: str | None = None,
|
||||
) -> ExchangeErrorView:
|
||||
error_type = _classify_exchange_error(exc)
|
||||
error_type = classify_exchange_error(exc)
|
||||
|
||||
if error_type == "network":
|
||||
return ExchangeErrorView(
|
||||
@@ -81,18 +87,12 @@ def _build_exchange_error_view(
|
||||
if error_type == "time":
|
||||
return ExchangeErrorView(
|
||||
headline="🔴 Ошибка времени",
|
||||
details=(
|
||||
time_details
|
||||
or "Не удалось выполнить запрос к бирже.\nОбнови экран."
|
||||
),
|
||||
details=time_details or DEFAULT_TIME_DETAILS,
|
||||
)
|
||||
|
||||
return ExchangeErrorView(
|
||||
headline="🔴 Ошибка биржи",
|
||||
details=(
|
||||
generic_details
|
||||
or "Не удалось получить данные с биржи.\nОбнови экран."
|
||||
),
|
||||
details=generic_details or DEFAULT_GENERIC_DETAILS,
|
||||
)
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ def render_exchange_error(
|
||||
*,
|
||||
title: str,
|
||||
exc: Exception,
|
||||
network_details: str,
|
||||
auth_details: str,
|
||||
network_details: str = DEFAULT_NETWORK_DETAILS,
|
||||
auth_details: str = DEFAULT_AUTH_DETAILS,
|
||||
time_details: str | None = None,
|
||||
generic_details: str | None = None,
|
||||
) -> str:
|
||||
@@ -178,8 +178,8 @@ async def show_callback_exchange_error(
|
||||
*,
|
||||
title: str,
|
||||
exc: Exception,
|
||||
network_details: str,
|
||||
auth_details: str,
|
||||
network_details: str = DEFAULT_NETWORK_DETAILS,
|
||||
auth_details: str = DEFAULT_AUTH_DETAILS,
|
||||
time_details: str | None = None,
|
||||
generic_details: str | None = None,
|
||||
retry_callback_data: str | None = None,
|
||||
@@ -205,10 +205,7 @@ async def show_callback_exchange_error(
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=markup,
|
||||
)
|
||||
await callback.message.edit_text(text, reply_markup=markup)
|
||||
await callback.answer()
|
||||
except TelegramBadRequest as tg_exc:
|
||||
if "message is not modified" in str(tg_exc).lower():
|
||||
@@ -222,8 +219,8 @@ async def show_message_exchange_error(
|
||||
*,
|
||||
title: str,
|
||||
exc: Exception,
|
||||
network_details: str,
|
||||
auth_details: str,
|
||||
network_details: str = DEFAULT_NETWORK_DETAILS,
|
||||
auth_details: str = DEFAULT_AUTH_DETAILS,
|
||||
time_details: str | None = None,
|
||||
generic_details: str | None = None,
|
||||
retry_callback_data: str | None = None,
|
||||
|
||||
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
|
||||
BIN
docs/Archive.zip
Normal file
BIN
docs/Archive.zip
Normal file
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
# 0014 — System Navigation and Journal Settings
|
||||
|
||||
## Решение
|
||||
Вынести журнал и системные настройки в отдельный раздел “Система” с многоуровневой навигацией.
|
||||
|
||||
## Причины
|
||||
- разгрузить главное меню
|
||||
- разделить operational UI и settings UI
|
||||
- упростить масштабирование новых системных экранов
|
||||
|
||||
## Последствия
|
||||
- появляется единый navigation pattern
|
||||
- журнал становится отдельным subsystem
|
||||
- проще расширять настройки
|
||||
@@ -68,10 +68,18 @@
|
||||
|
||||
---
|
||||
|
||||
## Stage 06 — Automation
|
||||
⏳ авто-режим
|
||||
⏳ планировщик
|
||||
⏳ фоновые задачи
|
||||
## Stage 06 — UI / Journal / Navigation
|
||||
|
||||
### 06.1
|
||||
✔ journal management UI
|
||||
✔ export CSV/XLSX
|
||||
✔ system navigation redesign
|
||||
|
||||
### 06.2
|
||||
⏳ archive / backup
|
||||
|
||||
### 06.3
|
||||
⏳ retention / limits
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Journal Management UI and System Navigation
|
||||
|
||||
## Что сделано
|
||||
|
||||
### 1. Полная переработка раздела Система
|
||||
- 🤖 Автоторговля
|
||||
- 📊 Торговля
|
||||
- 💼 Портфель
|
||||
- 📈 Рынок
|
||||
- 🖥️ Система
|
||||
|
||||
### 2. Новый navigation pattern
|
||||
СИСТЕМА · Настройки · Журнал
|
||||
|
||||
### 3. Экран 📒 Журнал
|
||||
- последние события
|
||||
- экспорт
|
||||
- настройки
|
||||
|
||||
### 4. Экран экспорта журнала
|
||||
CSV / Excel
|
||||
|
||||
### 5. Экспорт CSV/XLSX
|
||||
journal_{mode}_{level}_{timestamp}.{ext}
|
||||
|
||||
### 6. Metadata block в export
|
||||
- дата экспорта
|
||||
- аккаунт
|
||||
- уровень журнала
|
||||
- период
|
||||
|
||||
### 7. Экран настроек журнала
|
||||
- 📄 Записей
|
||||
- 📦 Лимит
|
||||
- ⏳ Хранение
|
||||
- 🗄 Архив
|
||||
|
||||
### 8. Очистка журнала
|
||||
- Очистить всё
|
||||
- Старше 90 дней
|
||||
|
||||
### 9. Journal UI refactor
|
||||
telegram/handlers/journal_ui.py
|
||||
|
||||
### 10. Logging integration
|
||||
|
||||
## Commit
|
||||
git commit -m "Stage 06.1 - journal management UI, export and system menu redesign"
|
||||
Reference in New Issue
Block a user