Удаление сценариев

This commit is contained in:
2026-02-02 22:57:27 +03:00
parent 73c6986645
commit d0445d4480
6 changed files with 305 additions and 10 deletions

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@ TASK.md
.env
data/
data_partial/
bot.db

View File

@@ -61,6 +61,8 @@ uv run main.py
## Администрирование
Бот управляется единственным администратором, чей Telegram логин указывается в переменной окружения `ADMIN_LOGIN`. Только у администратора доступна команда `/admin`, переводящая пользовательскую сессию в состояние **ADMIN**. Команда доступна из любого другого состояния. В **ADMIN** доступна кнопка "Вернуться в пользовательский режим", переводящая пользовательскую сессию в то состояние, из которого она была переведена в **ADMIN**. После перехода режим администратора выводится сообщение с информацией о текущем состоянии датасета: количество полностью озвученных сценариев, общее количество сценариев, количество полностью озвученных дорожек, общее количество дорожек, количество уникальных дикторов, количество озвученных реплик, общее количество реплик, количество уникальных пользователей бота и т. п.
Бот управляется единственным администратором, чей Telegram логин указывается в переменной окружения `ADMIN_LOGIN`. Только у администратора доступна команда `/admin`, переводящая пользовательскую сессию в состояние **ADMIN**. Команда доступна из любого другого состояния. В **ADMIN** доступна кнопка "Вернуться в пользовательский режим", переводящая пользовательскую сессию в то состояние, из которого она была переведена в **ADMIN**. Исключением является случай, когда админ озвучивал дорожку из сценария, который только что был удалён. После перехода режим администратора выводится сообщение с информацией о текущем состоянии датасета: количество полностью озвученных сценариев, общее количество сценариев, количество полностью озвученных дорожек, общее количество дорожек, количество уникальных дикторов, количество озвученных реплик, общее количество реплик, количество уникальных пользователей бота и т. п.
В **ADMIN** администратор может отправить боту `json`-файл с новым сценарием. Это переводит пользовательскую сессию в состояние **ADMIN_UPLOAD_CONFIRM**, если файл корректен, иначе выводится сообщение об ошибке в формате файла. В **ADMIN_UPLOAD_CONFIRM** выводится сообщение с предложением добавить сценарий, а также дополнительная информация о сценарии: количество дорожек, количество реплик. Две кнопки: "Да, добавить" и "Отмена". Обе кнопки возвращают администратора в состояние **ADMIN**.
В **ADMIN** доступна кнопка "Удалить сценарий". По нажатию выводится список всех сценариев в виде inline-кнопок, а также кнопка "Отмена". Выбор сценария переводит в состояние **ADMIN_DELETE_CONFIRM**. В этом состоянии выводится информация о сценарии: количество дорожек, количество реплик, количество записей. Две кнопки: "Да, удалить" и "Отмена". При удалении удаляются записи из базы данных и файлы из `data/` и `data_partial/`, а все пользователи, которые озвучивали этот сценарий, переводятся в **FIRST_REPLICA** с выводом уведомления сообщения о сохранении дорожки, если ещё есть сценарии не озвученные этим диктором, иначе в **NO_MORE_SCENARIOS**. Обе кнопки возвращают в **ADMIN**.

24
main.py
View File

@@ -14,16 +14,21 @@ from src.handlers import (
handle_admin_document,
handle_ask_replica_number,
handle_cancel_ask_number,
handle_cancel_delete,
handle_cancel_delete_list,
handle_cancel_restart,
handle_cancel_upload,
handle_confirm_delete,
handle_confirm_restart,
handle_confirm_upload,
handle_delete_scenario_list,
handle_exit_admin,
handle_replica_number_input,
handle_rerecord_last,
handle_rerecord_previous,
handle_restart_track,
handle_save_track,
handle_select_scenario_delete,
handle_unexpected_text,
handle_voice_message,
start_command,
@@ -75,6 +80,25 @@ def main() -> None:
CallbackQueryHandler(handle_cancel_upload, pattern="^cancel_upload$")
)
# Delete scenario handlers
app.add_handler(
CallbackQueryHandler(
handle_delete_scenario_list, pattern="^delete_scenario_list$"
)
)
app.add_handler(
CallbackQueryHandler(handle_select_scenario_delete, pattern="^delete_scenario:")
)
app.add_handler(
CallbackQueryHandler(handle_confirm_delete, pattern="^confirm_delete$")
)
app.add_handler(
CallbackQueryHandler(handle_cancel_delete, pattern="^cancel_delete$")
)
app.add_handler(
CallbackQueryHandler(handle_cancel_delete_list, pattern="^cancel_delete_list$")
)
# Message handlers
app.add_handler(MessageHandler(filters.VOICE, handle_voice_message))
app.add_handler(MessageHandler(filters.Document.ALL, handle_admin_document))

View File

@@ -22,6 +22,7 @@ class UserState(Enum):
REPEAT_REPLICA = "repeat_replica"
ADMIN = "admin"
ADMIN_UPLOAD_CONFIRM = "admin_upload_confirm"
ADMIN_DELETE_CONFIRM = "admin_delete_confirm"
@dataclass
@@ -484,20 +485,69 @@ def get_stats() -> dict:
"SELECT COUNT(*) FROM recordings"
).fetchone()[0]
# Количество полностью озвученных дорожек (в data/)
# Это вычисляется по файловой системе, здесь примерная оценка
# Количество полностью озвученных дорожек
stats["completed_tracks"] = conn.execute("""
SELECT COUNT(*) FROM (
SELECT user_id, scenario_id, speaker_id, COUNT(*) as cnt
SELECT r.user_id, r.scenario_id, rep.speaker_id, COUNT(*) as cnt
FROM recordings r
JOIN replicas rep ON r.scenario_id = rep.scenario_id
AND r.replica_index = rep.replica_index
GROUP BY user_id, scenario_id, speaker_id
GROUP BY r.user_id, r.scenario_id, rep.speaker_id
HAVING cnt = (
SELECT COUNT(*) FROM replicas
WHERE scenario_id = r.scenario_id AND speaker_id = rep.speaker_id
SELECT COUNT(*) FROM replicas rp
WHERE rp.scenario_id = r.scenario_id
AND rp.speaker_id = rep.speaker_id
)
)
""").fetchone()[0]
return stats
def get_scenario_stats(scenario_id: str) -> dict:
"""Получает статистику конкретного сценария."""
with get_connection() as conn:
stats = {}
# Количество реплик
stats["total_replicas"] = conn.execute(
"SELECT COUNT(*) FROM replicas WHERE scenario_id = ?", (scenario_id,)
).fetchone()[0]
# Количество дорожек
stats["total_tracks"] = conn.execute(
"SELECT COUNT(DISTINCT speaker_id) FROM replicas WHERE scenario_id = ?",
(scenario_id,),
).fetchone()[0]
# Количество записей
stats["total_recordings"] = conn.execute(
"SELECT COUNT(*) FROM recordings WHERE scenario_id = ?", (scenario_id,)
).fetchone()[0]
return stats
def get_users_with_scenario(scenario_id: str) -> list[tuple[int, int]]:
"""Получает пользователей, озвучивающих сценарий."""
with get_connection() as conn:
cursor = conn.execute(
"""
SELECT DISTINCT u.id, u.telegram_id
FROM user_sessions us
JOIN users u ON us.user_id = u.id
WHERE us.scenario_id = ?
""",
(scenario_id,),
)
return [(row["id"], row["telegram_id"]) for row in cursor.fetchall()]
def delete_scenario_data(scenario_id: str) -> None:
"""Удаляет сценарий и все связанные данные из БД."""
with get_connection() as conn:
conn.execute("DELETE FROM recordings WHERE scenario_id = ?", (scenario_id,))
conn.execute("DELETE FROM replicas WHERE scenario_id = ?", (scenario_id,))
conn.execute("DELETE FROM scenarios WHERE id = ?", (scenario_id,))
conn.commit()
logger.info(f"Deleted scenario {scenario_id} from database")

View File

@@ -6,14 +6,18 @@ from src.database import (
User,
UserSession,
UserState,
delete_scenario_data,
delete_user_recordings_for_scenario,
get_all_scenarios,
get_connection,
get_or_create_user,
get_replicas_for_track,
get_scenario,
get_scenario_stats,
get_stats,
get_user_session,
get_users_in_state,
get_users_with_scenario,
upsert_user_session,
)
from src.decorators import answer_callback, require_state, with_user_and_session
@@ -135,11 +139,16 @@ def get_ask_replica_number_keyboard() -> InlineKeyboardMarkup:
def get_admin_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
"🗑 Удалить сценарий", callback_data="delete_scenario_list"
)
],
[
InlineKeyboardButton(
"👤 Вернуться в пользовательский режим", callback_data="exit_admin"
)
]
],
]
)
@@ -153,6 +162,28 @@ def get_admin_upload_confirm_keyboard() -> InlineKeyboardMarkup:
)
def get_admin_delete_list_keyboard() -> InlineKeyboardMarkup:
"""Клавиатура со списком сценариев для удаления."""
scenarios = get_all_scenarios()
buttons = [
[InlineKeyboardButton(f"📄 {s.id}", callback_data=f"delete_scenario:{s.id}")]
for s in scenarios
]
buttons.append(
[InlineKeyboardButton("❌ Отмена", callback_data="cancel_delete_list")]
)
return InlineKeyboardMarkup(buttons)
def get_admin_delete_confirm_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[InlineKeyboardButton("🗑 Да, удалить", callback_data="confirm_delete")],
[InlineKeyboardButton("❌ Отмена", callback_data="cancel_delete")],
]
)
# === Вспомогательные функции ===
@@ -580,6 +611,171 @@ async def handle_cancel_upload(
upsert_user_session(session)
# === Delete scenario handlers ===
@answer_callback
@with_user_and_session
@require_state(UserState.ADMIN)
async def handle_delete_scenario_list(
update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession
) -> None:
"""Показывает список сценариев для удаления."""
query = update.callback_query
scenarios = get_all_scenarios()
if not scenarios:
await query.edit_message_text(
"📭 Нет сценариев для удаления.\n\n" + format_admin_stats(),
reply_markup=get_admin_keyboard(),
)
return
try:
await query.edit_message_text(
"🗑 Выберите сценарий для удаления:",
reply_markup=get_admin_delete_list_keyboard(),
)
except Exception:
pass # Сообщение уже такое же
session.last_bot_message_id = query.message.message_id
upsert_user_session(session)
@answer_callback
@with_user_and_session
@require_state(UserState.ADMIN)
async def handle_select_scenario_delete(
update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession
) -> None:
"""Обработчик выбора сценария для удаления."""
query = update.callback_query
scenario_id = query.data.replace("delete_scenario:", "")
stats = get_scenario_stats(scenario_id)
context.user_data["pending_delete_scenario"] = scenario_id
text = f"""🗑 Удаление сценария: {scenario_id}
📊 Статистика:
- Дорожек: {stats["total_tracks"]}
- Реплик: {stats["total_replicas"]}
- Записей: {stats["total_recordings"]}
⚠️ Это действие необратимо!
Будут удалены все записи из БД и файлы из data/ и data_partial/.
Пользователи, озвучивающие этот сценарий, будут перенаправлены."""
session.state = UserState.ADMIN_DELETE_CONFIRM
await query.edit_message_text(
text, reply_markup=get_admin_delete_confirm_keyboard()
)
session.last_bot_message_id = query.message.message_id
upsert_user_session(session)
@answer_callback
@with_user_and_session
@require_state(UserState.ADMIN_DELETE_CONFIRM)
async def handle_confirm_delete(
update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession
) -> None:
"""Подтверждение удаления сценария."""
query = update.callback_query
scenario_id = context.user_data.get("pending_delete_scenario")
if not scenario_id:
await query.edit_message_text("❌ Данные потеряны. Попробуйте снова.")
session.state = UserState.ADMIN
upsert_user_session(session)
return
from src.scenarios import delete_scenario_files
# Получаем пользователей, которые озвучивают этот сценарий
affected_users = get_users_with_scenario(scenario_id)
# Удаляем файлы и данные
deleted_files = delete_scenario_files(scenario_id)
delete_scenario_data(scenario_id)
del context.user_data["pending_delete_scenario"]
# Уведомляем и перенаправляем пользователей
for affected_user_id, telegram_id in affected_users:
if affected_user_id == user.id:
continue # Админа обработаем отдельно
try:
affected_session = get_user_session(affected_user_id)
if affected_session:
track = find_available_track(affected_user_id)
if track:
new_scenario_id, speaker_id = track
affected_session.state = UserState.FIRST_REPLICA
affected_session.scenario_id = new_scenario_id
affected_session.speaker_id = speaker_id
affected_session.replica_index = 0
msg = (
" Сценарий, который вы озвучивали, был удалён. Начинаем новый!"
)
else:
affected_session.state = UserState.NO_MORE_SCENARIOS
affected_session.scenario_id = None
affected_session.speaker_id = None
affected_session.replica_index = None
msg = (
" Сценарий, который вы озвучивали, был удалён.\n\n"
+ NO_MORE_SCENARIOS_TEXT
)
upsert_user_session(affected_session)
await context.bot.send_message(telegram_id, msg)
except Exception:
pass
session.state = UserState.ADMIN
await query.edit_message_text(
f"✅ Сценарий {scenario_id} удалён! (файлов: {deleted_files})\n\n"
+ format_admin_stats(),
reply_markup=get_admin_keyboard(),
)
session.last_bot_message_id = query.message.message_id
upsert_user_session(session)
logger.info(f"Scenario {scenario_id} deleted, affected: {len(affected_users)}")
@answer_callback
@with_user_and_session
@require_state(UserState.ADMIN_DELETE_CONFIRM)
async def handle_cancel_delete(
update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession
) -> None:
"""Отмена удаления сценария."""
query = update.callback_query
context.user_data.pop("pending_delete_scenario", None)
session.state = UserState.ADMIN
await query.edit_message_text(
format_admin_stats(), reply_markup=get_admin_keyboard()
)
session.last_bot_message_id = query.message.message_id
upsert_user_session(session)
@answer_callback
@with_user_and_session
@require_state(UserState.ADMIN)
async def handle_cancel_delete_list(
update: Update, context: ContextTypes.DEFAULT_TYPE, user: User, session: UserSession
) -> None:
"""Отмена выбора сценария для удаления."""
query = update.callback_query
await query.edit_message_text(
format_admin_stats(), reply_markup=get_admin_keyboard()
)
session.last_bot_message_id = query.message.message_id
upsert_user_session(session)
# === Message handlers ===

View File

@@ -250,3 +250,25 @@ def delete_partial_track(user_id: int, scenario_id: str, speaker_id: int) -> Non
f"Удалено {deleted_count} частичных записей для дорожки "
f"{scenario_id}/{speaker_id} (user_id={user_id})"
)
def delete_scenario_files(scenario_id: str) -> int:
"""Удаляет все файлы сценария из data/ и data_partial/. Возвращает число файлов."""
deleted_count = 0
for base_dir in [DATA_DIR, DATA_PARTIAL_DIR]:
scenario_dir = base_dir / scenario_id
if scenario_dir.exists():
for file in scenario_dir.glob("*.wav"):
file.unlink()
deleted_count += 1
# Удаляем пустую папку
try:
scenario_dir.rmdir()
except OSError:
pass # Папка не пуста
if deleted_count > 0:
logger.info(f"Удалено {deleted_count} файлов для сценария {scenario_id}")
return deleted_count