Большое обновление календаря. Объединили модель событий, добавили полноценную повторяемость (hourly/daily/weekly/monthly/annual), материализацию вхождений в списке, навели порядок с правами отделов и обновили фронтовой шаблон. Покрыли API pytest-тестами и починили мелкие баги.
### Ключевые изменения
**Модель**
* Заменили `CompanyEvent` и `DepartmentEvent` на единую **`CalendarEvent`**:
* `department` (nullable FK) — если задан, событие отдела; если `NULL`, событие компании.
* Даты/время: `start_date`, `end_date`, `start_time`, `end_time`, `all_day`.
* Повторяемость: `recurrence` ∈ {`one_time`, `hourly`, `daily`, `weekly`, `monthly`, `annual`}.
* Параметры повторов: `recurrence_interval` (int>0), `recurrence_until` (date), `recurrence_count` (int), `weekdays_mask` (bitmask для weekly).
* Индексы под выборки и сортировку; check-constraint `cal_event_date_time_range_valid` (валидность диапазонов даты/времени; для одного дня `start_time <= end_time` при наличии обоих).
* Поля оформления: `title`, `description`, `color`, `location`, `created_by`, `created_at`.
* Правка по умолчаниям: `weekdays_mask` теперь с безопасным default=0 (исправляет `NOT NULL` в SQLite).
**Сервисы**
* Добавлен сервис `expand_event_occurrences(event, range_start, range_end, max_instances=1000)`:
* Материализует все вхождения события в окне `[range_start, range_end)`.
* Корректно обрабатывает: leap day (29 Feb), месяцы с «дырками» (31-е), еженедельные маски, ежечасные интервалы.
* Выдаёт ровные границы дат/датавремени для FullCalendar.
**Сериализаторы**
* `CalendarEventWriteSerializer` — строгая валидация:
* Для hourly требуется `start_time` и `end_time`.
* Для weekly — пустой/непустой `weekdays` валидируется и мапится в `weekdays_mask`.
* `recurrence_interval` > 0; взаимоисключение `recurrence_until` и `recurrence_count`.
* `CalendarEventSerializer` — read-вариант с плоскими полями для фронта.
* Доп. сериализатор вьюхи для списка не нужен — материализуем в контроллере (см. ниже).
**ViewSet / API**
* Новый единый роут: **`/api/v1/calendar/events/`** (`ModelViewSet`):
* `list`: возвращает **материализованные** вхождения в окне `[start, end)` (query-параметры `YYYY-MM-DD`). Если передан `department_id` — фильтрует события отдела, иначе — компании.
* `retrieve/create/update/partial_update/destroy`: операции над самим `CalendarEvent`.
* `get_queryset`: для `list` фильтрует по department/company, для detail — полный QS.
* При `create`: подставляет `department_id` либо из контекста/параметров, либо из body, проставляет `created_by`, чистит кеш.
* **Права**:
* Используется комбинированный пермишен **`ManageCalendarPerm(AdminOrDeptAllowed)`**.
* Для компании делегирует на `AdminOrActionOrModelPerms`.
* Для отдела требует **строковый код**: `"manage_department_events"`.
* Обновляет инстанс пользователя из БД (сбрасывает кэши), чтобы в одном тесте «выдали право → сразу работает».
* `get_permissions()` → `[IsAuthenticated(), ManageCalendarPerm()]`.
* Починки:
* Подключены `parse_date`/`status` где нужно.
* Исправлен конфликт дат/датавремени в материализации.
* Возврат полей в `list`: `start`, `end`, `allDay`, `color`, `recurrence`, `department_id` (ID родителя можно оставить как `event.id`).
**Signals**
* Добавлен сигнал на изменение `date_of_birth` сотрудника:
* Создаёт/обновляет/удаляет соответствующее ежегодное событие в **глобальном** календаре.
* Идентификация через `source="employee_birthday"` + `source_ref=<employee_id>`.
**Админка**
* `CalendarEventAdmin`:
* Поиск по названию/описанию/локации.
* Фильтры по отделу, типу повторения, all\_day, наличию времени, «company vs department».
* Инлайнов нет; list-представление упрощено (исправлены ошибки `admin.E116` — фильтры теперь валидные `SimpleListFilter`/поля).
**Фронт (шаблон правой колонки)**
* Переписан `<script>` под единый эндпоинт:
* Список: GET `/api/v1/calendar/events/?[department_id=...]&start=YYYY-MM-DD&end=YYYY-MM-DD`.
* Создание:
* Компания: POST `/api/v1/calendar/events/` с `{title, start_date, [end_date], all_day}`.
* Отдел: POST туда же, но с `department_id` (в query или в body).
* Если указано время — **оба** (`start_time`, `end_time`) обязательны; иначе сохраняем как all-day.
* Нормализация дат в локали, аккуратные конвертации для FullCalendar.
* Заголовки авторизации: `Authorization: Bearer <token>` из `<meta name="api-access">`/`localStorage.api.access`. Добавлен fallback на CSRF-куку при отсутствии токена (для SessionAuth).
* Улучшен UI выбора отдела (`/api/v1/departments/my-departments/`), подписи, недельная лента.
**Тесты**
* Добавлены/актуализированы pytest-тесты API:
* Роуты и методы (CRUD).
* Позитив/негатив создания (валидация дат/времени/повторов).
* Изменение повторяемости (weekly → daily и пр.).
* Материализация (hourly alignment, weekly mask, monthly edge-cases, leap day).
* Права: отдел без/с `manage_department_events` (фикс за счёт строкового кода).
* Сигналы: апсерт/удаление дней рождения.
* Исправлены прежние падения: `NOT NULL weekdays_mask`, `NameError(parse_date/status)`, сравнения date/datetime, несоответствие `all_day` vs `allDay`, и др.
### Совместимость и заметки по деплою
* **BREAKING**: единый эндпоинт `/api/v1/calendar/events/` заменяет старые `/company-events/` и `/department-events/`. Если есть сторонние клиенты — переориентировать на новый.
* Миграции обязательны (новая таблица, индексы, дефолты).
* Если где-то ещё использовались константы прав — убедиться, что везде строка `"manage_department_events"` (тесты ориентируются на неё).
* Для фронта: убедиться, что реально прокидывается access-токен в `<meta name="api-access">` **или** есть валидная CSRF-кука при SessionAuth. Иначе POST вернёт 401.
### Почему так
* Единая модель/роут упрощают код и устраняют разъезд схем компании/отделов.
* Материализация в контроллере избавляет фронт от логики повторов и держит поведение единообразным.
* Явная строка кода права синхронизирована с тестовой выдачей пермишена и реальным хранилищем прав отделов.
* Валидация сериализатора закрывает большую часть негативных кейсов на уровне API.
442 lines
15 KiB
Python
442 lines
15 KiB
Python
import os
|
||
import logging
|
||
from datetime import timedelta
|
||
|
||
from dotenv import load_dotenv
|
||
|
||
import django
|
||
from django.conf import settings
|
||
from django.db import models
|
||
from django.utils import timezone
|
||
|
||
from aiogram import Bot, Dispatcher, types, F
|
||
from aiogram.filters import CommandStart
|
||
from aiogram.types import (
|
||
InlineKeyboardMarkup, InlineKeyboardButton,
|
||
ReplyKeyboardMarkup, KeyboardButton,
|
||
FSInputFile,
|
||
)
|
||
from aiogram.exceptions import TelegramBadRequest
|
||
from asgiref.sync import sync_to_async
|
||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||
|
||
from django.contrib.auth import get_user_model
|
||
|
||
from calendar_app.models import CalendarEvent
|
||
from bots.models import BotSubscriber
|
||
from bots.filters import IsBoundSubscriber_tg, IsEmployeeActive
|
||
from documents.models import Document, DocumentAcknowledgement
|
||
|
||
# Если запускается вне manage.py, настраиваем Django
|
||
if not settings.configured:
|
||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
|
||
django.setup()
|
||
|
||
# Логирование
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Загрузка .env
|
||
load_dotenv()
|
||
BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
|
||
|
||
# Инициализация бота и диспетчера
|
||
bot = Bot(token=BOT_TOKEN)
|
||
dp = Dispatcher()
|
||
|
||
# Модель сотрудника
|
||
Employee = get_user_model()
|
||
|
||
# Отделы для inline‑меню
|
||
DEPARTMENTS = {
|
||
"department_manager": {
|
||
"title": "Менеджеры",
|
||
"groups": [
|
||
{"name": "Группа 1", "link": "https://t.me/manager_group"},
|
||
{"name": "Рабочий чат", "link": "https://t.me/manager_workchat"},
|
||
],
|
||
},
|
||
"department_tech": {
|
||
"title": "Технари",
|
||
"groups": [
|
||
{"name": "Группа 1", "link": "https://t.me/tech_group"},
|
||
{"name": "Рабочий чат", "link": "https://t.me/tech_workchat"},
|
||
],
|
||
},
|
||
}
|
||
|
||
# — Клавиатуры —
|
||
|
||
|
||
def get_contact_kb() -> ReplyKeyboardMarkup:
|
||
return ReplyKeyboardMarkup(
|
||
keyboard=[[
|
||
KeyboardButton(text="📲 Поделиться контактом", request_contact=True)
|
||
]],
|
||
resize_keyboard=True,
|
||
one_time_keyboard=True,
|
||
)
|
||
|
||
|
||
def get_department_kb() -> InlineKeyboardMarkup:
|
||
buttons = [
|
||
InlineKeyboardButton(text=dept["title"], callback_data=key)
|
||
for key, dept in DEPARTMENTS.items()
|
||
]
|
||
return InlineKeyboardMarkup(inline_keyboard=[buttons])
|
||
|
||
|
||
def main_menu_kb() -> InlineKeyboardMarkup:
|
||
return InlineKeyboardMarkup(inline_keyboard=[[
|
||
InlineKeyboardButton(text="📅 Календарь",
|
||
callback_data="menu_calendar"),
|
||
InlineKeyboardButton(text="📊 Статус", callback_data="menu_status"),
|
||
InlineKeyboardButton(text="📄 Документы", callback_data="menu_docs"),
|
||
]])
|
||
|
||
|
||
def department_menu_kb() -> InlineKeyboardMarkup:
|
||
return InlineKeyboardMarkup(inline_keyboard=[
|
||
[InlineKeyboardButton(text="📅 На сегодня",
|
||
callback_data="cal_today")],
|
||
[InlineKeyboardButton(text="🗓 На неделю",
|
||
callback_data="cal_week")],
|
||
[InlineKeyboardButton(text="📆 На месяц",
|
||
callback_data="cal_month")],
|
||
[InlineKeyboardButton(text="◀️ Главное меню",
|
||
callback_data="go_main")],
|
||
])
|
||
|
||
|
||
async def safe_edit(
|
||
call: types.CallbackQuery,
|
||
text: str,
|
||
inline_kb: InlineKeyboardMarkup | None = None,
|
||
**kwargs
|
||
):
|
||
"""
|
||
Редактирует call.message:
|
||
- если передан inline_kb — edit_text,
|
||
- иначе — answer.
|
||
"""
|
||
msg = call.message
|
||
try:
|
||
if inline_kb:
|
||
await msg.edit_text(text, reply_markup=inline_kb, **kwargs)
|
||
else:
|
||
await msg.answer(text, **kwargs)
|
||
except TelegramBadRequest as e:
|
||
await call.answer(text=f"Загрузка...", cache_time=10)
|
||
# await msg.answer(text, reply_markup=inline_kb, **kwargs)
|
||
|
||
# — ORM‑хелперы —
|
||
|
||
|
||
@sync_to_async
|
||
def find_employee_by_phone(phone: str):
|
||
return Employee.objects.filter(telegram__endswith=phone[-10:]).first()
|
||
|
||
|
||
@sync_to_async
|
||
def find_employee_by_username(username: str):
|
||
uname = username.lower().lstrip('@')
|
||
return Employee.objects.filter(
|
||
models.Q(telegram__iexact=uname) |
|
||
models.Q(telegram__iexact='@' + uname)
|
||
).first()
|
||
|
||
|
||
@sync_to_async
|
||
def bind_telegram(user, tg_id: int):
|
||
BotSubscriber.objects.filter(telegram_id=tg_id).exclude(user=user)\
|
||
.update(telegram_id=None)
|
||
return BotSubscriber.objects.update_or_create(
|
||
user=user, defaults={"telegram_id": tg_id}
|
||
)
|
||
|
||
|
||
@sync_to_async
|
||
def _get_subscriber_user(tg_id: int):
|
||
sub = BotSubscriber.objects.select_related("user")\
|
||
.filter(telegram_id=tg_id).first()
|
||
return sub.user if sub and sub.user else None
|
||
|
||
|
||
@sync_to_async
|
||
def _get_user_documents(user):
|
||
from django.db.models import Q
|
||
return list(
|
||
Document.objects.filter(Q(sent_to_all=True) | Q(recipients=user))
|
||
.distinct().order_by('-uploaded_at')
|
||
)
|
||
|
||
|
||
@sync_to_async
|
||
def _has_acknowledged(user, document):
|
||
return DocumentAcknowledgement.objects.filter(user=user, document=document).exists()
|
||
|
||
|
||
@sync_to_async
|
||
def _acknowledge(user, document):
|
||
return DocumentAcknowledgement.objects.get_or_create(user=user, document=document)
|
||
|
||
|
||
@sync_to_async
|
||
def _get_one_time_events(date):
|
||
return list(CalendarEvent.objects.filter(
|
||
recurrence=CalendarEvent.ONE_TIME, date=date
|
||
))
|
||
|
||
|
||
@sync_to_async
|
||
def _get_annual_events():
|
||
return list(CalendarEvent.objects.filter(recurrence=CalendarEvent.ANNUAL))
|
||
|
||
|
||
@sync_to_async
|
||
def _get_subscribers():
|
||
return list(BotSubscriber.objects.filter(telegram_id__isnull=False))
|
||
|
||
# — Ежедневная рассылка —
|
||
|
||
|
||
async def send_daily_notifications():
|
||
today = timezone.localdate()
|
||
one_time = await _get_one_time_events(today)
|
||
annual_all = await _get_annual_events()
|
||
annual_today = [
|
||
ev for ev in annual_all
|
||
if (ev.date.month, ev.date.day) == (today.month, today.day)
|
||
]
|
||
events = one_time + annual_today
|
||
if not events:
|
||
logger.info("Нет событий на сегодня.")
|
||
return
|
||
|
||
subs = await _get_subscribers()
|
||
for sub in subs:
|
||
for ev in events:
|
||
text = (
|
||
f"📅 *{ev.title}*\n"
|
||
f"Дата: `{ev.date:%d.%m.%Y}`\n"
|
||
f"{ev.description or ''}"
|
||
)
|
||
try:
|
||
await bot.send_message(
|
||
sub.telegram_id,
|
||
text,
|
||
parse_mode="Markdown",
|
||
)
|
||
except:
|
||
logger.exception(f"Не удалось отправить {sub.telegram_id}")
|
||
|
||
scheduler = AsyncIOScheduler(timezone=settings.TIME_ZONE)
|
||
scheduler.add_job(send_daily_notifications, "cron", hour=9, minute=0)
|
||
|
||
|
||
async def on_startup_dispatcher():
|
||
logger.info("Запуск APScheduler…")
|
||
scheduler.start()
|
||
|
||
dp.startup.register(on_startup_dispatcher)
|
||
|
||
# — Хендлеры бота —
|
||
|
||
|
||
@dp.message(CommandStart())
|
||
async def cmd_start(msg: types.Message):
|
||
await msg.answer(
|
||
"Здравствуйте! Чтобы подключиться, пожалуйста, поделитесь контактом:",
|
||
reply_markup=get_contact_kb()
|
||
)
|
||
|
||
|
||
@dp.message(lambda m: m.contact is not None)
|
||
async def on_contact(msg: types.Message):
|
||
phone = msg.contact.phone_number
|
||
tg_id = msg.from_user.id
|
||
user = (
|
||
await find_employee_by_phone(phone)
|
||
or (msg.from_user.username and await find_employee_by_username(msg.from_user.username))
|
||
)
|
||
if not user:
|
||
return await msg.answer("❌ Профиль не найден.")
|
||
await bind_telegram(user, tg_id)
|
||
await msg.answer(
|
||
f"✅ Спасибо, {user.first_name}! Выберите отдел:",
|
||
reply_markup=get_department_kb()
|
||
)
|
||
|
||
|
||
@dp.callback_query(F.data.startswith("department_"), IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def department_selected(call: types.CallbackQuery):
|
||
dept = DEPARTMENTS.get(call.data)
|
||
text = (
|
||
f"*{dept['title']}*:\n" +
|
||
"\n".join(f"• [{g['name']}]({g['link']})" for g in dept["groups"])
|
||
) if dept else "Неизвестный отдел."
|
||
await safe_edit(call, text, inline_kb=main_menu_kb(), parse_mode="Markdown")
|
||
await call.answer()
|
||
|
||
|
||
@dp.callback_query(F.data == "menu_calendar", IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def menu_calendar(call: types.CallbackQuery):
|
||
await safe_edit(call, "Выберите период для просмотра:", inline_kb=department_menu_kb())
|
||
await call.answer()
|
||
|
||
|
||
@dp.callback_query(F.data == "menu_status", IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def menu_status(call: types.CallbackQuery):
|
||
user = await _get_subscriber_user(call.from_user.id)
|
||
status = await sync_to_async(lambda u: u.employment_status)(user) if user else "неизвестен"
|
||
text = f"Ваш статус: *{status}*"
|
||
await safe_edit(call, text, inline_kb=main_menu_kb(), parse_mode="Markdown")
|
||
# await call.answer(cache_time=60, text="Смотрите статус в сообщении")
|
||
|
||
|
||
@dp.callback_query(F.data == "cal_today", IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def calendar_today(call: types.CallbackQuery):
|
||
today = timezone.localdate()
|
||
evs = (await _get_one_time_events(today)) + [
|
||
ev for ev in await _get_annual_events()
|
||
if (ev.date.month, ev.date.day) == (today.month, today.day)
|
||
]
|
||
text = ("Событий на сегодня нет." if not evs
|
||
else "📅 События сегодня:\n" + "\n".join(f"• {e.title} ({e.date:%d.%m})" for e in evs))
|
||
kb = InlineKeyboardMarkup(inline_keyboard=[[
|
||
InlineKeyboardButton(text="◀️ Главное меню", callback_data="go_main")
|
||
]])
|
||
await safe_edit(call, text, inline_kb=kb)
|
||
await call.answer()
|
||
|
||
|
||
@dp.callback_query(F.data == "cal_week", IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def calendar_week(call: types.CallbackQuery):
|
||
start, end = timezone.localdate(), timezone.localdate() + timedelta(days=6)
|
||
one = await _get_one_time_events(start)
|
||
ann = await _get_annual_events()
|
||
evs = one + [
|
||
ev for ev in ann
|
||
if (start.month, start.day) <= (ev.date.month, ev.date.day) <= (end.month, end.day)
|
||
]
|
||
text = ("Событий на эту неделю нет." if not evs
|
||
else f"📅 С {start:%d.%m} по {end:%d.%m}:\n" + "\n".join(f"• {e.title} ({e.date:%d.%m})" for e in evs))
|
||
kb = InlineKeyboardMarkup(inline_keyboard=[[
|
||
InlineKeyboardButton(text="◀️ Главное меню", callback_data="go_main")
|
||
]])
|
||
await safe_edit(call, text, inline_kb=kb)
|
||
await call.answer()
|
||
|
||
|
||
@dp.callback_query(F.data == "cal_month", IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def calendar_month(call: types.CallbackQuery):
|
||
today = timezone.localdate()
|
||
start = today.replace(day=1)
|
||
nm = start.replace(month=(start.month % 12) + 1,
|
||
year=start.year + (start.month // 12))
|
||
end = nm - timedelta(days=1)
|
||
one = await _get_one_time_events(start)
|
||
ann = await _get_annual_events()
|
||
evs = one + [
|
||
ev for ev in ann
|
||
if (start.month, start.day) <= (ev.date.month, ev.date.day) <= (end.month, end.day)
|
||
]
|
||
text = ("Событий в этом месяце нет." if not evs
|
||
else f"📅 {today:%B %Y}:\n" + "\n".join(f"• {e.title} ({e.date:%d.%m})" for e in evs))
|
||
kb = InlineKeyboardMarkup(inline_keyboard=[[
|
||
InlineKeyboardButton(text="◀️ Главное меню", callback_data="go_main")
|
||
]])
|
||
await safe_edit(call, text, inline_kb=kb)
|
||
await call.answer()
|
||
|
||
|
||
@dp.callback_query(F.data == "go_main", IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def go_main(call: types.CallbackQuery):
|
||
await safe_edit(call, "Главное меню:", inline_kb=main_menu_kb())
|
||
await call.answer()
|
||
|
||
# — Документы через бот —
|
||
|
||
|
||
@dp.callback_query(F.data == "menu_docs", IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def menu_docs(call: types.CallbackQuery):
|
||
user = await _get_subscriber_user(call.from_user.id)
|
||
docs = await _get_user_documents(user) if user else []
|
||
if not docs:
|
||
text = "У вас нет доступных документов."
|
||
kb = InlineKeyboardMarkup(inline_keyboard=[[
|
||
InlineKeyboardButton(text="◀️ Главное меню",
|
||
callback_data="go_main")
|
||
]])
|
||
else:
|
||
text = "📄 Ваши документы:\nВыберите для просмотра:"
|
||
rows = [
|
||
[InlineKeyboardButton(
|
||
text=d.title[:30] + ("…" if len(d.title) > 30 else ""),
|
||
callback_data=f"doc_{d.id}"
|
||
)]
|
||
for d in docs[:5]
|
||
]
|
||
rows.append([InlineKeyboardButton(
|
||
text="◀️ Главное меню", callback_data="go_main")])
|
||
kb = InlineKeyboardMarkup(inline_keyboard=rows)
|
||
|
||
await safe_edit(call, text, inline_kb=kb)
|
||
await call.answer()
|
||
|
||
|
||
@dp.callback_query(F.data.startswith("doc_"), IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def show_document(call: types.CallbackQuery):
|
||
user = await _get_subscriber_user(call.from_user.id)
|
||
doc_id = int(call.data.split("_", 1)[1])
|
||
doc = await sync_to_async(Document.objects.get)(pk=doc_id)
|
||
|
||
caption = (
|
||
f"*{doc.title}*\n\n"
|
||
f"{doc.description or ''}\n\n"
|
||
f"Загружен: {doc.uploaded_at:%d.%m.%Y %H:%M}"
|
||
)
|
||
already = await _has_acknowledged(user, doc)
|
||
|
||
rows = []
|
||
if not already:
|
||
rows.append([InlineKeyboardButton(
|
||
text="✅ Ознакомился", callback_data=f"ack_{doc.id}")])
|
||
rows.append([InlineKeyboardButton(
|
||
text="◀️ Документы", callback_data="menu_docs")])
|
||
kb = InlineKeyboardMarkup(inline_keyboard=rows)
|
||
|
||
file_input = FSInputFile(
|
||
path=doc.file.path, filename=os.path.basename(doc.file.name))
|
||
await bot.send_document(
|
||
chat_id=call.from_user.id,
|
||
document=file_input,
|
||
caption=caption,
|
||
parse_mode="Markdown",
|
||
reply_markup=kb
|
||
)
|
||
await call.answer()
|
||
|
||
|
||
@dp.callback_query(F.data.startswith("ack_"), IsBoundSubscriber_tg(), IsEmployeeActive())
|
||
async def ack_document(call: types.CallbackQuery):
|
||
user = await _get_subscriber_user(call.from_user.id)
|
||
doc_id = int(call.data.split("_", 1)[1])
|
||
await _acknowledge(user, await sync_to_async(Document.objects.get)(pk=doc_id))
|
||
|
||
old = call.message.reply_markup.inline_keyboard
|
||
new = [row for row in old if not any(
|
||
btn.callback_data == f"ack_{doc_id}" for btn in row)]
|
||
kb = InlineKeyboardMarkup(inline_keyboard=new)
|
||
|
||
await call.message.edit_reply_markup(reply_markup=kb)
|
||
await call.answer("Ознакомление зафиксировано.")
|
||
|
||
|
||
def run_telegram_bot():
|
||
dp.run_polling(bot)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run_telegram_bot()
|