Удаление сценариев
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,4 +12,5 @@ wheels/
|
||||
TASK.md
|
||||
.env
|
||||
data/
|
||||
data_partial/
|
||||
data_partial/
|
||||
bot.db
|
||||
@@ -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** администратор может отправить боту `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
24
main.py
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
198
src/handlers.py
198
src/handlers.py
@@ -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 ===
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user