import logging
import os
import random
import sqlite3 # Импортируем sqlite3
from datetime import datetime, timedelta
from io import BytesIO

import matplotlib.pyplot as plt
import pandas as pd
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from dotenv import load_dotenv
from telegram import (
Update, InlineKeyboardButton, InlineKeyboardMarkup,
ReplyKeyboardMarkup, KeyboardButton
)
from telegram.ext import (
ApplicationBuilder, CommandHandler, ContextTypes,
CallbackQueryHandler, MessageHandler, filters, ConversationHandler
)

Загрузка переменных окружения из .env файла
load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN")

Настройка логирования
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.DEBUG, # Уровень DEBUG для детального логирования
handlers=[
logging.FileHandler("attendance_bot.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(name)

Подключение к базе данных
conn = sqlite3.connect('attendance_bot.db', check_same_thread=False)
cursor = conn.cursor()

Создание таблиц, если они не существуют
cursor.execute('''
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
duty_count INTEGER DEFAULT 0
)
''')

cursor.execute('''
CREATE TABLE IF NOT EXISTS attendance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
student_id INTEGER,
status TEXT,
FOREIGN KEY(student_id) REFERENCES students(id)
)
''')

Таблица администраторов с ролями
cursor.execute('''
CREATE TABLE IF NOT EXISTS admins (
user_id INTEGER PRIMARY KEY,
role TEXT NOT NULL DEFAULT 'user' -- Роли: main_admin, admin, junior_admin, user
)
''')
conn.commit()

Проверка наличия столбца 'role' в таблице 'admins' и добавление, если отсутствует
cursor.execute("PRAGMA table_info(admins)")
columns = [info[1] for info in cursor.fetchall()]
if 'role' not in columns:
cursor.execute("ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'user'")
conn.commit()
logger.info("Добавлен столбец 'role' в таблицу 'admins'.")
else:
logger.info("Столбец 'role' уже существует в таблице 'admins'.")

Ваш Telegram ID для начального администратора (замените на ваш фактический Telegram ID)
INITIAL_ADMIN_USER_ID = 1525524847 # Замените на ваш Telegram ID

Добавление начального администратора с ролью main_admin
cursor.execute("INSERT OR IGNORE INTO admins (user_id, role) VALUES (?, ?)", (INITIAL_ADMIN_USER_ID, 'main_admin'))
conn.commit()

Определение состояний для ConversationHandler
ADDING_STUDENT = 1
ADDING_STUDENTS_LIST = 2
DELETING_STUDENT = 3
ADDING_ADMIN = 4
REMOVING_ADMIN = 5
SELECT_REPORT_TYPE = 6
SELECT_DUTY_HISTORY_STUDENT = 7

Инициализация планировщика
scheduler = AsyncIOScheduler()

Форматирование имени: "Фамилия И. О."
def format_name(full_name):
parts = full_name.strip().split()
if len(parts) < 2:
return full_name # Если формат некорректен, возвращаем как есть
surname = parts[0]
initials = ' '.join([f"{name[0]}." for name in parts[1:]])
return f"{surname} {initials}"

Декоратор для проверки роли
def role_required(required_roles):
def decorator(func):
async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
cursor.execute("SELECT role FROM admins WHERE user_id = ?", (user_id,))
result = cursor.fetchone()
if not result or result[0] not in required_roles:
await update.message.reply_text("У вас нет доступа к этой команде.")
logger.warning(f"Пользователь с ID {user_id} попытался использовать команду без необходимых прав.")
# Отправка уведомления main_admin о попытке несанкционированного доступа
cursor.execute("SELECT user_id FROM admins WHERE role = 'main_admin'")
main_admins = cursor.fetchall()
for admin in main_admins:
try:
if admin[0] != user_id:
await context.bot.send_message(
chat_id=admin[0],
text=f"⚠️ Пользователь с ID {user_id} попытался использовать команду без необходимых прав."
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление main_admin с ID {admin[0]}: {e}")
return
return await func(update, context)
return wrapped
return decorator

Кастомная клавиатура для главного меню с учетом роли
def main_menu_keyboard(role='user'):
keyboard = []
if role in ['main_admin', 'admin']:
keyboard = [
[KeyboardButton("➕ Добавить студента"), KeyboardButton("➕➕ Добавить нескольких студентов")],
[KeyboardButton("❌ Удалить студента"), KeyboardButton("📅 Отметить посещаемость")],
[KeyboardButton("🛠 Назначить дежурство"), KeyboardButton("📊 Статистика")],
[KeyboardButton("📈 Генерация отчетов"), KeyboardButton("📊 Визуализация данных")],
[KeyboardButton("📜 История дежурств")],
[KeyboardButton("🔐 Добавить администратора"), KeyboardButton("🔒 Удалить администратора")],
[KeyboardButton("🔄 Сбросить отметки"), KeyboardButton("🕹 Отмена")],
]
elif role == 'junior_admin':
keyboard = [
[KeyboardButton("📅 Отметить посещаемость")],
[KeyboardButton("🕹 Отмена")],
]
else: # user
keyboard = [
[KeyboardButton("🕹 Отмена")],
]
return ReplyKeyboardMarkup(keyboard, resize_keyboard=True)

Функция для получения роли пользователя
def get_user_role(user_id):
cursor.execute("SELECT role FROM admins WHERE user_id = ?", (user_id,))
result = cursor.fetchone()
return result[0] if result else 'user'

Обработчик неизвестных сообщений
async def unknown(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
role = get_user_role(user_id)
await update.message.reply_text(
"Команда не распознана. Пожалуйста, выберите действие из меню ниже:",
reply_markup=main_menu_keyboard(role)
)
logger.warning(f"Пользователь {user_id} отправил неизвестную команду или сообщение.")

Команда /start
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
role = get_user_role(user_id)


await update.message.reply_text(
    "Привет! Я помогу вам вести посещаемость и распределять дежурства.\n\nВыберите действие из меню ниже:",
    reply_markup=main_menu_keyboard(role)
)
logger.info(f"Пользователь {user_id} начал взаимодействие с ботом. Роль: {role}.")
Команда /cancel (Отмена операций)
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
role = get_user_role(user_id)


await update.message.reply_text("Текущая операция отменена.", reply_markup=main_menu_keyboard(role))
logger.info(f"Пользователь {user_id} отменил текущую операцию.")
return ConversationHandler.END
Команда /add_student (Добавить одного студента)
@role_required(['admin', 'main_admin'])
async def add_student(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
await update.message.reply_text(
"Введите имя студента (Фамилия Имя Отчество):",
reply_markup=ReplyKeyboardMarkup([["Отмена"]], resize_keyboard=True)
)
logger.info(f"Пользователь {user_id} начал добавление студента.")
return ADDING_STUDENT

Обработка имени одного студента
@role_required(['admin', 'main_admin'])
async def add_student_name(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
name = update.message.text.strip()
if name.lower() == "отмена":
await cancel(update, context)
return


if not name:
    await update.message.reply_text("Имя не может быть пустым. Попробуйте снова:")
    logger.warning(f"Пользователь {user_id} ввёл пустое имя студента.")
    return ADDING_STUDENT
try:
    cursor.execute("INSERT INTO students (name) VALUES (?)", (name,))
    conn.commit()
    await update.message.reply_text(
        f"Студент '{format_name(name)}' добавлен.",
        reply_markup=main_menu_keyboard(role=get_user_role(user_id))
    )
    logger.info(f"Студент '{format_name(name)}' добавлен пользователем {user_id}.")
except sqlite3.IntegrityError:
    await update.message.reply_text(
        f"Студент '{format_name(name)}' уже существует.",
        reply_markup=main_menu_keyboard(role=get_user_role(user_id))
    )
    logger.warning(f"Пользователь {user_id} попытался добавить уже существующего студента '{name}'.")
return ConversationHandler.END
Команда /add_students (Массовое добавление студентов)
@role_required(['admin', 'main_admin'])
async def add_students(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
await update.message.reply_text(
"Введите список студентов, каждый на новой строке (Фамилия Имя Отчество):",
reply_markup=ReplyKeyboardMarkup([["Отмена"]], resize_keyboard=True)
)
logger.info(f"Пользователь {user_id} начал массовое добавление студентов.")
return ADDING_STUDENTS_LIST

Обработка списка студентов
@role_required(['admin', 'main_admin'])
async def add_students_list(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
text = update.message.text.strip()
if text.lower() == "отмена":
await cancel(update, context)
return


names = text.split('\n')
added = []
skipped = []
for name in names:
    formatted_name = format_name(name)
    if name:
        try:
            cursor.execute("INSERT INTO students (name) VALUES (?)", (name,))
            conn.commit()
            added.append(formatted_name)
            logger.info(f"Студент '{formatted_name}' добавлен пользователем {user_id}.")
        except sqlite3.IntegrityError:
            skipped.append(formatted_name)
            logger.warning(f"Пользователь {user_id} попытался добавить уже существующего студента '{formatted_name}'.")
response = ""
if added:
    response += "Добавлены следующие студенты:\n" + "\n".join(added) + "\n\n"
if skipped:
    response += "Следующие студенты уже существуют и были пропущены:\n" + "\n".join(skipped)
await update.message.reply_text(response, reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
return ConversationHandler.END
Команда /delete_student
@role_required(['admin', 'main_admin'])
async def delete_student(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
cursor.execute("SELECT id, name FROM students ORDER BY name")
students = cursor.fetchall()
if not students:
await update.message.reply_text("Список студентов пуст.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info(f"Пользователь {user_id} попытался удалить студента, но список пуст.")
return ConversationHandler.END


keyboard = []
for student in students:
    formatted_name = format_name(student[1])
    keyboard.append([InlineKeyboardButton(formatted_name, callback_data=f"delete_{student[0]}")])

reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text("Выберите студента для удаления:", reply_markup=reply_markup)
logger.info(f"Пользователь {user_id} выбрал операцию удаления студента.")
return DELETING_STUDENT
Обработка удаления студента
@role_required(['admin', 'main_admin'])
async def handle_delete_student(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


data = query.data.split("_")
if len(data) != 2 or data[0] != "delete":
    await query.edit_message_text("Некорректные данные.")
    logger.error(f"Пользователь {user_id} прислал некорректные данные для удаления студента: {query.data}")
    return ConversationHandler.END

try:
    student_id = int(data[1])
except ValueError:
    await query.edit_message_text("Некорректный ID студента.")
    logger.error(f"Пользователь {user_id} прислал некорректный ID студента: {data[1]}")
    return ConversationHandler.END

student = cursor.execute("SELECT name FROM students WHERE id = ?", (student_id,)).fetchone()
if student:
    cursor.execute("DELETE FROM students WHERE id = ?", (student_id,))
    conn.commit()
    await query.edit_message_text(f"Студент '{format_name(student[0])}' удалён.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info(f"Студент '{student[0]}' удалён пользователем {user_id}.")
else:
    await query.edit_message_text("Студент не найден.")
    logger.warning(f"Пользователь {user_id} попытался удалить несуществующего студента с ID {student_id}.")

return ConversationHandler.END
Команда /add_admin
@role_required(['main_admin'])
async def add_admin(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
await update.message.reply_text(
"Введите Telegram ID пользователя и роль, разделенные пробелом (например: 123456789 admin):",
reply_markup=ReplyKeyboardMarkup([["Отмена"]], resize_keyboard=True)
)
logger.info(f"Пользователь {user_id} начал добавление нового администратора.")
return ADDING_ADMIN

Обработка добавления администратора
@role_required(['main_admin'])
async def add_admin_id(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
text = update.message.text.strip()
if text.lower() == "отмена":
await cancel(update, context)
return


parts = text.split()
if len(parts) != 2:
    await update.message.reply_text("Пожалуйста, введите ID пользователя и роль, например: `123456789 admin`.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    return ADDING_ADMIN

try:
    new_user_id = int(parts[0])
    role = parts[1]
    if role not in ['main_admin', 'admin', 'junior_admin']:
        await update.message.reply_text("Недопустимая роль. Доступны: main_admin, admin, junior_admin.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
        return ADDING_ADMIN

    cursor.execute("INSERT INTO admins (user_id, role) VALUES (?, ?)", (new_user_id, role))
    conn.commit()
    await update.message.reply_text(f"Пользователь с ID {new_user_id} добавлен как {role}.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info(f"Пользователь {new_user_id} добавлен как {role} пользователем {user_id}.")
except ValueError:
    await update.message.reply_text("Некорректный ID. Пожалуйста, введите числовой Telegram ID и роль.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.error(f"Пользователь {user_id} ввёл некорректный ID администратора для добавления: {text}")
except sqlite3.IntegrityError:
    await update.message.reply_text(f"Пользователь с ID {new_user_id} уже является администратором.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.warning(f"Пользователь {user_id} попытался добавить уже существующего администратора с ID {new_user_id}.")
return ConversationHandler.END
Команда /remove_admin
@role_required(['main_admin'])
async def remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
cursor.execute("SELECT user_id, role FROM admins WHERE role != 'main_admin' ORDER BY user_id")
admins = cursor.fetchall()
if not admins:
await update.message.reply_text("Нет администраторов для удаления.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info(f"Пользователь {user_id} попытался удалить администратора, но список пуст.")
return ConversationHandler.END


keyboard = []
for admin in admins:
    if admin[0] != INITIAL_ADMIN_USER_ID:  # Исключаем начального администратора
        keyboard.append([InlineKeyboardButton(f"ID: {admin[0]} - {admin[1]}", callback_data=f"remove_admin_{admin[0]}")])

reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text("Выберите администратора для удаления:", reply_markup=reply_markup)
logger.info(f"Пользователь {user_id} выбрал операцию удаления администратора.")
return REMOVING_ADMIN
Обработка удаления администратора (CallbackQueryHandler)
@role_required(['main_admin'])
async def handle_remove_admin(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


data = query.data.split("_")
if len(data) != 3 or data[0] != "remove" or data[1] != "admin":
    await query.edit_message_text("Некорректные данные.")
    logger.error(f"Пользователь {user_id} прислал некорректные данные для удаления администратора: {query.data}")
    return ConversationHandler.END

try:
    admin_id = int(data[2])
except ValueError:
    await query.edit_message_text("Некорректный ID администратора.")
    logger.error(f"Пользователь {user_id} прислал некорректный ID администратора: {data[2]}")
    return ConversationHandler.END

if admin_id == INITIAL_ADMIN_USER_ID:
    await query.edit_message_text("Нельзя удалить начального администратора.")
    logger.warning(f"Пользователь {user_id} попытался удалить начального администратора с ID {admin_id}.")
    return ConversationHandler.END

cursor.execute("DELETE FROM admins WHERE user_id = ?", (admin_id,))
conn.commit()

if cursor.rowcount > 0:
    await query.edit_message_text(f"Пользователь с ID {admin_id} удалён из администраторов.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info(f"Пользователь с ID {admin_id} удалён из администраторов пользователем {user_id}.")
else:
    await query.edit_message_text(f"Пользователь с ID {admin_id} не найден среди администраторов.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.warning(f"Пользователь {user_id} попытался удалить администратора с несуществующим ID {admin_id}.")

return ConversationHandler.END
Команда /mark_attendance
@role_required(['admin', 'main_admin', 'junior_admin'])
async def mark_attendance(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
today = datetime.now().strftime("%Y-%m-%d")
logger.info(f"Пользователь {user_id} начал отметку посещаемости для даты {today}.")


# Проверка, была ли уже отмечена посещаемость сегодня для всех студентов
cursor.execute("SELECT COUNT(DISTINCT student_id) FROM attendance WHERE date = ?", (today,))
existing = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM students")
total_students = cursor.fetchone()[0]
if existing >= total_students:
    await update.message.reply_text(
        "Посещаемость на сегодня уже была отмечена. Если хотите отметить заново, выполните /reset_attendance.",
        reply_markup=main_menu_keyboard(role=get_user_role(user_id))
    )
    logger.info(f"Пользователь {user_id} попытался отметить посещаемость, но она уже была отмечена.")
    return

cursor.execute("SELECT id, name FROM students ORDER BY name")
students = cursor.fetchall()

if not students:
    await update.message.reply_text(
        "Список студентов пуст. Добавьте студентов с помощью ➕ Добавить студента или ➕➕ Добавить нескольких студентов.",
        reply_markup=main_menu_keyboard(role=get_user_role(user_id))
    )
    logger.info(f"Пользователь {user_id} попытался отметить посещаемость, но список студентов пуст.")
    return

# Отправляем сообщение с кнопками для каждого студента
for student in students:
    formatted_name = format_name(student[1])
    buttons = [
        InlineKeyboardButton("Присутствовал (П)", callback_data=f"mark_P_{student[0]}"),
        InlineKeyboardButton("Не был (Н)", callback_data=f"mark_N_{student[0]}"),
        InlineKeyboardButton("Опоздал (О)", callback_data=f"mark_O_{student[0]}")
    ]
    reply_markup = InlineKeyboardMarkup([buttons])
    await update.message.reply_text(f"Отметьте статус для {formatted_name}:", reply_markup=reply_markup)
    logger.info(f"Пользователю {user_id} отправлена форма отметки для студента {formatted_name}.")

# Добавление кнопки "Завершить отметку"
finish_button = InlineKeyboardButton("Завершить отметку", callback_data="finish_mark_attendance")
finish_markup = InlineKeyboardMarkup([[finish_button]])

await update.message.reply_text("Когда закончите отмечать, нажмите 'Завершить отметку'.", reply_markup=finish_markup)
logger.info(f"Пользователю {user_id} отправлена кнопка для завершения отметки посещаемости.")
Обработка отметки "P", "N" или "O"
@role_required(['admin', 'main_admin', 'junior_admin'])
async def handle_attendance_mark(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


data = query.data.split("_")
if len(data) != 3:
    await query.edit_message_text("Некорректные данные.")
    logger.error(f"Пользователь {user_id} прислал некорректные данные для отметки посещаемости: {query.data}")
    return

action, status, student_id = data
if action != "mark" or status not in ["P", "N", "O"]:
    await query.edit_message_text("Некорректные данные.")
    logger.error(f"Пользователь {user_id} использовал некорректный статус для отметки: {status}")
    return

try:
    student_id = int(student_id)
except ValueError:
    await query.edit_message_text("Некорректный ID студента.")
    logger.error(f"Пользователь {user_id} прислал некорректный ID студента: {student_id}")
    return

# Проверка наличия уже отметки для этого студента сегодня
today = datetime.now().strftime("%Y-%m-%d")
exists = cursor.execute("SELECT * FROM attendance WHERE date = ? AND student_id = ?", (today, student_id)).fetchone()
if exists:
    await query.edit_message_text("Статус уже был установлен.")
    logger.warning(f"Пользователь {user_id} попытался повторно отметить посещаемость для студента ID {student_id}.")
    return

# Вставка отметки "P", "N" или "O"
try:
    cursor.execute("INSERT INTO attendance (date, student_id, status) VALUES (?, ?, ?)", (today, student_id, status))
    conn.commit()
except Exception as e:
    await query.edit_message_text("Произошла ошибка при сохранении отметки.")
    logger.error(f"Ошибка при вставке отметки посещаемости для студента ID {student_id}: {e}")
    return

student = cursor.execute("SELECT name FROM students WHERE id = ?", (student_id,)).fetchone()
student_name = format_name(student[0]) if student else "Неизвестный студент"

status_text = {
    "P": "Присутствовал",
    "N": "Не был",
    "O": "Опоздал"
}.get(status, "Неизвестный статус")

await query.edit_message_text(f"Статус для {student_name} установлен как '{status_text}'.")
logger.info(f"Пользователь {user_id} отметил студента {student_name} как '{status_text}'.")
Обработка нажатия "Завершить отметку"
@role_required(['admin', 'main_admin', 'junior_admin'])
async def finish_mark_attendance(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


today = datetime.now().strftime("%Y-%m-%d")
logger.info(f"Пользователь {user_id} завершил отметку посещаемости для даты {today}.")

# Получение всех студентов
cursor.execute("SELECT id, name FROM students")
all_students = cursor.fetchall()
all_student_ids = {s[0] for s in all_students}

# Получение студентов, кто были отмечены как "P", "N", "O", "D"
cursor.execute("SELECT student_id FROM attendance WHERE date = ?", (today,))
marked_students = {row[0] for row in cursor.fetchall()}

# Определение студентов, которым нужно назначить "P"
present_students = all_student_ids - marked_students

# Назначение "P" (Присутствовал) для остальных
try:
    for student_id in present_students:
        cursor.execute("INSERT INTO attendance (date, student_id, status) VALUES (?, ?, ?)", (today, student_id, "P"))
    conn.commit()
except Exception as e:
    await query.edit_message_text("Произошла ошибка при назначении статуса 'Присутствовал (П)'.")
    logger.error(f"Ошибка при назначении статуса 'P' для студентов: {e}")
    return

await query.edit_message_text("Отметка посещаемости завершена. Всем не отмеченным присвоен статус 'Присутствовал (П)'.")
logger.info(f"Статус 'P' назначен всем не отмеченным студентам для даты {today}.")

# Отправка нового сообщения с главным меню
await query.message.reply_text(
    "Выберите следующее действие:",
    reply_markup=main_menu_keyboard(role=get_user_role(user_id))
)
logger.info(f"Пользователь {user_id} вернулся в главное меню после завершения отметки посещаемости.")
Команда /assign_duty
@role_required(['admin', 'main_admin'])
async def assign_duty(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
today = datetime.now().strftime("%Y-%m-%d")
logger.info(f"Пользователь {user_id} начал назначение дежурств для даты {today}.")


# Проверка, были ли уже назначены дежурства на сегодня
cursor.execute("SELECT student_id FROM attendance WHERE date = ? AND status = 'D'", (today,))
existing_duties = cursor.fetchall()
if existing_duties:
    await update.message.reply_text(
        "Дежурства на сегодня уже были назначены. Если хотите назначить заново, выполните /reset_duty.",
        reply_markup=main_menu_keyboard(role=get_user_role(user_id))
    )
    logger.info(f"Дежурства на сегодня уже были назначены. Пользователь {user_id} не может назначить заново.")
    return

# Получение студентов, которым можно назначить дежурство (не "N")
cursor.execute("""
    SELECT s.id, s.name, s.duty_count
    FROM students s
    LEFT JOIN attendance a ON s.id = a.student_id AND a.date = ?
    WHERE a.status != 'N' OR a.status IS NULL
    ORDER BY s.duty_count ASC, s.name ASC
""", (today,))
eligible_students = cursor.fetchall()

if len(eligible_students) < 2:
    await update.message.reply_text(
        "Недостаточно студентов для назначения дежурств. Нужно минимум 2 присутствующих (или опоздавших) студента.",
        reply_markup=main_menu_keyboard(role=get_user_role(user_id))
    )
    logger.info(f"Недостаточно студентов для назначения дежурств. Пользователь {user_id} не может продолжить.")
    return

# Определяем минимальное количество дежурств среди eligible
min_duty = eligible_students[0][2]
# Находим всех с минимальным duty_count
min_duty_students = [s for s in eligible_students if s[2] == min_duty]

if len(min_duty_students) >= 2:
    # Если достаточно студентов с минимальным duty_count, выбираем из них
    selected = random.sample(min_duty_students, 2)
else:
    # Если недостаточно, выбираем всех с минимальными и дополняем из следующих
    selected = min_duty_students.copy()
    additional_needed = 2 - len(selected)
    remaining_students = [s for s in eligible_students if s[2] > min_duty]
    if len(remaining_students) < additional_needed:
        await update.message.reply_text(
            "Недостаточно студентов с минимальным количеством дежурств для назначения. Попробуйте позже.",
            reply_markup=main_menu_keyboard(role=get_user_role(user_id))
        )
        logger.info(f"Недостаточно студентов с минимальным количеством дежурств для назначения пользователем {user_id}.")
        return
    selected += random.sample(remaining_students, additional_needed)

duty_names = [format_name(s[1]) for s in selected]
logger.info(f"Назначены дежурные: {', '.join(duty_names)} пользователем {user_id}.")

# Обновляем счетчик дежурств и добавляем запись в attendance со статусом "D" (дежурный)
try:
    for s in selected:
        cursor.execute("UPDATE students SET duty_count = duty_count + 1 WHERE id = ?", (s[0],))
        cursor.execute("INSERT INTO attendance (date, student_id, status) VALUES (?, ?, ?)", (today, s[0], "D"))
    conn.commit()
except Exception as e:
    await update.message.reply_text("Произошла ошибка при назначении дежурств.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.error(f"Ошибка при назначении дежурств: {e}")
    return

# Сохранение текущих дежурств в context для дальнейших операций
context.user_data['current_duty'] = selected

# Создание кнопок для изменения дежурств
buttons = [
    InlineKeyboardButton("Сменить дежурного A", callback_data="swap_A"),
    InlineKeyboardButton("Сменить дежурного B", callback_data="swap_B"),
    InlineKeyboardButton("Сменить обоих дежурных", callback_data="swap_both")
]
reply_markup = InlineKeyboardMarkup([buttons])

# Отправка сообщения с кнопками
duty_message = await update.message.reply_text(
    f"Назначенные дежурные: {duty_names[0]} и {duty_names[1]}.",
    reply_markup=reply_markup
)
context.user_data['duty_message_id'] = duty_message.message_id
logger.info(f"Сообщение о назначении дежурств отправлено пользователю {user_id}.")
Обработка кнопок после назначения дежурств
@role_required(['admin', 'main_admin'])
async def handle_duty_buttons(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


action = query.data
today = datetime.now().strftime("%Y-%m-%d")
duty = context.user_data.get('current_duty')

if not duty:
    await query.edit_message_text("Нет текущих назначений дежурств для изменения.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.warning(f"Пользователь {user_id} пытался изменить дежурства, но текущих дежурств нет.")
    return

if action == "swap_A" or action == "swap_B" or action == "swap_both":
    if action == "swap_A":
        swap_indices = [0]
        description = "сменить дежурного A"
    elif action == "swap_B":
        swap_indices = [1]
        description = "сменить дежурного B"
    elif action == "swap_both":
        swap_indices = [0, 1]
        description = "сменить обоих дежурных"

    # Получение студентов, которым можно назначить дежурство
    excluded_ids = [s[0] for s in duty]
    placeholders = ','.join(['?'] * len(excluded_ids)) if excluded_ids else ''
    if excluded_ids:
        query_sql = f"""
            SELECT s.id, s.name, s.duty_count
            FROM students s
            LEFT JOIN attendance a ON s.id = a.student_id AND a.date = ?
            WHERE (a.status != 'N' OR a.status IS NULL)
            AND s.id NOT IN ({placeholders}) 
            ORDER BY s.duty_count ASC, s.name ASC
        """
        params = [today] + excluded_ids
    else:
        query_sql = """
            SELECT s.id, s.name, s.duty_count
            FROM students s
            LEFT JOIN attendance a ON s.id = a.student_id AND a.date = ?
            WHERE (a.status != 'N' OR a.status IS NULL)
            ORDER BY s.duty_count ASC, s.name ASC
        """
        params = [today]

    cursor.execute(query_sql, params)
    eligible_students = cursor.fetchall()

    if not eligible_students:
        await query.edit_message_text("Нет доступных студентов для замены.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
        logger.info(f"Нет доступных студентов для замены дежурных пользователем {user_id}.")
        return

    # Определяем минимальное количество дежурств среди eligible
    min_duty = eligible_students[0][2]
    min_duty_students = [s for s in eligible_students if s[2] == min_duty]

    if len(min_duty_students) >= len(swap_indices):
        # Если достаточно студентов с минимальным duty_count, выбираем из них
        selected_new = random.sample(min_duty_students, len(swap_indices))
    else:
        # Если недостаточно, выбираем всех с минимальными и дополняем из следующих
        selected_new = min_duty_students.copy()
        additional_needed = len(swap_indices) - len(selected_new)
        remaining_students = [s for s in eligible_students if s[2] > min_duty]
        if len(remaining_students) < additional_needed:
            await query.edit_message_text("Недостаточно студентов с минимальным количеством дежурств для замены.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
            logger.info(f"Недостаточно студентов с минимальным количеством дежурств для замены пользователем {user_id}.")
            return

        selected_new += random.sample(remaining_students, additional_needed)

    # Обновляем duty_count и attendance
    try:
        for idx, new_student in zip(swap_indices, selected_new):
            # Удаляем предыдущего дежурного из attendance
            previous_student_id = duty[idx][0]
            cursor.execute("DELETE FROM attendance WHERE date = ? AND student_id = ?", (today, previous_student_id))
            # Обновляем duty_count для предыдущего дежурного
            cursor.execute("UPDATE students SET duty_count = duty_count - 1 WHERE id = ?", (previous_student_id,))
            # Назначаем нового дежурного
            cursor.execute("UPDATE students SET duty_count = duty_count + 1 WHERE id = ?", (new_student[0],))
            cursor.execute("INSERT INTO attendance (date, student_id, status) VALUES (?, ?, ?)", (today, new_student[0], "D"))

            # Обновляем текущие дежурства в context
            duty[idx] = new_student
        conn.commit()
    except Exception as e:
        await query.edit_message_text("Произошла ошибка при смене дежурных.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
        logger.error(f"Ошибка при смене дежурных: {e}")
        return

    duty_names = [format_name(s[1]) for s in duty]
    logger.info(f"Дежурные успешно {description}: {', '.join(duty_names)} пользователем {user_id}.")

    # Обновляем сообщение с новыми дежурными
    buttons = [
        InlineKeyboardButton("Сменить дежурного A", callback_data="swap_A"),
        InlineKeyboardButton("Сменить дежурного B", callback_data="swap_B"),
        InlineKeyboardButton("Сменить обоих дежурных", callback_data="swap_both")
    ]
    reply_markup = InlineKeyboardMarkup([buttons])

    await query.edit_message_text(
        f"Дежурные успешно {description}.\nНовые дежурные: {duty_names[0]} и {duty_names[1]}.",
        reply_markup=reply_markup
    )
    logger.info(f"Сообщение обновлено с новыми дежурными пользователем {user_id}.")
Команда /stats
@role_required(['admin', 'main_admin'])
async def stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
cursor.execute("SELECT name, duty_count FROM students ORDER BY duty_count, name")
students = cursor.fetchall()


if not students:
    await update.message.reply_text("Список студентов пуст.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info(f"Пользователь {user_id} запросил статистику, но список студентов пуст.")
    return

message = "Статистика дежурств:\n"
for s in students:
    message += f"{format_name(s[0])}: {s[1]} раз(а)\n"

await update.message.reply_text(message, reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info(f"Пользователь {user_id} получил статистику дежурств.")
Команда /reset_attendance
@role_required(['admin', 'main_admin'])
async def reset_attendance(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
today = datetime.now().strftime("%Y-%m-%d")
logger.info(f"Пользователь {user_id} начал сброс отметки посещаемости для даты {today}.")


try:
    cursor.execute("DELETE FROM attendance WHERE date = ?", (today,))
    conn.commit()
    await update.message.reply_text("Отметка посещаемости на сегодня сброшена.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info(f"Отметка посещаемости для даты {today} успешно сброшена пользователем {user_id}.")
except Exception as e:
    await update.message.reply_text("Произошла ошибка при сбросе отметки посещаемости.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.error(f"Ошибка при сбросе отметки посещаемости для даты {today}: {e}")
Команда /reset_duty
@role_required(['admin', 'main_admin'])
async def reset_duty(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
today = datetime.now().strftime("%Y-%m-%d")
logger.info(f"Пользователь {user_id} начал сброс дежурств для даты {today}.")


try:
    # Получение текущих дежурств
    cursor.execute("SELECT student_id FROM attendance WHERE date = ? AND status = 'D'", (today,))
    current_duties = cursor.fetchall()
    if not current_duties:
        await update.message.reply_text("Дежурства на сегодня не назначены.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
        logger.info(f"Пользователь {user_id} попытался сбросить дежурства, но они не назначены.")
        return

    for duty in current_duties:
        student_id = duty[0]
        cursor.execute("DELETE FROM attendance WHERE date = ? AND student_id = ?", (today, student_id))
        cursor.execute("UPDATE students SET duty_count = duty_count - 1 WHERE id = ?", (student_id,))
    conn.commit()

    await update.message.reply_text("Дежурства на сегодня сброшены.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info(f"Дежурства на сегодня для даты {today} успешно сброшены пользователем {user_id}.")
except Exception as e:
    await update.message.reply_text("Произошла ошибка при сбросе дежурств.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.error(f"Ошибка при сбросе дежурств для даты {today}: {e}")
Команда /report
@role_required(['admin', 'main_admin'])
async def report(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
await update.message.reply_text(
"Выберите тип отчета:",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Ежедневный", callback_data="report_daily")],
[InlineKeyboardButton("Недельный", callback_data="report_weekly")],
[InlineKeyboardButton("Месячный", callback_data="report_monthly")],
[InlineKeyboardButton("Отмена", callback_data="report_cancel")]
])
)
logger.info(f"Пользователь {user_id} начал генерацию отчетов.")
return SELECT_REPORT_TYPE

Обработка выбора типа отчета (CallbackQueryHandler)
@role_required(['admin', 'main_admin'])
async def handle_report_selection_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


data = query.data
role = get_user_role(user_id)

if data == "report_cancel":
    await query.edit_message_text("Генерация отчетов отменена.", reply_markup=main_menu_keyboard(role=role))
    logger.info(f"Пользователь {user_id} отменил генерацию отчета.")
    return ConversationHandler.END

report_type = data.split("_")[1]  # daily, weekly, monthly
logger.info(f"Пользователь {user_id} выбрал тип отчета: {report_type}.")

# Генерация отчета в зависимости от типа
if report_type == "daily":
    await generate_daily_report(query, context, user_id)
elif report_type == "weekly":
    await generate_weekly_report(query, context, user_id)
elif report_type == "monthly":
    await generate_monthly_report(query, context, user_id)
else:
    await query.edit_message_text("Некорректный тип отчета.", reply_markup=main_menu_keyboard(role=role))
    logger.error(f"Пользователь {user_id} выбрал некорректный тип отчета: {report_type}.")

return ConversationHandler.END
Генерация ежедневного отчета
@role_required(['admin', 'main_admin'])
async def generate_daily_report(query, context, user_id):
today = datetime.now().strftime("%Y-%m-%d")
logger.info(f"Пользователь {user_id} запросил ежедневный отчет для даты {today}.")


cursor.execute("""
    SELECT s.name, a.status, COUNT(*) as count
    FROM attendance a
    JOIN students s ON a.student_id = s.id
    WHERE a.date = ?
    GROUP BY s.name, a.status
""", (today,))

data = cursor.fetchall()

if not data:
    await query.edit_message_text("Нет данных для ежедневного отчета.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для ежедневного отчета.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Студент", "Статус", "Количество"])

# Создание графика
fig, ax = plt.subplots(figsize=(10,6))
for status in df['Статус'].unique():
    subset = df[df['Статус'] == status]
    ax.bar(subset['Студент'], subset['Количество'], label=status)

ax.set_title(f'Ежедневный отчет по посещаемости ({today})')
ax.set_xlabel('Студенты')
ax.set_ylabel('Количество')
ax.legend()

# Поворот подписей для лучшей читаемости
plt.xticks(rotation=45, ha='right')

# Сохранение графика в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка графика как изображение
await query.edit_message_text(f"Ежедневный отчет по посещаемости ({today}):")
await context.bot.send_photo(
    chat_id=user_id,
    photo=bio,
    caption="Ежедневный отчет по посещаемости."
)
await context.bot.send_message(
    chat_id=user_id,
    text="📈 Ежедневный отчет готов.",
    reply_markup=main_menu_keyboard(role=get_user_role(user_id))
)
logger.info("Ежедневный отчет отправлен.")
Генерация недельного отчета
@role_required(['admin', 'main_admin'])
async def generate_weekly_report(query, context, user_id):
today = datetime.now()
start_of_week = today - timedelta(days=today.weekday()) # Понедельник
end_of_week = start_of_week + timedelta(days=6) # Воскресенье


logger.info(f"Пользователь {user_id} запросил недельный отчет с {start_of_week.strftime('%Y-%m-%d')} по {end_of_week.strftime('%Y-%m-%d')}.")

cursor.execute("""
    SELECT s.name, a.status, COUNT(*) as count
    FROM attendance a
    JOIN students s ON a.student_id = s.id
    WHERE a.date BETWEEN ? AND ?
    GROUP BY s.name, a.status
""", (start_of_week.strftime("%Y-%m-%d"), end_of_week.strftime("%Y-%m-%d")))

data = cursor.fetchall()

if not data:
    await query.edit_message_text("Нет данных для недельного отчета.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для недельного отчета.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Студент", "Статус", "Количество"])

# Создание графика
fig, ax = plt.subplots(figsize=(10,6))
for status in df['Статус'].unique():
    subset = df[df['Статус'] == status]
    ax.bar(subset['Студент'], subset['Количество'], label=status)

ax.set_title(f'Недельный отчет по посещаемости ({start_of_week.strftime("%d-%m-%Y")} - {end_of_week.strftime("%d-%m-%Y")})')
ax.set_xlabel('Студенты')
ax.set_ylabel('Количество')
ax.legend()

# Поворот подписей для лучшей читаемости
plt.xticks(rotation=45, ha='right')

# Сохранение графика в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка графика как изображение
await query.edit_message_text(f"Недельный отчет по посещаемости с {start_of_week.strftime('%d-%m-%Y')} по {end_of_week.strftime('%d-%m-%Y')}:")
await context.bot.send_photo(
    chat_id=user_id,
    photo=bio,
    caption="Недельный отчет по посещаемости."
)
await context.bot.send_message(
    chat_id=user_id,
    text="📈 Недельный отчет готов.",
    reply_markup=main_menu_keyboard(role=get_user_role(user_id))
)
logger.info("Недельный отчет отправлен.")
Генерация месячного отчета
@role_required(['admin', 'main_admin'])
async def generate_monthly_report(query, context, user_id):
today = datetime.now()
start_of_month = today.replace(day=1)
next_month = start_of_month + timedelta(days=32)
start_of_next_month = next_month.replace(day=1)
end_of_month = start_of_next_month - timedelta(days=1)


logger.info(f"Пользователь {user_id} запросил месячный отчет с {start_of_month.strftime('%Y-%m-%d')} по {end_of_month.strftime('%Y-%m-%d')}.")

cursor.execute("""
    SELECT s.name, a.status, COUNT(*) as count
    FROM attendance a
    JOIN students s ON a.student_id = s.id
    WHERE a.date BETWEEN ? AND ?
    GROUP BY s.name, a.status
""", (start_of_month.strftime("%Y-%m-%d"), end_of_month.strftime("%Y-%m-%d")))

data = cursor.fetchall()

if not data:
    await query.edit_message_text("Нет данных для месячного отчета.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для месячного отчета.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Студент", "Статус", "Количество"])

# Создание графика
fig, ax = plt.subplots(figsize=(12,7))
pivot_df = df.pivot(index='Студент', columns='Статус', values='Количество').fillna(0)
pivot_df.plot(kind='bar', ax=ax)
ax.set_title(f'Месячный отчет по посещаемости ({start_of_month.strftime("%B %Y")})')
ax.set_xlabel('Студенты')
ax.set_ylabel('Количество')
plt.xticks(rotation=45, ha='right')
plt.legend(title='Статус')

# Сохранение графика в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка графика как изображение
await query.edit_message_text(f"Месячный отчет по посещаемости ({start_of_month.strftime('%B %Y')}):")
await context.bot.send_photo(
    chat_id=user_id,
    photo=bio,
    caption="Месячный отчет по посещаемости."
)
await context.bot.send_message(
    chat_id=user_id,
    text="📈 Месячный отчет готов.",
    reply_markup=main_menu_keyboard(role=get_user_role(user_id))
)
logger.info("Месячный отчет отправлен.")
Обработка Callback Queries для выбора типа отчета
@role_required(['admin', 'main_admin'])
async def handle_report_selection_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


data = query.data
role = get_user_role(user_id)

if data == "report_cancel":
    await query.edit_message_text("Генерация отчетов отменена.", reply_markup=main_menu_keyboard(role=role))
    logger.info(f"Пользователь {user_id} отменил генерацию отчета.")
    return ConversationHandler.END

report_type = data.split("_")[1]  # daily, weekly, monthly
logger.info(f"Пользователь {user_id} выбрал тип отчета: {report_type}.")

# Генерация отчета в зависимости от типа
if report_type == "daily":
    await generate_daily_report(query, context, user_id)
elif report_type == "weekly":
    await generate_weekly_report(query, context, user_id)
elif report_type == "monthly":
    await generate_monthly_report(query, context, user_id)
else:
    await query.edit_message_text("Некорректный тип отчета.", reply_markup=main_menu_keyboard(role=role))
    logger.error(f"Пользователь {user_id} выбрал некорректный тип отчета: {report_type}.")

return ConversationHandler.END
Команда /duty_history
@role_required(['admin', 'main_admin'])
async def duty_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
cursor.execute("SELECT id, name FROM students ORDER BY name")
students = cursor.fetchall()


if not students:
    await update.message.reply_text("Список студентов пуст.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info(f"Пользователь {user_id} запросил историю дежурств, но список студентов пуст.")
    return

# Создание инлайн клавиатуры с выбором студента
keyboard = []
for student in students:
    formatted_name = format_name(student[1])
    keyboard.append([InlineKeyboardButton(formatted_name, callback_data=f"duty_history_{student[0]}")])

reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text("Выберите студента для просмотра истории дежурств:", reply_markup=reply_markup)
logger.info("Инлайн клавиатура для выбора студента отправлена.")
return SELECT_DUTY_HISTORY_STUDENT
Обработка запроса истории дежурств
@role_required(['admin', 'main_admin'])
async def handle_duty_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


data = query.data.split("_")
if len(data) != 3 or data[0] != "duty" or data[1] != "history":
    await query.edit_message_text("Некорректные данные.")
    logger.error(f"Пользователь {user_id} прислал некорректные данные для истории дежурств: {query.data}")
    return ConversationHandler.END

try:
    student_id = int(data[2])
except ValueError:
    await query.edit_message_text("Некорректный ID студента.")
    logger.error(f"Пользователь {user_id} прислал некорректный ID студента для истории дежурств: {student_id}")
    return ConversationHandler.END

# Получение имени студента
student = cursor.execute("SELECT name FROM students WHERE id = ?", (student_id,)).fetchone()
if not student:
    await query.edit_message_text("Студент не найден.")
    logger.warning(f"Студент с ID {student_id} не найден для истории дежурств.")
    return ConversationHandler.END

student_name = format_name(student[0])

# Получение истории дежурств
cursor.execute("""
    SELECT date 
    FROM attendance 
    WHERE student_id = ? AND status = 'D'
    ORDER BY date DESC
""", (student_id,))
history = cursor.fetchall()

if not history:
    await query.edit_message_text(f"История дежурств для {student_name} пуста.")
    logger.info(f"История дежурств для {student_name} пуста.")
    return ConversationHandler.END

history_dates = [row[0] for row in history]
history_text = f"История дежурств для {student_name}:\n" + "\n".join(history_dates)

await query.edit_message_text(history_text)
logger.info(f"История дежурств для {student_name} отправлена.")
return ConversationHandler.END
Визуализация данных: Тренды опозданий и отсутствий
@role_required(['admin', 'main_admin'])
async def late_absence_trends(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
logger.info(f"Пользователь {user_id} запросил тренды опозданий и отсутствий.")


cursor.execute("""
    SELECT date, status
    FROM attendance
    WHERE status IN ('N', 'O')
""")
data = cursor.fetchall()

if not data:
    await update.message.reply_text("Нет данных для трендов опозданий и отсутствий.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для трендов опозданий и отсутствий.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Дата", "Статус"])
df['Дата'] = pd.to_datetime(df['Дата'])
df.set_index('Дата', inplace=True)
df['Статус'] = df['Статус'].map({'N': 'Не было', 'O': 'Опоздал'})

# Группировка по дате и статусу
trend = df.groupby([pd.Grouper(freq='D'), 'Статус']).size().unstack(fill_value=0)

# Создание графика
fig, ax = plt.subplots(figsize=(12,7))
trend.plot(kind='line', ax=ax)
ax.set_title('Тренды опозданий и отсутствий')
ax.set_xlabel('Дата')
ax.set_ylabel('Количество')
ax.legend(title='Статус')
plt.xticks(rotation=45)

# Сохранение графика в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка графика как изображение
await update.message.reply_photo(photo=bio, caption="Тренды опозданий и отсутствий.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info("График трендов опозданий и отсутствий отправлен.")
Визуализация данных: Распределение дежурств
@role_required(['admin', 'main_admin'])
async def duty_distribution(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
logger.info(f"Пользователь {user_id} запросил распределение дежурств.")


cursor.execute("""
    SELECT name, duty_count 
    FROM students
    ORDER BY duty_count DESC
""")
data = cursor.fetchall()

if not data:
    await update.message.reply_text("Нет данных для распределения дежурств.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для распределения дежурств.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Студент", "Количество дежурств"])

# Создание диаграммы распределения
fig, ax = plt.subplots(figsize=(10,6))
ax.pie(df['Количество дежурств'], labels=df['Студент'], autopct='%1.1f%%', startangle=140)
ax.set_title('Распределение дежурств среди студентов')

# Сохранение диаграммы в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка диаграммы как изображение
await update.message.reply_photo(photo=bio, caption="Распределение дежурств среди студентов.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info("Диаграмма распределения дежурств отправлена.")
Обработка Callback Queries для callback_data duty_history_<id>
@role_required(['admin', 'main_admin'])
async def handle_duty_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


data = query.data.split("_")
if len(data) != 3 or data[0] != "duty" or data[1] != "history":
    await query.edit_message_text("Некорректные данные.")
    logger.error(f"Пользователь {user_id} прислал некорректные данные для истории дежурств: {query.data}")
    return ConversationHandler.END

try:
    student_id = int(data[2])
except ValueError:
    await query.edit_message_text("Некорректный ID студента.")
    logger.error(f"Пользователь {user_id} прислал некорректный ID студента для истории дежурств: {student_id}")
    return ConversationHandler.END

# Получение имени студента
student = cursor.execute("SELECT name FROM students WHERE id = ?", (student_id,)).fetchone()
if not student:
    await query.edit_message_text("Студент не найден.")
    logger.warning(f"Студент с ID {student_id} не найден для истории дежурств.")
    return ConversationHandler.END

student_name = format_name(student[0])

# Получение истории дежурств
cursor.execute("""
    SELECT date 
    FROM attendance 
    WHERE student_id = ? AND status = 'D'
    ORDER BY date DESC
""", (student_id,))
history = cursor.fetchall()

if not history:
    await query.edit_message_text(f"История дежурств для {student_name} пуста.")
    logger.info(f"История дежурств для {student_name} пуста.")
    return ConversationHandler.END

history_dates = [row[0] for row in history]
history_text = f"История дежурств для {student_name}:\n" + "\n".join(history_dates)

await query.edit_message_text(history_text)
logger.info(f"История дежурств для {student_name} отправлена.")
return ConversationHandler.END
Визуализация данных: Тренды посещаемости
@role_required(['admin', 'main_admin'])
async def attendance_trends(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
logger.info(f"Пользователь {user_id} запросил тренды посещаемости.")


cursor.execute("""
    SELECT strftime('%w', date) as weekday, status, COUNT(*) as count
    FROM attendance
    GROUP BY weekday, status
""")

data = cursor.fetchall()

if not data:
    await update.message.reply_text("Нет данных для трендов посещаемости.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для трендов посещаемости.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Weekday", "Status", "Count"])
df['Weekday'] = df['Weekday'].astype(int)
df['Weekday'] = df['Weekday'].apply(lambda x: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'][x])

# Создание графика
fig, ax = plt.subplots(figsize=(10,6))
for status in df['Status'].unique():
    subset = df[df['Status'] == status]
    ax.plot(subset['Weekday'], subset['Count'], marker='o', label=status)

ax.set_title('Тренды посещаемости по дням недели')
ax.set_xlabel('День недели')
ax.set_ylabel('Количество')
ax.legend()

# Сохранение графика в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка графика как изображение
await update.message.reply_photo(photo=bio, caption="Тренды посещаемости по дням недели.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info("График трендов посещаемости отправлен.")
Визуализация данных: Распределение дежурств
@role_required(['admin', 'main_admin'])
async def duty_distribution(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
logger.info(f"Пользователь {user_id} запросил распределение дежурств.")


cursor.execute("""
    SELECT name, duty_count 
    FROM students
    ORDER BY duty_count DESC
""")
data = cursor.fetchall()

if not data:
    await update.message.reply_text("Нет данных для распределения дежурств.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для распределения дежурств.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Студент", "Количество дежурств"])

# Создание диаграммы распределения
fig, ax = plt.subplots(figsize=(10,6))
ax.pie(df['Количество дежурств'], labels=df['Студент'], autopct='%1.1f%%', startangle=140)
ax.set_title('Распределение дежурств среди студентов')

# Сохранение диаграммы в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка диаграммы как изображение
await update.message.reply_photo(photo=bio, caption="Распределение дежурств среди студентов.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info("Диаграмма распределения дежурств отправлена.")
Визуализация данных: Тренды опозданий и отсутствий
@role_required(['admin', 'main_admin'])
async def late_absence_trends(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
logger.info(f"Пользователь {user_id} запросил тренды опозданий и отсутствий.")


cursor.execute("""
    SELECT date, status
    FROM attendance
    WHERE status IN ('N', 'O')
""")
data = cursor.fetchall()

if not data:
    await update.message.reply_text("Нет данных для трендов опозданий и отсутствий.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для трендов опозданий и отсутствий.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Дата", "Статус"])
df['Дата'] = pd.to_datetime(df['Дата'])
df.set_index('Дата', inplace=True)
df['Статус'] = df['Статус'].map({'N': 'Не было', 'O': 'Опоздал'})

# Группировка по дате и статусу
trend = df.groupby([pd.Grouper(freq='D'), 'Статус']).size().unstack(fill_value=0)

# Создание графика
fig, ax = plt.subplots(figsize=(12,7))
trend.plot(kind='line', ax=ax)
ax.set_title('Тренды опозданий и отсутствий')
ax.set_xlabel('Дата')
ax.set_ylabel('Количество')
ax.legend(title='Статус')
plt.xticks(rotation=45)

# Сохранение графика в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка графика как изображение
await update.message.reply_photo(photo=bio, caption="Тренды опозданий и отсутствий.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info("График трендов опозданий и отсутствий отправлен.")
Обработка Callback Queries для callback_data duty_history_<id>
@role_required(['admin', 'main_admin'])
async def handle_duty_history(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
user_id = query.from_user.id
await query.answer()


data = query.data.split("_")
if len(data) != 3 or data[0] != "duty" or data[1] != "history":
    await query.edit_message_text("Некорректные данные.")
    logger.error(f"Пользователь {user_id} прислал некорректные данные для истории дежурств: {query.data}")
    return ConversationHandler.END

try:
    student_id = int(data[2])
except ValueError:
    await query.edit_message_text("Некорректный ID студента.")
    logger.error(f"Пользователь {user_id} прислал некорректный ID студента для истории дежурств: {student_id}")
    return ConversationHandler.END

# Получение имени студента
student = cursor.execute("SELECT name FROM students WHERE id = ?", (student_id,)).fetchone()
if not student:
    await query.edit_message_text("Студент не найден.")
    logger.warning(f"Студент с ID {student_id} не найден для истории дежурств.")
    return ConversationHandler.END

student_name = format_name(student[0])

# Получение истории дежурств
cursor.execute("""
    SELECT date 
    FROM attendance 
    WHERE student_id = ? AND status = 'D'
    ORDER BY date DESC
""", (student_id,))
history = cursor.fetchall()

if not history:
    await query.edit_message_text(f"История дежурств для {student_name} пуста.")
    logger.info(f"История дежурств для {student_name} пуста.")
    return ConversationHandler.END

history_dates = [row[0] for row in history]
history_text = f"История дежурств для {student_name}:\n" + "\n".join(history_dates)

await query.edit_message_text(history_text)
logger.info(f"История дежурств для {student_name} отправлена.")
return ConversationHandler.END
Визуализация данных: Тренды посещаемости
@role_required(['admin', 'main_admin'])
async def attendance_trends(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
logger.info(f"Пользователь {user_id} запросил тренды посещаемости.")


cursor.execute("""
    SELECT strftime('%w', date) as weekday, status, COUNT(*) as count
    FROM attendance
    GROUP BY weekday, status
""")

data = cursor.fetchall()

if not data:
    await update.message.reply_text("Нет данных для трендов посещаемости.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для трендов посещаемости.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Weekday", "Status", "Count"])
df['Weekday'] = df['Weekday'].astype(int)
df['Weekday'] = df['Weekday'].apply(lambda x: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'][x])

# Создание графика
fig, ax = plt.subplots(figsize=(10,6))
for status in df['Status'].unique():
    subset = df[df['Status'] == status]
    ax.plot(subset['Weekday'], subset['Count'], marker='o', label=status)

ax.set_title('Тренды посещаемости по дням недели')
ax.set_xlabel('День недели')
ax.set_ylabel('Количество')
ax.legend()

# Сохранение графика в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка графика как изображение
await update.message.reply_photo(photo=bio, caption="Тренды посещаемости по дням недели.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info("График трендов посещаемости отправлен.")
Визуализация данных: Распределение дежурств
@role_required(['admin', 'main_admin'])
async def duty_distribution(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
logger.info(f"Пользователь {user_id} запросил распределение дежурств.")


cursor.execute("""
    SELECT name, duty_count 
    FROM students
    ORDER BY duty_count DESC
""")
data = cursor.fetchall()

if not data:
    await update.message.reply_text("Нет данных для распределения дежурств.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для распределения дежурств.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Студент", "Количество дежурств"])

# Создание диаграммы распределения
fig, ax = plt.subplots(figsize=(10,6))
ax.pie(df['Количество дежурств'], labels=df['Студент'], autopct='%1.1f%%', startangle=140)
ax.set_title('Распределение дежурств среди студентов')

# Сохранение диаграммы в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка диаграммы как изображение
await update.message.reply_photo(photo=bio, caption="Распределение дежурств среди студентов.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info("Диаграмма распределения дежурств отправлена.")
Визуализация данных: Тренды опозданий и отсутствий
@role_required(['admin', 'main_admin'])
async def late_absence_trends(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_id = update.effective_user.id
logger.info(f"Пользователь {user_id} запросил тренды опозданий и отсутствий.")


cursor.execute("""
    SELECT date, status
    FROM attendance
    WHERE status IN ('N', 'O')
""")
data = cursor.fetchall()

if not data:
    await update.message.reply_text("Нет данных для трендов опозданий и отсутствий.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
    logger.info("Нет данных для трендов опозданий и отсутствий.")
    return

# Преобразование данных в DataFrame
df = pd.DataFrame(data, columns=["Дата", "Статус"])
df['Дата'] = pd.to_datetime(df['Дата'])
df.set_index('Дата', inplace=True)
df['Статус'] = df['Статус'].map({'N': 'Не было', 'O': 'Опоздал'})

# Группировка по дате и статусу
trend = df.groupby([pd.Grouper(freq='D'), 'Статус']).size().unstack(fill_value=0)

# Создание графика
fig, ax = plt.subplots(figsize=(12,7))
trend.plot(kind='line', ax=ax)
ax.set_title('Тренды опозданий и отсутствий')
ax.set_xlabel('Дата')
ax.set_ylabel('Количество')
ax.legend(title='Статус')
plt.xticks(rotation=45)

# Сохранение графика в байтовый поток
bio = BytesIO()
plt.tight_layout()
plt.savefig(bio, format='png')
bio.seek(0)
plt.close(fig)

# Отправка графика как изображение
await update.message.reply_photo(photo=bio, caption="Тренды опозданий и отсутствий.", reply_markup=main_menu_keyboard(role=get_user_role(user_id)))
logger.info("График трендов опозданий и отсутствий отправлен.")
Кнопка для визуализации данных: Тренды посещаемости
application.add_handler(MessageHandler(filters.Regex("^📈 Тренды посещаемости$"), attendance_trends))

Кнопка для раздела распределения дежурств
application.add_handler(MessageHandler(filters.Regex("^📊 Распределение дежурств$"), duty_distribution))

Кнопка для трендов опозданий и отсутствий
application.add_handler(MessageHandler(filters.Regex("^📉 Тренды опозданий и отсутствий$"), late_absence_trends))

Обработка Callback Queries для выбора типа отчета и истории дежурств
application.add_handler(CallbackQueryHandler(handle_attendance_mark, pattern=r"^mark_[PNO]\d+$"))
application.add_handler(CallbackQueryHandler(finish_mark_attendance, pattern=r"^finish_mark_attendance$"))
application.add_handler(CallbackQueryHandler(handle_duty_buttons, pattern=r"^(swap_A|swap_B|swap_both)$"))
application.add_handler(CallbackQueryHandler(handle_duty_history, pattern=r"^duty_history\d+$"))
application.add_handler(CallbackQueryHandler(handle_report_selection_callback, pattern=r"^report_(daily|weekly|monthly|cancel)$"))
application.add_handler(CallbackQueryHandler(handle_remove_admin, pattern=r"^remove_admin_\d+$"))

Command handlers
conversation_handlers = [
ConversationHandler(
entry_points=[MessageHandler(filters.Regex("^➕ Добавить студента$"), add_student)],
states={
ADDING_STUDENT: [MessageHandler(filters.TEXT & ~filters.COMMAND, add_student_name)]
},
fallbacks=[CommandHandler('cancel', cancel)]
),
ConversationHandler(
entry_points=[MessageHandler(filters.Regex("^➕➕ Добавить нескольких студентов$"), add_students)],
states={
ADDING_STUDENTS_LIST: [MessageHandler(filters.TEXT & ~filters.COMMAND, add_students_list)]
},
fallbacks=[CommandHandler('cancel', cancel)]
),
ConversationHandler(
entry_points=[MessageHandler(filters.Regex("^❌ Удалить студента$"), delete_student)],
states={
DELETING_STUDENT: [CallbackQueryHandler(handle_delete_student, pattern=r"^delete_\d+$")]
},
fallbacks=[CommandHandler('cancel', cancel)]
),
ConversationHandler(
entry_points=[MessageHandler(filters.Regex("^🔐 Добавить администратора$"), add_admin)],
states={
ADDING_ADMIN: [MessageHandler(filters.TEXT & ~filters.COMMAND, add_admin_id)]
},
fallbacks=[CommandHandler('cancel', cancel)]
),
ConversationHandler(
entry_points=[MessageHandler(filters.Regex("^🔒 Удалить администратора$"), remove_admin)],
states={
REMOVING_ADMIN: [CallbackQueryHandler(handle_remove_admin, pattern=r"^remove_admin_\d+$")]
},
fallbacks=[CommandHandler('cancel', cancel)]
),
ConversationHandler(
entry_points=[CommandHandler("report", report)],
states={
SELECT_REPORT_TYPE: [CallbackQueryHandler(handle_report_selection_callback, pattern=r"^report_(daily|weekly|monthly|cancel)$")]
},
fallbacks=[CommandHandler('cancel', cancel)]
),
ConversationHandler(
entry_points=[MessageHandler(filters.Regex("^📜 История дежурств$"), duty_history_handler)],
states={
SELECT_DUTY_HISTORY_STUDENT: [CallbackQueryHandler(handle_duty_history, pattern=r"^duty_history_\d+$")]
},
fallbacks=[CommandHandler('cancel', cancel)]
),
]

for conv in conversation_handlers:
application.add_handler(conv)

Запланированные задания
def schedule_jobs(application):
scheduler.add_job(
send_daily_reminder,
'cron',
hour=8,
minute=0,
args=[application],
timezone='Europe/Moscow' # Установите ваш часовой пояс
)
scheduler.add_job(
send_weekly_summary,
'cron',
day_of_week='fri',
hour=18,
minute=0,
args=[application],
timezone='Europe/Moscow'
)
scheduler.add_job(
auto_reassign_duty_wrapper,
'cron',
hour=20,
minute=0, # Время после завершения отметки посещаемости
args=[application],
timezone='Europe/Moscow'
)
scheduler.start()
logger.info("Планировщик заданий запущен с ежедневными, еженедельными задачами и авто переназначением дежурств.")

Функция-обертка для переназначения дежурств
async def auto_reassign_duty_wrapper(application: ApplicationBuilder):
await auto_reassign_duty(application)

Обработка автоматического переназначения дежурств
async def auto_reassign_duty(application):
today = datetime.now().strftime("%Y-%m-%d")
logger.info("Автоматическое переназначение дежурств запускается.")


# Получение дежурств на сегодня
cursor.execute("""
    SELECT student_id 
    FROM attendance 
    WHERE date = ? AND status = 'D'
""", (today,))
current_duties = cursor.fetchall()

if not current_duties:
    logger.info("Дежурства на сегодня не назначены. Пропуск переназначения.")
    return

unavailable = []
for duty in current_duties:
    student_id = duty[0]
    # Определение недоступности: студент помечен как "N" или отсутствует
    cursor.execute("""
        SELECT status 
        FROM attendance 
        WHERE date = ? AND student_id = ?
    """, (today, student_id))
    status = cursor.fetchone()
    if not status or status[0] not in ['P', 'O', 'D']:
        unavailable.append(student_id)

if not unavailable:
    logger.info("Все дежурные доступны. Переназначение не требуется.")
    return

for student_id in unavailable:
    # Поиск нового дежурного
    cursor.execute("""
        SELECT s.id, s.name, s.duty_count
        FROM students s
        LEFT JOIN attendance a ON s.id = a.student_id AND a.date = ?
        WHERE (a.status != 'N' OR a.status IS NULL)
        AND s.id NOT IN (SELECT student_id FROM attendance WHERE date = ? AND status = 'D')
        ORDER BY s.duty_count ASC, s.name ASC
        LIMIT 1
    """, (today, today))
    new_duty = cursor.fetchone()

    if new_duty:
        new_student_id = new_duty[0]
        cursor.execute("UPDATE students SET duty_count = duty_count + 1 WHERE id = ?", (new_student_id,))
        cursor.execute("INSERT INTO attendance (date, student_id, status) VALUES (?, ?, ?)", (today, new_student_id, 'D'))
        cursor.execute("DELETE FROM attendance WHERE date = ? AND student_id = ?", (today, student_id))
        conn.commit()
        # Предполагаем, что student_id соответствует chat_id (это может отличаться в вашей реализации)
        try:
            await application.bot.send_message(
                chat_id=new_student_id,  # Убедитесь, что chat_id и student_id совпадают
                text="🔄 Ваше дежурство было автоматически переназначено."
            )
            logger.info(f"Дежурство переназначено на студента ID {new_student_id}.")
        except Exception as e:
            logger.error(f"Ошибка при отправке уведомления студенту {new_student_id}: {e}")
    else:
        logger.warning(f"Не удалось найти замену для студента ID {student_id}.")
Отправка ежедневных напоминаний
async def send_daily_reminder(application):
today = datetime.now().strftime("%Y-%m-%d")
logger.info(f"Отправка ежедневного напоминания администраторам для даты {today}.")
cursor.execute("SELECT user_id FROM admins")
admins = cursor.fetchall()
if not admins:
logger.warning("Нет администраторов для отправки напоминаний.")
return


for admin in admins:
    try:
        await application.bot.send_message(
            chat_id=admin[0],
            text="🔔 Напоминание: Пожалуйста, отметьте посещаемость за сегодня.",
            reply_markup=main_menu_keyboard(role=get_user_role(admin[0]))
        )
        logger.info(f"Напоминание отправлено администратору с ID {admin[0]}.")
    except Exception as e:
        logger.error(f"Ошибка при отправке напоминания админу {admin[0]}: {e}")
Отправка еженедельных сводок
async def send_weekly_summary(application):
today = datetime.now()
start_of_week = today - timedelta(days=today.weekday()) # Понедельник
end_of_week = start_of_week + timedelta(days=6) # Воскресенье
logger.info(f"Отправка еженедельной сводки администраторам за период {start_of_week.strftime('%Y-%m-%d')} - {end_of_week.strftime('%Y-%m-%d')}.")


cursor.execute("""
    SELECT s.name, a.status, COUNT(*) as count
    FROM attendance a
    JOIN students s ON a.student_id = s.id
    WHERE a.date BETWEEN ? AND ?
    GROUP BY s.name, a.status
""", (start_of_week.strftime("%Y-%m-%d"), end_of_week.strftime("%Y-%m-%d")))

data = cursor.fetchall()
if not data:
    summary = "Еженедельная сводка:\nНет данных за прошлую неделю."
else:
    df = pd.DataFrame(data, columns=["Студент", "Статус", "Количество"])
    summary = f"Еженедельная сводка с {start_of_week.strftime('%d-%m-%Y')} по {end_of_week.strftime('%d-%m-%Y')}:\n"
    for student in df['Студент'].unique():
        student_data = df[df['Студент'] == student]
        statuses = ", ".join([f"{row['Статус']}: {row['Количество']}" for index, row in student_data.iterrows()])
        summary += f"{student}: {statuses}\n"

cursor.execute("SELECT user_id FROM admins")
admins = cursor.fetchall()
if not admins:
    logger.warning("Нет администраторов для отправки еженедельных сводок.")
    return

for admin in admins:
    try:
        await application.bot.send_message(
            chat_id=admin[0],
            text=summary,
            reply_markup=main_menu_keyboard(role=get_user_role(admin[0]))
        )
        logger.info(f"Еженедельная сводка отправлена администратору с ID {admin[0]}.")
    except Exception as e:
        logger.error(f"Ошибка при отправке еженедельной сводки админу {admin[0]}: {e}")
Обработка автоматического переназначения дежурств
async def auto_reassign_duty_wrapper(application: ApplicationBuilder):
await auto_reassign_duty(application)

Основная функция
def main():
application = ApplicationBuilder().token(BOT_TOKEN).build()


# Определение ConversationHandlers
conv_handler_add = ConversationHandler(
    entry_points=[MessageHandler(filters.Regex("^➕ Добавить студента$"), add_student)],
    states={
        ADDING_STUDENT: [MessageHandler(filters.TEXT & ~filters.COMMAND, add_student_name)]
    },
    fallbacks=[CommandHandler('cancel', cancel)]
)

conv_handler_add_multiple = ConversationHandler(
    entry_points=[MessageHandler(filters.Regex("^➕➕ Добавить нескольких студентов$"), add_students)],
    states={
        ADDING_STUDENTS_LIST: [MessageHandler(filters.TEXT & ~filters.COMMAND, add_students_list)]
    },
    fallbacks=[CommandHandler('cancel', cancel)]
)

conv_handler_delete = ConversationHandler(
    entry_points=[MessageHandler(filters.Regex("^❌ Удалить студента$"), delete_student)],
    states={
        DELETING_STUDENT: [CallbackQueryHandler(handle_delete_student, pattern=r"^delete_\d+$")]
    },
    fallbacks=[CommandHandler('cancel', cancel)]
)

conv_handler_add_admin = ConversationHandler(
    entry_points=[MessageHandler(filters.Regex("^🔐 Добавить администратора$"), add_admin)],
    states={
        ADDING_ADMIN: [MessageHandler(filters.TEXT & ~filters.COMMAND, add_admin_id)]
    },
    fallbacks=[CommandHandler('cancel', cancel)]
)

conv_handler_remove_admin = ConversationHandler(
    entry_points=[MessageHandler(filters.Regex("^🔒 Удалить администратора$"), remove_admin)],
    states={
        REMOVING_ADMIN: [CallbackQueryHandler(handle_remove_admin, pattern=r"^remove_admin_\d+$")]
    },
    fallbacks=[CommandHandler('cancel', cancel)]
)

conv_handler_report = ConversationHandler(
    entry_points=[CommandHandler("report", report)],
    states={
        SELECT_REPORT_TYPE: [CallbackQueryHandler(handle_report_selection_callback, pattern=r"^report_(daily|weekly|monthly|cancel)$")]
    },
    fallbacks=[CommandHandler('cancel', cancel)]
)

conv_handler_duty_history = ConversationHandler(
    entry_points=[MessageHandler(filters.Regex("^📜 История дежурств$"), duty_history)],
    states={
        SELECT_DUTY_HISTORY_STUDENT: [CallbackQueryHandler(handle_duty_history, pattern=r"^duty_history_\d+$")]
    },
    fallbacks=[CommandHandler('cancel', cancel)]
)

conversation_handlers = [
    conv_handler_add,
    conv_handler_add_multiple,
    conv_handler_delete,
    conv_handler_add_admin,
    conv_handler_remove_admin,
    conv_handler_report,
    conv_handler_duty_history
]

for conv in conversation_handlers:
    application.add_handler(conv)

# Обработчики кнопок символов
application.add_handler(MessageHandler(filters.Regex("^📈 Тренды посещаемости$"), attendance_trends))
application.add_handler(MessageHandler(filters.Regex("^📊 Распределение дежурств$"), duty_distribution))
application.add_handler(MessageHandler(filters.Regex("^📉 Тренды опозданий и отсутствий$"), late_absence_trends))

# Обработчики callback queries
application.add_handler(CallbackQueryHandler(handle_attendance_mark, pattern=r"^mark_[PNO]_\d+$"))
application.add_handler(CallbackQueryHandler(finish_mark_attendance, pattern=r"^finish_mark_attendance$"))
application.add_handler(CallbackQueryHandler(handle_duty_buttons, pattern=r"^(swap_A|swap_B|swap_both)$"))
application.add_handler(CallbackQueryHandler(handle_duty_history, pattern=r"^duty_history_\d+$"))
application.add_handler(CallbackQueryHandler(handle_report_selection_callback, pattern=r"^report_(daily|weekly|monthly|cancel)$"))
application.add_handler(CallbackQueryHandler(handle_remove_admin, pattern=r"^remove_admin_\d+$"))

# Обработчик неизвестных сообщений
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, unknown))

# Запланированные задания
schedule_jobs(application)

# Запуск бота
logger.info("Бот запускается...")
application.run_polling()
Функции для отправки напоминаний и сводок
async def send_daily_reminder(application):
today = datetime.now().strftime("%Y-%m-%d")
logger.info(f"Отправка ежедневного напоминания администраторам для даты {today}.")
cursor.execute("SELECT user_id FROM admins")
admins = cursor.fetchall()
if not admins:
logger.warning("Нет администраторов для отправки напоминаний.")
return


for admin in admins:
    try:
        await application.bot.send_message(
            chat_id=admin[0],
            text="🔔 Напоминание: Пожалуйста, отметьте посещаемость за сегодня.",
            reply_markup=main_menu_keyboard(role=get_user_role(admin[0]))
        )
        logger.info(f"Напоминание отправлено администратору с ID {admin[0]}.")
    except Exception as e:
        logger.error(f"Ошибка при отправке напоминания админу {admin[0]}: {e}")
async def send_weekly_summary(application):
today = datetime.now()
start_of_week = today - timedelta(days=today.weekday()) # Понедельник
end_of_week = start_of_week + timedelta(days=6) # Воскресенье
logger.info(f"Отправка еженедельной сводки администраторам за период {start_of_week.strftime('%Y-%m-%d')} - {end_of_week.strftime('%Y-%m-%d')}.")


cursor.execute("""
    SELECT s.name, a.status, COUNT(*) as count
    FROM attendance a
    JOIN students s ON a.student_id = s.id
    WHERE a.date BETWEEN ? AND ?
    GROUP BY s.name, a.status
""", (start_of_week.strftime("%Y-%m-%d"), end_of_week.strftime("%Y-%m-%d")))

data = cursor.fetchall()
if not data:
    summary = "Еженедельная сводка:\nНет данных за прошлую неделю."
else:
    df = pd.DataFrame(data, columns=["Студент", "Статус", "Количество"])
    summary = f"Еженедельная сводка с {start_of_week.strftime('%d-%m-%Y')} по {end_of_week.strftime('%d-%m-%Y')}:\n"
    for student in df['Студент'].unique():
        student_data = df[df['Студент'] == student]
        statuses = ", ".join([f"{row['Статус']}: {row['Количество']}" for index, row in student_data.iterrows()])
        summary += f"{student}: {statuses}\n"

cursor.execute("SELECT user_id FROM admins")
admins = cursor.fetchall()
if not admins:
    logger.warning("Нет администраторов для отправки еженедельных сводок.")
    return

for admin in admins:
    try:
        await application.bot.send_message(
            chat_id=admin[0],
            text=summary,
            reply_markup=main_menu_keyboard(role=get_user_role(admin[0]))
        )
        logger.info(f"Еженедельная сводка отправлена администратору с ID {admin[0]}.")
    except Exception as e:
        logger.error(f"Ошибка при отправке еженедельной сводки админу {admin[0]}: {e}")
Обработка автоматического переназначения дежурств
async def auto_reassign_duty(application):
today = datetime.now().strftime("%Y-%m-%d")
logger.info("Автоматическое переназначение дежурств запускается.")


# Получение дежурств на сегодня
cursor.execute("""
    SELECT student_id 
    FROM attendance 
    WHERE date = ? AND status = 'D'
""", (today,))
current_duties = cursor.fetchall()

if not current_duties:
    logger.info("Дежурства на сегодня не назначены. Пропуск переназначения.")
    return

unavailable = []
for duty in current_duties:
    student_id = duty[0]
    # Определение недоступности: студент помечен как "N" или отсутствует
    cursor.execute("""
        SELECT status 
        FROM attendance 
        WHERE date = ? AND student_id = ?
    """, (today, student_id))
    status = cursor.fetchone()
    if not status or status[0] not in ['P', 'O', 'D']:
        unavailable.append(student_id)

if not unavailable:
    logger.info("Все дежурные доступны. Переназначение не требуется.")
    return

for student_id in unavailable:
    # Поиск нового дежурного
    cursor.execute("""
        SELECT s.id, s.name, s.duty_count
        FROM students s
        LEFT JOIN attendance a ON s.id = a.student_id AND a.date = ?
        WHERE (a.status != 'N' OR a.status IS NULL)
        AND s.id NOT IN (SELECT student_id FROM attendance WHERE date = ? AND status = 'D')
        ORDER BY s.duty_count ASC, s.name ASC
        LIMIT 1
    """, (today, today))
    new_duty = cursor.fetchone()

    if new_duty:
        new_student_id = new_duty[0]
        cursor.execute("UPDATE students SET duty_count = duty_count + 1 WHERE id = ?", (new_student_id,))
        cursor.execute("INSERT INTO attendance (date, student_id, status) VALUES (?, ?, ?)", (today, new_student_id, 'D'))
        cursor.execute("DELETE FROM attendance WHERE date = ? AND student_id = ?", (today, student_id))
        conn.commit()
        # Предполагаем, что student_id соответствует chat_id (это может отличаться в вашей реализации)
        try:
            await application.bot.send_message(
                chat_id=new_student_id,  # Убедитесь, что chat_id и student_id совпадают
                text="🔄 Ваше дежурство было автоматически переназначено."
            )
            logger.info(f"Дежурство переназначено на студента ID {new_student_id}.")
        except Exception as e:
            logger.error(f"Ошибка при отправке уведомления студенту {new_student_id}: {e}")
    else:
        logger.warning(f"Не удалось найти замену для студента ID {student_id}.")
Обработка Callback Queries для выбора типа отчета и истории дежурств
application.add_handler(CallbackQueryHandler(handle_attendance_mark, pattern=r"^mark_[PNO]\d+$"))
application.add_handler(CallbackQueryHandler(finish_mark_attendance, pattern=r"^finish_mark_attendance$"))
application.add_handler(CallbackQueryHandler(handle_duty_buttons, pattern=r"^(swap_A|swap_B|swap_both)$"))
application.add_handler(CallbackQueryHandler(handle_duty_history, pattern=r"^duty_history\d+$"))
application.add_handler(CallbackQueryHandler(handle_report_selection_callback, pattern=r"^report_(daily|weekly|monthly|cancel)$"))
application.add_handler(CallbackQueryHandler(handle_remove_admin, pattern=r"^remove_admin_\d+$"))

Обработка автопереназначения дежурств через планировщик
def schedule_jobs(application):
scheduler.add_job(
send_daily_reminder,
'cron',
hour=8,
minute=0,
args=[application],
timezone='Europe/Moscow' # Установите ваш часовой пояс
)
scheduler.add_job(
send_weekly_summary,
'cron',
day_of_week='fri',
hour=18,
minute=0,
args=[application],
timezone='Europe/Moscow'
)
scheduler.add_job(
auto_reassign_duty_wrapper,
'cron',
hour=20,
minute=0, # Время после завершения отметки посещаемости
args=[application],
timezone='Europe/Moscow'
)
scheduler.start()
logger.info("Планировщик заданий запущен с ежедневными, еженедельными задачами и авто переназначением дежурств.")

Функция-обертка для переназначения дежурств
async def auto_reassign_duty_wrapper(application: ApplicationBuilder):
await auto_reassign_duty(application)

Запуск основной функции при запуске скрипта
if name == 'main':
main() [2024-09-27 21:25:55,227 - main - INFO - Столбец 'role' уже существует в таблице 'admins'.
2024-09-27 21:25:55,228 - tzlocal - DEBUG - Looking up time zone info from registry
2024-09-27 21:25:55,254 - matplotlib.pyplot - DEBUG - Loaded backend tkagg version 8.6.
Backend tkagg is interactive backend. Turning interactive mode on. , application.add_handler(MessageHandler(filters.Regex("^📈 Тренды посещаемости$"), attendance_trends))-name 'application' is not defined , Серьезность Код
Предупреждение (активно) reportMissingModuleSource Import "pandas" could not be resolved from source c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 9
Предупреждение (активно) reportMissingImports Import "apscheduler.schedulers.asyncio" could not be resolved c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 10
Предупреждение (активно) reportMissingImports Import "dotenv" could not be resolved c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 11
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1622
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1624
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1626
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1629
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1630
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1631
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1632
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1633
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1634
Предупреждение (активно) reportUndefinedVariable "duty_history_handler" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1681
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 1690
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 2085
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 2086
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 2087
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 2088
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 2089
Предупреждение (активно) reportUndefinedVariable "application" is not defined c:\Users\Admin\source\repos\bot_on_duty\bot_on_duty\bot_on_duty.py 2090 ] (предоставь исправленный код