diff --git a/.gitignore b/.gitignore index 2a16037..8832cac 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ config.py *.pyc **/__pycache__ +**/.state-save diff --git a/bot/app/__init__.py b/bot/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/app/alembic.ini b/bot/app/alembic.ini new file mode 100644 index 0000000..1e1c37f --- /dev/null +++ b/bot/app/alembic.ini @@ -0,0 +1,105 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql://user:password@postgres:5432/db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/bot/app/alembic/README b/bot/app/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/bot/app/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/bot/app/alembic/env.py b/bot/app/alembic/env.py new file mode 100644 index 0000000..79a538e --- /dev/null +++ b/bot/app/alembic/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata + +import sys +from os.path import abspath, dirname +sys.path.insert(0, '/app') +from db.base import Base +from db.models import * +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/bot/app/alembic/script.py.mako b/bot/app/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/bot/app/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/bot/app/alembic/versions/9d20a4ff1eab_add_active.py b/bot/app/alembic/versions/9d20a4ff1eab_add_active.py new file mode 100644 index 0000000..b5906d9 --- /dev/null +++ b/bot/app/alembic/versions/9d20a4ff1eab_add_active.py @@ -0,0 +1,30 @@ +"""add active + +Revision ID: 9d20a4ff1eab +Revises: +Create Date: 2023-10-29 12:09:17.958616 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9d20a4ff1eab' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('queue', sa.Column('active', sa.Boolean(), nullable=True)) + op.add_column('queueuser', sa.Column('active', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('queueuser', 'active') + op.drop_column('queue', 'active') + # ### end Alembic commands ### diff --git a/bot/app/bot.py b/bot/app/bot.py index 9cf563d..6e85b1b 100644 --- a/bot/app/bot.py +++ b/bot/app/bot.py @@ -17,10 +17,11 @@ from datetime import datetime import math import socket import os +import json from typing import Union # Local imports -from config import token +from config import token, admins from constants import ( MAX_QUEUES_OWN, MAX_QUEUES_PARTS_IN, @@ -29,10 +30,12 @@ from constants import ( ) import textbook import keyboards +from pagination import PaginatedList # DB from db.base import Session, engine, Base from db.models import User, Queue, QueueUser +from sqlalchemy import select bot = AsyncTeleBot(token, state_storage=StatePickleStorage()) @@ -42,13 +45,17 @@ class States(StatesGroup): default = State() changing_name = State() changing_queue_name = State() + admin_broadcasting = State() # Utils def get_queue_stats_text(queue: Queue) -> str: - s = textbook.queue_stats.format(name=queue.name, count=len(queue.users)) + queue_users = queue.users.filter_by(active=True) + s = textbook.queue_stats.format( + name=queue.name, count=len(queue_users) + ) return s @@ -62,7 +69,7 @@ async def get_queue_from_state_data(call: types.CallbackQuery) -> Queue: callback_query_id=call.id, text=textbook.queue_operational_error ) return None - queue = session.query(Queue).filter_by(id=queue_id).first() + queue = session.query(Queue).filter_by(id=queue_id, active=True).first() if queue.owner.id != call.from_user.id: await bot.answer_callback_query( callback_query_id=call.id, text=textbook.queue_operational_error @@ -95,7 +102,7 @@ def get_first_queue_user(queue: Queue) -> Union[QueueUser, None]: def kick_first(queue: Queue) -> bool: if len(queue.users): first_user = get_first_queue_user(queue) - session.delete(first_user) + setattr(first_user, "active", False) session.commit() normalize_queue(queue) return True @@ -118,15 +125,19 @@ def normalize_queue(queue: Queue) -> Queue: # setattr(first_user, "position", 0) # session.commit() # queue = session.query(Queue).filter_by(id=queue.id).first() - for i, qu in enumerate(sorted(queue.users, key=lambda qu: qu.position)): - setattr(qu, "position", i) + queue_users = queue.users.filter_by(active= True) + for i, qu in enumerate(sorted(queue_users, key=lambda qu: qu.position)): + # setattr(qu, "position", i) + pass session.commit() return queue async def update_queue_users_message(msg: Message, queue: Queue): queue = normalize_queue(queue) - users_str = "\n".join([f"{qu.position}. {qu.user.name}" for qu in queue.users]) + filtered_users = list(filter(lambda qu: qu.active, queue.users)) + users = sorted(filtered_users, key=lambda qu: qu.position) + users_str = "\n".join([f"{qu.position}. {qu.user.name}" for qu in users]) await bot.edit_message_text( chat_id=msg.chat.id, message_id=msg.id, @@ -156,15 +167,19 @@ async def start(msg: Message): await bot.set_state(user_id=msg.from_user.id, state=States.default) if len(msg.text.split()) > 1: command, queue_id = msg.text.split() - if queue := session.query(Queue).filter_by(id=queue_id).first(): + if ( + queue := session.query(Queue) + .filter_by(id=queue_id, active=True) + .first() + ): if ( not session.query(QueueUser) - .filter_by(queue_id=queue.id, user_id=msg.from_user.id) + .filter_by(queue_id=queue.id, user_id=msg.from_user.id, active=True) .first() ) and not len(user.takes_part_in_queues) > MAX_QUEUES_PARTS_IN: last_user = ( session.query(QueueUser) - .filter_by(queue_id=queue.id) + .filter_by(queue_id=queue.id, active=True) .order_by(QueueUser.position.desc()) .first() ) @@ -215,7 +230,7 @@ async def to_menu_handler(call: types.CallbackQuery): @bot.callback_query_handler(func=lambda c: c.data[:2] == "p:") async def proceed_user_handler(call: types.CallbackQuery): queue_id = call.data[2:] - queue = session.query(Queue).filter_by(id=queue_id).first() + queue = session.query(Queue).filter_by(id=queue_id, active=True).first() user = session.query(User).filter_by(id=call.from_user.id).first() if next_queue_user := proceed_queue_user(queue, user): try: @@ -250,6 +265,11 @@ async def proceed_user_handler(call: types.CallbackQuery): await bot.answer_callback_query(callback_query_id=call.id) +@bot.callback_query_handler(func=lambda c: c.data == "dummy") +async def dummy_handler(call: types.CallbackQuery): + await bot.answer_callback_query(callback_query_id=call.id) + + # Main menu @@ -257,7 +277,7 @@ async def proceed_user_handler(call: types.CallbackQuery): async def new_queue_handler(call: types.CallbackQuery): user = session.query(User).filter_by(id=call.from_user.id).first() if user: - if len(user.owns_queues) < MAX_QUEUES_OWN: + if len(list(filter(lambda q: q.active, user.owns_queues))) < MAX_QUEUES_OWN: queue = Queue(owner_id=call.from_user.id) session.add(queue) session.commit() @@ -277,7 +297,7 @@ async def new_queue_handler(call: types.CallbackQuery): @bot.callback_query_handler(func=lambda c: c.data == "my_queues") async def my_queues_handler(call: types.CallbackQuery): user = session.query(User).filter_by(id=call.from_user.id).first() - queues = user.owns_queues + queues = list(filter(lambda q: q.active, user.owns_queues)) await bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.id, @@ -290,7 +310,9 @@ async def my_queues_handler(call: types.CallbackQuery): @bot.callback_query_handler(func=lambda c: c.data == "parts_queues") async def parts_queues_handler(call: types.CallbackQuery): user = session.query(User).filter_by(id=call.from_user.id).first() - queues = [qu.queue for qu in user.takes_part_in_queues] + queues = [ + qu.queue for qu in list(filter(lambda q: q.active, user.takes_part_in_queues)) + ] await bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.id, @@ -327,7 +349,7 @@ async def about_handler(call: types.CallbackQuery): @bot.callback_query_handler(func=lambda c: c.data[:2] == "t:") async def queue_parts_handler(call: types.CallbackQuery, queue_id: str = None): queue_id = call.data[2:] if not queue_id else queue_id - queue = session.query(Queue).filter_by(id=queue_id).first() + queue = session.query(Queue).filter_by(id=queue_id, active=True).first() if not queue: await bot.answer_callback_query(callback_query_id=call.id, text=textbook.error) return None @@ -335,7 +357,12 @@ async def queue_parts_handler(call: types.CallbackQuery, queue_id: str = None): user_id=call.from_user.id, chat_id=call.message.chat.id ) as state_data: state_data["part_queue_id"] = queue_id - users_str = "\n".join([f"{qu.position}. {qu.user.name}" for qu in queue.users]) + users_str = "\n".join( + [ + f"{qu.position}. {qu.user.name}" + for qu in list(filter(lambda qu: qu.active, queue.users)) + ] + ) await bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.id, @@ -354,10 +381,10 @@ async def leave_queue_handler(call: types.CallbackQuery): if queue := await get_parting_queue_from_state_data(call): queueuser = ( session.query(QueueUser) - .filter_by(queue_id=queue.id, user_id=call.from_user.id) + .filter_by(queue_id=queue.id, user_id=call.from_user.id, active=True) .first() ) - session.delete(queueuser) + setattr(queueuser, "active", False) session.commit() normalize_queue(queue) await bot.answer_callback_query( @@ -372,7 +399,12 @@ async def leave_queue_handler(call: types.CallbackQuery): @bot.callback_query_handler(func=lambda c: c.data == "refresh_list") async def refresh_list_handler(call: types.CallbackQuery): if queue := await get_parting_queue_from_state_data(call): - users_str = "\n".join([f"{qu.position}. {qu.user.name}" for qu in queue.users]) + users_str = "\n".join( + [ + f"{qu.position}. {qu.user.name}" + for qu in list(filter(lambda q: q.active, queue.users)) + ] + ) try: await bot.edit_message_text( chat_id=call.message.chat.id, @@ -392,23 +424,24 @@ async def refresh_list_handler(call: types.CallbackQuery): @bot.callback_query_handler(func=lambda c: c.data[:2] == "q:") async def queue_handler(call: types.CallbackQuery, queue_id: str = None): queue_id = call.data[2:] if not queue_id else queue_id - queue = session.query(Queue).filter_by(id=queue_id).first() - if not queue: + queue = session.query(Queue).filter_by(id=queue_id, active=True).first() + if queue: + async with bot.retrieve_data( + user_id=call.from_user.id, chat_id=call.message.chat.id + ) as state_data: + state_data["queue_id"] = queue.id + await bot.edit_message_text( + chat_id=call.message.chat.id, + message_id=call.message.id, + text=get_queue_stats_text(queue), + reply_markup=keyboards.queue_menu(), + parse_mode="html", + ) + else: await bot.answer_callback_query(callback_query_id=call.id, text=textbook.error) return None - async with bot.retrieve_data( - user_id=call.from_user.id, chat_id=call.message.chat.id - ) as state_data: - state_data["queue_id"] = queue_id - await bot.edit_message_text( - chat_id=call.message.chat.id, - message_id=call.message.id, - text=get_queue_stats_text(queue), - reply_markup=keyboards.queue_menu(), - parse_mode="html", - ) await bot.answer_callback_query(callback_query_id=call.id) - + # Queue menu @@ -416,7 +449,12 @@ async def queue_handler(call: types.CallbackQuery, queue_id: str = None): @bot.callback_query_handler(func=lambda c: c.data == "get_queue_users") async def get_queue_users_handler(call: types.CallbackQuery): if queue := await get_queue_from_state_data(call): - users_str = "\n".join([f"{qu.position}. {qu.user.name}" for qu in queue.users]) + users_str = "\n".join( + [ + f"{qu.position}. {qu.user.name}" + for qu in list(filter(lambda qu: qu.active, queue.users)) + ] + ) await bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.id, @@ -509,11 +547,23 @@ async def kick_first_handler(call: types.CallbackQuery): @bot.callback_query_handler(func=lambda c: c.data == "swap_users") async def swap_users_handler(call: types.CallbackQuery): - await bot.answer_callback_query( - callback_query_id=call.id, - text=textbook.in_development_plug, - show_alert=True, - ) + if queue := await get_queue_from_state_data(call): + pl = PaginatedList(queue.users, 8) + async with bot.retrieve_data( + user_id=call.from_user.id, chat_id=call.message.chat.id + ) as state_data: + state_data["pl"] = pl + await bot.edit_message_text( + chat_id=call.message.chat.id, + message_id=call.message.id, + text=textbook.swap_users_first, + reply_markup=keyboards.swap_users_list( + queue_users=pl.get_current_page(), + current_page=pl.current_page, + total_pages=len(pl.divided_list), + ), + parse_mode="html", + ) @bot.callback_query_handler(func=lambda c: c.data == "refresh_users") @@ -521,7 +571,10 @@ async def refresh_users_handler(call: types.CallbackQuery): if queue := await get_queue_from_state_data(call): try: users_str = "\n".join( - [f"{qu.position}. {qu.user.name}" for qu in queue.users] + [ + f"{qu.position}. {qu.user.name}" + for qu in list(filter(lambda qu: qu.active, queue.users)) + ] ) await bot.edit_message_text( chat_id=call.message.chat.id, @@ -537,6 +590,14 @@ async def refresh_users_handler(call: types.CallbackQuery): await bot.answer_callback_query(callback_query_id=call.id) +# Swap users + + +@bot.callback_query_handler(func=lambda c: c.data == "swap_users_page_up") +async def swap_users_page_up_handler(call: types.CallbackQuery): + pass + + # Queue settings @@ -568,7 +629,7 @@ async def update_queue_name(msg: Message): user_id=msg.from_user.id, chat_id=msg.chat.id ) as state_data: queue_id = state_data.get("queue_id", None) - queue = session.query(Queue).filter_by(id=queue_id).first() + queue = session.query(Queue).filter_by(id=queue_id, active=True).first() if not queue: await bot.send_message(chat_id=msg.chat.id, text=textbook.edit_name_error) return None @@ -609,8 +670,10 @@ async def delete_queue_approve_handler(call: types.CallbackQuery): async def delete_queue_handler(call: types.CallbackQuery): if queue := await get_queue_from_state_data(call): for qu in queue.users: - session.delete(qu) # TODO: Use SQLAlchemy to cascade-delete all users - session.delete(queue) + setattr( + qu, "active", False + ) # TODO: Use SQLAlchemy to cascade-delete all users + setattr(queue, "active", False) session.commit() await bot.answer_callback_query( callback_query_id=call.id, text=textbook.queue_deleted @@ -688,6 +751,53 @@ async def changelog(msg: Message): await bot.send_message(chat_id=msg.chat.id, text=file.read(), parse_mode="html") +@bot.message_handler(commands=["mydata"]) +async def mydata(msg: Message): + async with bot.retrieve_data( + user_id=msg.from_user.id, chat_id=msg.chat.id + ) as state_data: + await bot.send_message(chat_id=msg.chat.id, text=str(state_data)) + + +@bot.message_handler(commands=["broadcast"]) +async def broadcast(msg: Message): + if msg.from_user.id in admins: + await bot.set_state(user_id=msg.from_user.id, state=States.admin_broadcasting) + await bot.send_message( + chat_id=msg.chat.id, + text=textbook.admin_broadcasting, + reply_markup=keyboards.edit_name(), + ) + + +@bot.callback_query_handler( + func=lambda c: c.data == "cancel", state=States.admin_broadcasting +) +async def cancel_broadcast(call: types.CallbackQuery): + await to_menu_handler(call) + + +@bot.message_handler(content_types=["text"], state=States.admin_broadcasting) +async def broadcast_message_handler(msg: Message): + if msg.from_user.id in admins: + counter = 0 + for user in session.query(User): + try: + await bot.send_message( + chat_id=user.id, + text=msg.text, + ) + counter += 1 + except Exception as e: + continue + await bot.send_message( + chat_id=msg.chat.id, + text=textbook.broadcast_completed.format(count=counter), + ) + await asyncio.sleep(1) + await start(msg) + + # Launch diff --git a/bot/app/changelog.txt b/bot/app/changelog.txt index d2400aa..efd230c 100644 --- a/bot/app/changelog.txt +++ b/bot/app/changelog.txt @@ -1,3 +1,8 @@ +v0.2.1-beta +- Добавлена возможность миграции базы данных с помощью alembic +- Небольшая персистентность данных +- Теперь я могу броадкастить сообщения всем юзерам бота + v0.1.8-beta - Исправлен баг, при котором после кика первого юзера очередь отображалась наоборот - При создании очереди сразу генерится ссылка и создателю предлагается в нее вступить diff --git a/bot/app/db/__init__.py b/bot/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/app/db/models.py b/bot/app/db/models.py index e248d2e..dc27273 100644 --- a/bot/app/db/models.py +++ b/bot/app/db/models.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy import Column, Integer, String, BigInteger, ForeignKey +from sqlalchemy import Column, Integer, String, BigInteger, ForeignKey, Boolean from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship, backref @@ -26,6 +26,7 @@ class Queue(Base): name = Column(String(40), default="Новая очередь") description = Column(String(120), default=None) owner_id = Column(BigInteger, ForeignKey("user.id")) + active = Column(Boolean, default=True) users = relationship( "QueueUser", backref="queue" @@ -39,3 +40,4 @@ class QueueUser(Base): user_id = Column(BigInteger, ForeignKey("user.id")) queue_id = Column(UUID(as_uuid=True), ForeignKey("queue.id")) position = Column(Integer) + active = Column(Boolean, default=True) diff --git a/bot/app/keyboards.py b/bot/app/keyboards.py index 9c34019..0fbebd3 100644 --- a/bot/app/keyboards.py +++ b/bot/app/keyboards.py @@ -2,7 +2,7 @@ from telebot.types import ( InlineKeyboardButton as button, InlineKeyboardMarkup as keyboard, ) -from db.models import Queue +from db.models import Queue, QueueUser def menu() -> keyboard: @@ -117,3 +117,28 @@ def to_menu_keyboard() -> keyboard: [button(text="⬅️ В меню", callback_data="to_menu")], ] ) + + +def pagination_footer(current_page: int, total_pages: int) -> list: + return [ + button(text="◀️", callback_data="page_down"), + button(text=f"{current_page}/{total_pages}", callback_data="dummy"), + button(text="▶️", callback_data="page_up"), + ] + + +def swap_users_list( + queue_users: list[QueueUser], current_page: int, total_pages: int +) -> keyboard: + kb = [ + [ + button( + text=f"{qu.position}. {qu.user.name}", + callback_data=f"action:{qu.id}", + ) + ] + for qu in queue_users + ] + kb.append(pagination_footer(current_page, total_pages)) + kb.append([button(text="⬅️ Назад", callback_data="get_queue_users")]) + return keyboard(kb) diff --git a/bot/app/pagination.py b/bot/app/pagination.py new file mode 100644 index 0000000..b13f9f4 --- /dev/null +++ b/bot/app/pagination.py @@ -0,0 +1,34 @@ +from typing import Union + +class PaginatedList: + def __init__(self, data: list, page_size: int, current_page:int=0): + self.data = data + self.page_size = page_size + self.current_page = current_page + + @property + def divided_list(self): + return [ + self.data[x : x + self.page_size] + for x in range(0, len(self.data), self.page_size) + ] + + def get_page(self, page: int) -> Union[list, None]: + if page < len(self.divided_list): + return self.divided_list[page] + return None + + def get_current_page(self) -> list: + return self.get_page(self.current_page) + + def page_up(self) -> Union[list, None]: + if self.current_page + 1 < len(self.divided_list): + self.current_page += 1 + return self.get_page(self.current_page) + return None + + def page_down(self) -> Union[list, None]: + if self.current_page - 1 >= 0: + self.current_page -= 1 + return self.get_page(self.current_page) + return None diff --git a/bot/app/textbook.py b/bot/app/textbook.py index 81a2cc9..091075b 100644 --- a/bot/app/textbook.py +++ b/bot/app/textbook.py @@ -28,6 +28,9 @@ kick_first_error = ( "Действие не выполнено, возможно вы уже вышли из очереди, или очередь пуста?" ) +swap_users_first = "Выбери первого пользователя" +swap_users_second = "Выбран: {name}. Поменять с:" + parts_queues_menu = "Ты принимаешь участие в {count} очереди/ей" part_queue = "Очередь {name}\n\nУчастники:\n{users_str}" leaved_queue = "Ты вышел из очереди {name}" @@ -43,3 +46,6 @@ stats = "Количество пользователей: {users_count}\nКол about = "Бот для очередей.\n\nРазработчик - ollyhearn.\nЯ всегда открыт для вопросов и предложений: @OllyHearn\n\nv0.1.8-beta" groups_plug = "Всем привет, я бот для очередей! В настоящее время идет активная разработка, так что я пока не могу полностью функционировать в группах, но вы всегда можете запустить меня в личном диалоге, создать очередь, и отправить ссылку на очередь сюда. Функционал будет доработан, а пока пользуйтесь мной в личке:\n\nhttps://t.me/queue_senko_bot" in_development_plug = "Функция в разработке ¯\_(ツ)_/¯" + +admin_broadcasting = "Введите сообщение, или нажмите Отменить" +broadcast_completed = "Сообщение разослано {count} юзерам!" \ No newline at end of file diff --git a/bot/requirements.txt b/bot/requirements.txt index 7c75ce6..884e9fc 100644 --- a/bot/requirements.txt +++ b/bot/requirements.txt @@ -5,3 +5,4 @@ psycopg-binary pydantic sqlalchemy psycopg2-binary +alembic \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index cc7ccfe..4a8d149 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: interval: 2s timeout: 2s retries: 5 + ports: + - 5432 bot: build: context: bot @@ -25,6 +27,11 @@ services: HOST: postgres PORT: 5432 restart: always + volumes: + - ./persistent_data/.state-save:/app/.state-save:rw + - ./bot/app:/app:z + ports: + - "4444:4444" # debugger port depends_on: postgres: condition: service_healthy