Как я пагинацию на telebot делал

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Введение

Разберемся с начала, что это за статья зачем она и для кого. Пришлось мне в рамках хакатона "Поколение ИТ" писать бота для телеги.

Но готового решения для пагинации, которое бы нам подходило мы не нашли. Поэтому было принято решение изобретать велосипед. Решение моих товарищей было максимально странным, брать количество записей и перебирать их в цикле от 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)

Теперь мы можем перемещаться по страницам:

Да, но перед нами встает две проблемы:

  1. Нужно писать отдельные исключения, чтобы при возврате кнопкой назад на первую страницу - кнопка назад больше не отображалась. А при переходе на последнюю не было кнопки вперед.

  2. Проблема передачи локальной переменной (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. Сделаю три условия вывода кнопок.

  1. Вывод кнопки вперед, для первой страницы

  2. Вывод "Вперед" и "Назад" для всех страниц между первой и последней

  3. Вывод кнопки "Назад" для последний страницы

Вот, что получилось:

А вот и  полученный код:

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

Источник: https://habr.com/ru/post/675404/


Интересные статьи

Интересные статьи

В июне 2021 года на выставке e-commerce мы впервые показали интегрированный магазин в телеграм с базовым набором функций. Уже тогда мы попытались вместить десятки категорий с десятками тысяч товаров в...
Лежу я ночью, пытаюсь уснуть. И как обычно тысяча мыслей, и среди них я сумел зацепился за одну. А звучала она так: "почему бы не сделать анализатора футбольных матчей, где нужно будет ли...
Накануне, в рамках технологической выставки CES 2021, исполнительный директор AMD Лиза Су представила новую серию мобильных процессоров Ryzen 5000. Новая линейка включает CPU с низким T...
Мне 17 лет и я уже несколько месяцев делаю клон мобильного приложения Хабра, назвав его соответствующе, модно, со стилем и пафосной точкой в конце — habra. Получилось реализовать нескол...
Согласно многочисленным исследованиям поведения пользователей на сайте, порядка 25% посетителей покидают ресурс, если страница грузится более 4 секунд.