Введение
Разберемся с начала, что это за статья зачем она и для кого. Пришлось мне в рамках хакатона "Поколение ИТ" писать бота для телеги.
Но готового решения для пагинации, которое бы нам подходило мы не нашли. Поэтому было принято решение изобретать велосипед. Решение моих товарищей было максимально странным, брать количество записей и перебирать их в цикле от 1 до N (конца, записей), но данная идея сразу была отброшена. Поэтому предоставляю вашему вниманию наше творчество, которое мы изобрели.
Постараюсь максимально просто и в полной мере описать, как мы собирали эту балалайку. Извинюсь сразу, если статья слишком нудная, это моя первая статья.
Покажу для начала, что мы вообще сделали и о чем пойдет речь. Времени было мало, и нужен был алгоритм, который позволит быстро и просто реализовать пагинацию под разные таблицы.
Пустой шаблон бота
Для простоты описания вся суть алгоритма будет описана на шаблоном боте, который будет отвечать на любое сообщение. Заглушкой ответа на данном этапе будет сообщение "Привет"
import telebot;
bot = telebot.TeleBot('Токен');
@bot.message_handler(content_types=['text'])
def start(m):
bot.send_message(m.from_user.id, "Привет");
if __name__ == '__main__':
bot.polling(none_stop=True)
Проверили, все работает. А значит идем дальше.
Создание inline кнопок
Первым делом в начале кода пропишем:
from telebot.types import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
Кнопка скрыть
Теперь добавим inline кнопку скрыть, для удаления сообщения от бота.
Перед тем, как отправить сообщение пользователю создадим markup с inline кнопкой "скрыть" после чего отправим пользователю сообщение с данной кнопкой.
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen'))
bot.send_message(m.from_user.id, "Привет", reply_markup = markup)
Чтобы кнопка работала, нам для нее нужен отдельный обработчик он выглядит так:
@bot.callback_query_handler(func=lambda call:True)
def callback_query(call):
req = call.data.split('_')
if req[0] == 'unseen':
bot.delete_message(call.message.chat.id, call.message.message_id)
Необходимо учитывать, что здесь может возникнуть эксепшн, т.к у телеграмма есть ограничения на удаление. Например старые сообщения бот не сможет удалить. Поэтому нужно писать отдельное исключение. Но цель моей статьи заключается в том, что бы показать мой подход к демонстрации алгоритма пагинации.
Снова проверяем:
При нажатии на кнопку скрыть сообщение удаляется, а значит не будем зацикливаться больше на этом.
Кнопки навигации
Теперь перейдем непосредственно к кнопкам "Вперд" и "Назад" для перехода по страницам? которые будут располагаться под кнопкой "скрыть".
К уже существующему markup добавим еще две новые кнопки. Т.к при первом сообщение от бота приходит первая страница, добавим пока что только кнопку "Вперед".
Кнопка вперед будет в callback_data отправлять строку 'next-page', а в обработчике мы будем прибавлять к page 1. После чего пересоздадим markap уже с кнопкой назад и новым сообщением. Аналогичным образом для кнопки назад в callback_data будем отправлять строку 'back-page'
Теперь наш код выглядит вот так:
На данном этапе для простоты демонстрации я делаю count и page глобальными переменными, позже я заменю их.
import telebot;
from telebot.types import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
bot = telebot.TeleBot('token')
page = 1
count = 10
@bot.callback_query_handler(func=lambda call:True)
def callback_query(call):
req = call.data.split('_')
global count
global page
#Обработка кнопки - скрыть
if req[0] == 'unseen':
bot.delete_message(call.message.chat.id, call.message.message_id)
#Обработка кнопки - вперед
elif req[0] == 'next-page':
if page < count:
page = page + 1
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen'))
markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data=f'back-page'),InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->', callback_data=f'next-page'))
bot.edit_message_text(f'Страница {page} из {count}', reply_markup = markup, chat_id=call.message.chat.id, message_id=call.message.message_id)
#Обработка кнопки - назад
elif req[0] == 'back-page':
if page > 1:
page = page - 1
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen'))
markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data=f'back-page'),InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->', callback_data=f'next-page'))
bot.edit_message_text(f'Страница {page} из {count}', reply_markup = markup, chat_id=call.message.chat.id, message_id=call.message.message_id)
#Обработчик входящих сообщений
@bot.message_handler(content_types=['text'])
def start(m):
global count
global page
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen'))
markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->', callback_data=f'next-page'))
bot.send_message(m.from_user.id, "Привет!!!", reply_markup = markup)
if __name__ == '__main__':
bot.polling(none_stop=True)
Теперь мы можем перемещаться по страницам:
Да, но перед нами встает две проблемы:
Нужно писать отдельные исключения, чтобы при возврате кнопкой назад на первую страницу - кнопка назад больше не отображалась. А при переходе на последнюю не было кнопки вперед.
Проблема передачи локальной переменной (page и count) от главной функции в обработчик нажатия кнопки. Telebot в отличие от например aiogramm не может передать другие параметры вместе с callback_data. А callback_data – это строка. Решение этой проблемы, я увидел в передаче в callback_data склеенного через разделитель json в который и запишу count и page. Мой товарищ вышел из данной ситуации более странным на мой взгляд решением – он записывал во временную таблицу бд id узера, id сообщение и страницу, которую он смотрит и потом удалял их. Достаточно радикальный способ по ряду многих причин (что если не удалиться запись из БД; БД вообще не для этого сделана; нам нужно будет столько таблиц, во скольких местах будет пагинация), но переубедить я его не смог ).
Как вариант еще можно создать публичный словарь, но ради двух переменных это странно + способ с передачей json с двумя параметрами, как строку в callback_data на мой взгляд кажется самым универсальным и адекватным решением при данной проблеме.
Для начала решим 2 проблему
Удалим глобальные переменный page и count и создадим их внутри функции start
В callback будем отправлять такую строку:
{'method': 'page', 'NumberPage': 1, 'CountPage': 10}
Для кнопки вперед, это выглядит вот так:
markup.add(InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}"))
Для кнопки назад, это выглядит, так:
markup.add(InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page-1) + ",\"CountPage\":" + str(count) + "}"))
На данном этапе видно, что я начал отдавать номер строки, на которую перехожу. Если нажму "Назад" со второй строки в обработчик уйдет 1. Это избавляет меня от необходимости использовать теперь разные обработчик для кнопки назад (back-page
) и вперед (next-page
) (их можно просто удалить)
Теперь за пагинацию будет отвечать новый обработчик, который увидит в полученной строке вхождение 'pagination'
:
После чего строка полученная в callback_data будет распаршена в json. И уже из JSON мы получим необходимые нам Count и Page.
elif 'pagination' in req[0]:
json_string = json.loads(req[0])
count = json_string['CountPage']
page = json_string['NumberPage']
Пока пишу новый обработчик, сразу решу проблему 1. Сделаю три условия вывода кнопок.
Вывод кнопки вперед, для первой страницы
Вывод "Вперед" и "Назад" для всех страниц между первой и последней
Вывод кнопки "Назад" для последний страницы
Вот, что получилось:
А вот и полученный код:
import json
import telebot;
from telebot.types import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
bot = telebot.TeleBot('TOKEN')
@bot.callback_query_handler(func=lambda call:True)
def callback_query(call):
req = call.data.split('_')
#Обработка кнопки - скрыть
if req[0] == 'unseen':
bot.delete_message(call.message.chat.id, call.message.message_id)
#Обработка кнопок - вперед и назад
elif 'pagination' in req[0]:
#Расспарсим полученный JSON
json_string = json.loads(req[0])
count = json_string['CountPage']
page = json_string['NumberPage']
#Пересоздаем markup
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen'))
#markup для первой страницы
if page == 1:
markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->',
callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(
page + 1) + ",\"CountPage\":" + str(count) + "}"))
#markup для второй страницы
elif page == count:
markup.add(InlineKeyboardButton(text=f'<--- Назад',
callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(
page - 1) + ",\"CountPage\":" + str(count) + "}"),
InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '))
#markup для остальных страниц
else:
markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page-1) + ",\"CountPage\":" + str(count) + "}"),
InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}"))
bot.edit_message_text(f'Страница {page} из {count}', reply_markup = markup, chat_id=call.message.chat.id, message_id=call.message.message_id)
@bot.message_handler(content_types=['text'])
def start(m):
count = 10
page = 1
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen'))
markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}"))
bot.send_message(m.from_user.id, "Привет!!!", reply_markup = markup)
if __name__ == '__main__':
bot.polling(none_stop=True)
Желательно в обработчик добавить исключение, что page > 0 и page <=count, а так же строку c bot.edit_message_text занести в блок try. На случай, если телеграмм зависнет и пользователь, сможет сделать двойной клик по одной кнопке. Но статья направлена на описания подхода реализации. Поэтому на этом я не буду останавливаться.
Получения нужных строк по страницам из БД
Вернемся к "главной" функции start, которая принимает сообщения от пользователя. Я оставил count = 10 и page = 1; Пора это исправить!
count - необходимо будет получать из БД, а в сообщение пользователю отдавать необходимые строки.
Для этого создадим новый класс database, который будет отвечать за подключение к БД и вывод нужных строк из БД.
Что бы выводить построчно записи из БД я буду использовать конструкцию SQL: OFFSET-FETCH
.Она предназначена, как раз для разбиения результирующего набора на части (страницы)
У меня завалялась таблица учебных организаций Москвы. На ней и покажу, как это выглядит:
Делаем сортировку по id и отображаем 15 записей от 0 (т.е самой первой). Это будет наша первая страница, что бы показать 2 страницу нужно будет пропустить столько записей, сколько было на первой странице.
Таким образом число после NEXT – является всегда статичным это будет переменная SkipSize. А число после OFFSET - то после которой надо забрать следующие (SkipSize) строк. Записать это можно, как (Номер страницы – 1)*SkipSize
На псевдо-коде это выглядит вот так:
Теперь перейдем к написанию самого класса. Он вышел вот таким:
import psycopg2
from psycopg2 import sql
from psycopg2._psycopg import AsIs
class Database:
def __init__(self):
self.conn = psycopg2.connect(database='myDataBase', user='MyUsers',
password=SECRET', host='ip_host', port=5432)
self.cursor = self.conn.cursor()
#Функцию пытался сделать максимально адаптивной под разные потребности и таблицы, поэтому у нее есть такие параметры, как:
# tables – имя самой таблицы
# schema – схема, по умолчанию organization т.к большая часть таблиц лежит именно в этой схеме
# Page – непосредственно номер страницы, который нужно вывести
# SkipSize – сколько строк, необходимо вывести
# order – аргумент, по которому происходит сортировка
# where строка в которую можно передать строку where (по хорошему так делать не надо, это слишком костыльно)
def listColledjeForPage(self, tables, order, schema='organization', Page=1, SkipSize=1, wheres=''):
sql = f"""select * from %(schemas)s.%(tables)s o
%(wheres)s
ORDER BY o.%(orders)s
OFFSET %(skipsPage)s ROWS FETCH NEXT %(SkipSizes)s ROWS only;"""
self.cursor.execute(sql, {'schemas': AsIs(schema), 'tables': AsIs(tables), 'orders': AsIs(order),
'skipsPage': ((Page - 1) * SkipSize), 'SkipSizes': SkipSize, 'wheres': AsIs(wheres)})
res = self.cursor.fetchall()
return res, len(res)
Теперь в main нам надо объявить наш новый класс:
from database import Database
database = Database()
А вот так к нему можно обратиться:
stringsearch = 'колледж связи'
sqlTransaction = database.listColledjeForPage(tables = 'organization', order='title_full', Page=1, SkipSize=1, wheres=f"where lower(title_full) like lower('%{stringsearch}%') OR lower(title) like lower('%{stringsearch}%')")
data = sqlTransaction[0] #Набор строк
count = sqlTransaction[1] #Количество строк
print(data)
print(count)
Или вот так:
sqlTransaction = database.listColledjeForPage(tables = 'organization', order='title_full', Page=1, SkipSize=15)
data = sqlTransaction[0] # Набор строк
count = sqlTransaction[1] # Количество строк
print(data)
print(count)
Вот такой вывод получаем по итогу:
И тут я понял, что общие количество записей так и не получил (тот самый count). В return к функции (listColledjeForPage) вывода записей добавлю еще вывод общего количества записей в запросе.
self.cursor.execute(f"""select Count(*) from %(schemas)s.%(tables)s o %(wheres)s;""",
{'schemas': AsIs(schema), 'tables': AsIs(tables),'wheres': AsIs(wheres)})
count = self.cursor.fetchone()[0]
return res, len(res), count
Я еще раз убедился, что все работает и пошел дописывать main
Сделаем вывод уже полученной строки из нашего нового класса. В "главной функции" переменную page оставляем равной 1, а count получаем из функции класса database, которая выполняет нужный нам запрос.
page = 1
sqlTransaction = database.listColledjeForPage(tables = 'organization', order='title', Page=1, SkipSize=1) # SkipSize - т.к я буду отображать по одной записи
data = sqlTransaction[0] # Набор строк
count = sqlTransaction[2] # Количество строк
print()
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen'))
markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}"))
bot.send_message(m.from_user.id, str(data[0]), reply_markup = markup)
Вот, что получили:
Супер, мне нравится осталось только вывести этот текст в отформатированном виде и сделать такой же вывод в обработчики, который отвечает за перелистывание страниц вперед, назад.
Делаем форматирование сообщения с помощью HTML
Осталось причесать наш вывод:
bot.send_message(m.from_user.id, f'<b>{data[3]}</b>\n\n'
f'<b>Короткое название:</b> <i>{data[4]}</i>\n'
f'<b>Email:</b><i>{data[6]}</i>\n'
f'<b>Сайт:</b><i> {data[8]}</i>',
parse_mode="HTML", reply_markup = markup)
Теперь такое же форматирование вставляем в обработчик при редактировании сообщения после нажатия кнопок "Впред"/"Назад".
Вот что получилось по итогу:
А вот итоговый код:
main.py
import json
import telebot;
from telebot.types import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
from database import Database
database = Database()
bot = telebot.TeleBot('token')
@bot.callback_query_handler(func=lambda call:True)
def callback_query(call):
req = call.data.split('_')
if req[0] == 'unseen':
bot.delete_message(call.message.chat.id, call.message.message_id)
elif 'pagination' in req[0]:
json_string = json.loads(req[0])
count = json_string['CountPage']
page = json_string['NumberPage']
sqlTransaction = database.listColledjeForPage(tables='organization', order='title', Page=page,
SkipSize=1) # SkipSize - т.к я буду отображать по одной записи
data = sqlTransaction[0][0]
count = sqlTransaction[2]
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen'))
if page == 1:
markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->',
callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(
page + 1) + ",\"CountPage\":" + str(count) + "}"))
elif page == count:
markup.add(InlineKeyboardButton(text=f'<--- Назад',
callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(
page - 1) + ",\"CountPage\":" + str(count) + "}"),
InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '))
else:
markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page-1) + ",\"CountPage\":" + str(count) + "}"),
InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}"))
bot.edit_message_text(f'<b>{data[3]}</b>\n\n'
f'<b>Короткое название:</b> <i>{data[4]}</i>\n'
f'<b>Email:</b><i>{data[6]}</i>\n'
f'<b>Сайт:</b><i> {data[8]}</i>',
parse_mode="HTML",reply_markup = markup, chat_id=call.message.chat.id, message_id=call.message.message_id)
@bot.message_handler(content_types=['text'])
def start(m):
page = 1
sqlTransaction = database.listColledjeForPage(tables = 'organization', order='title', Page=page, SkipSize=1) # SkipSize - т.к я буду отображать по одной записи
data = sqlTransaction[0][0] # Набор строк
count = sqlTransaction[2] # Количество строк
print()
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen'))
markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '),
InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}"))
bot.send_message(m.from_user.id, f'<b>{data[3]}</b>\n\n'
f'<b>Короткое название:</b> <i>{data[4]}</i>\n'
f'<b>Email:</b><i>{data[6]}</i>\n'
f'<b>Сайт:</b><i> {data[8]}</i>',
parse_mode="HTML", reply_markup = markup)
if __name__ == '__main__':
bot.polling(none_stop=True)
database.py
import psycopg2
from psycopg2 import sql
from psycopg2._psycopg import AsIs
class Database:
def __init__(self):
self.conn = psycopg2.connect(database='MyDataBase', user='MyUser',
password='SECRET', host='MeServ', port=5432)
self.cursor = self.conn.cursor()
def listColledjeForPage(self, tables, order, schema='organization', Page=1, SkipSize=1, wheres=''):
sql = f"""select * from %(schemas)s.%(tables)s o
%(wheres)s
ORDER BY o.%(orders)s
OFFSET %(skipsPage)s ROWS FETCH NEXT %(SkipSizes)s ROWS only;"""
self.cursor.execute(sql, {'schemas': AsIs(schema), 'tables': AsIs(tables), 'orders': AsIs(order),
'skipsPage': ((Page - 1) * SkipSize), 'SkipSizes': SkipSize, 'wheres': AsIs(wheres)})
res = self.cursor.fetchall()
self.cursor.execute(f"""select Count(*) from %(schemas)s.%(tables)s o %(wheres)s;""",
{'schemas': AsIs(schema), 'tables': AsIs(tables),'wheres': AsIs(wheres)})
count = self.cursor.fetchone()[0]
return res, len(res), count
Комментарии (8)
SerGeRybakov
06.07.2022 20:03Интересно, а чем вас вот этот проект не устроил?
DanyByLuckyCraft Автор
06.07.2022 20:05Я библиотеку эту нашел, после хакатона только.
Мне нужен универсальный вариант, в который можно было пихать мой поиск. И возможность быстро переделывать под себя
Не нравятся сама реализация, а что бы переделать под себя пришлось бы разбираться и править саму библиотеку.
А так проект, очень даже хорош
Badiboy
06.07.2022 22:55bot.polling(none_stop=True) -> bot.infinity_polling().
Иначе будет вылетать по таймаутам и прочим базовым (нормальным в работе) ошибкам. Ну или в цикл и ловить исключения.
Kirillmaska
07.07.2022 16:17+1Не соглашусь. Данный метод имеет большие траблы в СНГ. Сообщения могут просто не дойти до обработчика, либо долгое время ожидания. Легче сделать конструкцию на автоподнятие. Это решение будет надёжным. Так как даже если что-то отвалится, то бот при первой же возможности начнёт работать сам.
if __name__ == '__main__':
while True:
try:
bot.polling(none_stop=True)
except:
time.sleep(0.3)
kazasoft
07.07.2022 18:18+1Добрый день. Акцентирую внимание на следующую вещь (вдруг кому пригодится): у параметра callback_data ограниченная длина (в офф документации 64 байта). Лучше использовать формат V=123&V2=3&V2=A. Переключалка страниц это хорошо, но Вам в последствии обязательно потребуется передавать ещё какие-нибудь данные. А если такая многостраничная менюшка не одна? А если в зависимости от типа меню надо показывать разную структуру одних и тех же данных? И параметр callback_data начинает разрастаться type=catalog1&id=1253&menu=type2&page=15&no_show_desc=1&show_count=4&...... У меня как-то разраслось до такого что обрезалась часть данных.
В общем, я для себя вывел следующие требования к callback_data с несколькими многостраничными менюшками(что и всем рекомендую):
Короткие имена переменных, минимальный разделитель, а используя формат GET/POST формат можно строку разобрать сразу в массив.
remzalp
Примерно таким же методом пагинация реализовывалась лет 20 назад на PHP, только HTML генерился :))
И еще полезный разбор, чем не очень хорош OFFSET + LIMIT
https://habr.com/ru/company/ruvds/blog/513766/
DanyByLuckyCraft Автор
Почитал статью. Limit и offset, в отличие от OFFSET - FETCH, работают с любыми сортировками. В статье опираются на id для сортировки, а как видно из скринов id не очень то и равномерно распределен у меня. И что если я захочу сделать сортировку по title? Так что OFFSET пока для меня остается решением получше, хоть и более долгим по выполнению.