From 1802ce81b0e594a3fc0221bea52f6af2885f1f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=ADITQ?= Date: Sat, 23 Mar 2024 02:47:05 +0300 Subject: [PATCH] feat: Added travel models, travel creation command, travels list command with pagination, set help message, improvements and fixes --- app/bot.py | 8 +- app/callbacks/menu.py | 61 +++++++- app/callbacks/travels.py | 40 ++++++ app/config.py | 1 + app/handlers/create_travel_command.py | 135 ++++++++++++++++++ app/handlers/travels_command.py | 36 +++++ app/keyboards/builders.py | 71 ++++++++- app/messages.py | 28 +++- app/migrations/env.py | 2 +- .../fe4ace4196fb_added_travel_models.py | 97 +++++++++++++ app/models/__init__.py | 7 + app/models/base.py | 5 + app/models/travel.py | 124 ++++++++++++++++ app/models/user.py | 14 +- app/states/travel.py | 9 ++ 15 files changed, 623 insertions(+), 15 deletions(-) create mode 100644 app/callbacks/travels.py create mode 100644 app/handlers/create_travel_command.py create mode 100644 app/handlers/travels_command.py create mode 100644 app/migrations/versions/fe4ace4196fb_added_travel_models.py create mode 100644 app/models/__init__.py create mode 100644 app/models/base.py create mode 100644 app/models/travel.py create mode 100644 app/states/travel.py diff --git a/app/bot.py b/app/bot.py index acc105d..370d43c 100644 --- a/app/bot.py +++ b/app/bot.py @@ -6,13 +6,15 @@ from aiogram import Bot, Dispatcher from aiogram.enums import ParseMode from aiogram.fsm.storage.redis import RedisStorage -from app.callbacks import menu, profile +from app.callbacks import menu, profile, travels from app.config import Config from app.handlers import ( + create_travel_command, help_command, menu_command, profile_command, start_command, + travels_command, ) from app.middlewares.throttling import ThrottlingMiddleware @@ -34,8 +36,12 @@ async def main() -> None: help_command.router, menu_command.router, profile_command.router, + create_travel_command.router, + travels_command.router, + menu.router, # type: ignore profile.router, # type: ignore + travels.router, # type: ignore ) await bot.delete_webhook(drop_pending_updates=True) diff --git a/app/callbacks/menu.py b/app/callbacks/menu.py index 21fbea8..5b5ad8f 100644 --- a/app/callbacks/menu.py +++ b/app/callbacks/menu.py @@ -3,18 +3,25 @@ __all__ = () from aiogram import F, Router from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery from app import messages +from app.config import Config from app.filters.user import RegisteredCallback +from app.keyboards.builders import travels_keyboard from app.keyboards.profile import get from app.models.user import User +from app.states.travel import TravelCreationState router = Router(name="menu_callback") -@router.callback_query(F.data == "menu_profile", RegisteredCallback()) +@router.callback_query( + F.data == "menu_profile", RegisteredCallback(), StateFilter(None), +) async def profile_callback(callback: CallbackQuery) -> None: if callback.data is None or callback.message is None: return @@ -39,3 +46,55 @@ async def profile_callback(callback: CallbackQuery) -> None: await callback.message.delete() except TelegramBadRequest: pass + + +@router.callback_query( + F.data == "menu_create_travel", RegisteredCallback(), StateFilter(None), +) +async def create_travel_callback( + callback: CallbackQuery, + state: FSMContext, +) -> None: + if callback.data is None or callback.message is None: + return + + await callback.message.answer( + messages.CREATE_TRAVEL, + ) + await callback.message.answer( + messages.INPUT_TRAVEL_TITLE, + ) + await state.set_state(TravelCreationState.title) + + await callback.answer() + + try: + await callback.message.delete() + except TelegramBadRequest: + pass + + +@router.callback_query( + F.data == "menu_travels", RegisteredCallback(), StateFilter(None), +) +async def travels_callback( + callback: CallbackQuery, +) -> None: + page = 0 + + user = User().get_user_by_telegram_id(callback.from_user.id) + + travels = user.get_user_travels() + + if not travels or travels == []: + await callback.message.answer(messages.NO_TRAVELS) + else: + pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE + + await callback.message.answer( + messages.TRAVELS, + reply_markup=travels_keyboard(travels, page, pages), + ) + + await callback.message.delete() + await callback.answer() diff --git a/app/callbacks/travels.py b/app/callbacks/travels.py new file mode 100644 index 0000000..77e0e2f --- /dev/null +++ b/app/callbacks/travels.py @@ -0,0 +1,40 @@ +# type: ignore +__all__ = () + +from aiogram import F, Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import StateFilter +from aiogram.types import CallbackQuery + +from app import messages +from app.config import Config +from app.filters.user import RegisteredCallback +from app.keyboards.builders import travels_keyboard +from app.models.user import User + + +router = Router(name="menu_callback") + + +@router.callback_query( + F.data.startswith("travels_page"), RegisteredCallback(), StateFilter(None), +) +async def travels_callback(callback: CallbackQuery) -> None: + page = int(callback.data.replace("travels_page_", "")) + + user = User().get_user_by_telegram_id(callback.from_user.id) + + travels = user.get_user_travels() + + if not travels or travels == []: + try: + await callback.message.edit_text(messages.NO_TRAVELS) + except TelegramBadRequest: + pass + else: + pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE + + await callback.message.edit_text( + messages.TRAVELS, + reply_markup=travels_keyboard(travels, page, pages), + ) diff --git a/app/config.py b/app/config.py index 831a73a..79e434b 100644 --- a/app/config.py +++ b/app/config.py @@ -17,3 +17,4 @@ class Config: "REDIS_URL", "redis://localhost:6379", ) + PAGE_SIZE = 6 diff --git a/app/handlers/create_travel_command.py b/app/handlers/create_travel_command.py new file mode 100644 index 0000000..2b823de --- /dev/null +++ b/app/handlers/create_travel_command.py @@ -0,0 +1,135 @@ +__all__ = () + +from aiogram import F, Router +from aiogram.filters import Command, StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.types import Message + +from app import messages, session +from app.filters.user import Registered +from app.models.travel import Travel +from app.states.travel import TravelCreationState +from app.utils.states import delete_message_from_state, handle_validation_error + + +router = Router(name="create_travel_command") + + +@router.message(Command("create_travel"), Registered(), StateFilter(None)) +async def command_create_travel_handler( + message: Message, + state: FSMContext, +) -> None: + if message.from_user is None: + return + + await message.answer( + messages.CREATE_TRAVEL, + ) + await message.answer( + messages.INPUT_TRAVEL_TITLE, + ) + + await state.set_state(TravelCreationState.title) + + +@router.message(TravelCreationState.title, F.text) +async def name_handler( + message: Message, + state: FSMContext, +) -> None: + if message.text is None: + return + + title = message.text.strip() + + if title == "/cancel": + await message.answer(messages.ACTION_CANCELED) + await message.delete() + + await delete_message_from_state(state, message.chat.id, message.bot) + await state.clear() + + return + + try: + validated_title = Travel().validate_title(key="title", value=title) + except AssertionError as e: + await handle_validation_error(message, state, e) + + return + + await delete_message_from_state(state, message.chat.id, message.bot) + + await state.update_data(title=validated_title) + await state.set_state(TravelCreationState.description) + + await message.answer( + messages.INPUT_TRAVEL_CALLBACK.format( + key="title", + value=validated_title, + ), + ) + await message.answer( + messages.INPUT_TRAVEL_DESCRIPTION, + ) + + +@router.message(TravelCreationState.description, F.text) +async def description_handler( + message: Message, + state: FSMContext, +) -> None: + if message.text is None or message.from_user is None: + return + + description = message.text.strip() + + if description == "/cancel": + await message.answer(messages.ACTION_CANCELED) + await message.delete() + + await delete_message_from_state(state, message.chat.id, message.bot) + await state.clear() + + return + + if description == "/skip": + await state.update_data(description=None) + + await message.answer(messages.INPUT_TRAVEL_DESCRIPTION_SKIPPED) + else: + try: + validated_description = Travel().validate_description( + key="description", + value=description, + ) + except AssertionError as e: + await handle_validation_error(message, state, e) + + return + + await state.update_data(description=validated_description) + await state.set_state(TravelCreationState.error_message_id) + + await message.answer( + messages.INPUT_TRAVEL_CALLBACK.format( + key="description", + value=validated_description, + ), + ) + + await delete_message_from_state(state, message.chat.id, message.bot) + + data = await state.get_data() + await state.clear() + + if "error_message_id" in data: + del data["error_message_id"] + + data["author_id"] = message.from_user.id + + session.add(Travel(**data)) + session.commit() + + await message.answer(messages.TRAVEL_CREATED.format(title=data["title"])) diff --git a/app/handlers/travels_command.py b/app/handlers/travels_command.py new file mode 100644 index 0000000..d83abf2 --- /dev/null +++ b/app/handlers/travels_command.py @@ -0,0 +1,36 @@ +__all__ = () + +from aiogram import Router +from aiogram.filters import Command, StateFilter +from aiogram.types import Message + +from app import messages +from app.config import Config +from app.filters.user import Registered +from app.keyboards.builders import travels_keyboard +from app.models.user import User + + +router = Router(name="travels_command") + + +@router.message(Command("travels"), Registered(), StateFilter(None)) +async def command_help_handler(message: Message) -> None: + page = 0 + + if message.from_user is None: + return + + user = User().get_user_by_telegram_id(message.from_user.id) + + travels = user.get_user_travels() + + if not travels or travels == []: + await message.answer(messages.NO_TRAVELS) + else: + pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE + + await message.answer( + messages.TRAVELS, + reply_markup=travels_keyboard(travels, page, pages), + ) diff --git a/app/keyboards/builders.py b/app/keyboards/builders.py index 96fe862..5369e56 100644 --- a/app/keyboards/builders.py +++ b/app/keyboards/builders.py @@ -1,13 +1,76 @@ __all__ = ("sex_keyboard",) -from aiogram.utils.keyboard import ReplyKeyboardBuilder +from aiogram.types import InlineKeyboardButton +from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder + +from app.config import Config -def sex_keyboard(text: str | list): +def sex_keyboard(choices: str | list): builder = ReplyKeyboardBuilder() - if isinstance(text, str): - text = [text] + if isinstance(choices, str): + text = [choices] [builder.button(text=txt) for txt in text] return builder.as_markup(resize_keyboard=True) + + +def travels_keyboard(travels: list, page: int, pages: int): + builder = InlineKeyboardBuilder() + rows = [] + + start_index = page * Config.PAGE_SIZE + end_index = min((page + 1) * Config.PAGE_SIZE, len(travels)) + + for travel in travels[start_index:end_index]: + rows.append( + InlineKeyboardButton( + text=travel.title, + callback_data=f"travel_detail_{travel.id}", + ), + ) + + for _ in range(0, Config.PAGE_SIZE - len(rows)): + rows.append(InlineKeyboardButton(text=" ", callback_data="pass")) + + builder.row(*rows, width=2) + + if pages > 1: + navigation_row = [] + + if page > 0: + navigation_row.append( + InlineKeyboardButton( + text="⬅️", callback_data=f"travels_page_{page - 1}", + ), + ) + else: + navigation_row.append( + InlineKeyboardButton( + text=" ", callback_data="pass", + ), + ) + + navigation_row.append( + InlineKeyboardButton( + text=f"{page + 1}/{pages}", callback_data="pass", + ), + ) + + if page < pages - 1: + navigation_row.append( + InlineKeyboardButton( + text="➡️", callback_data=f"travels_page_{page + 1}", + ), + ) + else: + navigation_row.append( + InlineKeyboardButton( + text=" ", callback_data="pass", + ), + ) + + builder.row(*navigation_row) + + return builder.as_markup() diff --git a/app/messages.py b/app/messages.py index 6a1452f..372102d 100644 --- a/app/messages.py +++ b/app/messages.py @@ -2,10 +2,34 @@ MENU = "Menu:" +TRAVELS = "📃 Travels:" +NO_TRAVELS = "No travels yet. You can create one with /create_travel command." +CREATE_TRAVEL = "🧳 Let's create new travel!\nEnter /cancel to cancel creating." +INPUT_TRAVEL_TITLE = "Enter travel title:\nMaximum length: 30 characters" +INPUT_TRAVEL_CALLBACK = ( + "All right, travel {key} is set to: {value}" +) +INPUT_TRAVEL_DESCRIPTION = ( + "Enter travel description (enter /skip if you want to skip this step):\nMaximum length: 100 characters" +) +INPUT_TRAVEL_DESCRIPTION_SKIPPED = "Sure. You can always fill it later." +TRAVEL_CREATED = "Travel {title} successfully created! You can now view and edit it in the travels list (/travels command)." +ACTION_CANCELED = "❌ Action canceled" + WELCOME_MESSAGE = "Hello, {name}! Welcome to the ✈️ Travel Agent bot! Let's start our journey by filling out some information about you." WELCOME_AGAIN_MESSAGE = "Hello, {name}! Welcome back to the ✈️ Travel Agent bot! If you get lost, you can always call the /help command for assistance." -HELP_MESSAGE = "Help message text." +HELP_MESSAGE = ( + "Welcome to the ✈️ Travel Agent bot! Here is list of commands you can use:\n\n" + "/start - Start the bot\n" + "/help - Show this message\n" + "/menu - Show the main menu\n" + "/profile - View and edit your profile\n" + "/create_travel - Create new travel\n" + "/travels - View and edit your travels\n" + "/cancel - Cancel the current action\n\n" + "❓ If you have any questions/issues, feel free to contact us via @itq_travel_agent_support_bot on Telegram." +) REGISTERED_MESSAGE = "You have successfully registered. Welcome to the ✈️ Travel Agent bot! \nYou can view and edit your profile using the /profile command." @@ -27,7 +51,7 @@ PROFILE = ( "\tCountry: {country}\n" "\tCity: {city}\n" "\tBio: {bio}\n" - "\tDate joined: {date_joined}\n" + "\tDate joined: {date_joined} UTC\n" ) NOT_SET = "Not set" EDIT_USERNAME = "Enter your username:\nAllowed characters: a-z, A-Z, 0-9, _\nLength: 5-20 characters" diff --git a/app/migrations/env.py b/app/migrations/env.py index b05ea8a..c8313c2 100644 --- a/app/migrations/env.py +++ b/app/migrations/env.py @@ -8,7 +8,7 @@ from dotenv import load_dotenv from sqlalchemy import engine_from_config from sqlalchemy import pool -from app.models.user import Base +from app.models import Base load_dotenv() diff --git a/app/migrations/versions/fe4ace4196fb_added_travel_models.py b/app/migrations/versions/fe4ace4196fb_added_travel_models.py new file mode 100644 index 0000000..bbed24d --- /dev/null +++ b/app/migrations/versions/fe4ace4196fb_added_travel_models.py @@ -0,0 +1,97 @@ +"""Added travel models + +Revision ID: fe4ace4196fb +Revises: 4914f00ae14a +Create Date: 2024-03-22 19:19:36.662090 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fe4ace4196fb' +down_revision: Union[str, None] = '4914f00ae14a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'travels', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('title', sa.String(length=50), nullable=False), + sa.Column('description', sa.String(length=100), nullable=True), + sa.Column('author_id', sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ['author_id'], + ['users.telegram_id'], + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('title'), + ) + op.create_index(op.f('ix_travels_id'), 'travels', ['id'], unique=True) + op.create_table( + 'locations', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('date_start', sa.Date(), nullable=False), + sa.Column('date_end', sa.Date(), nullable=False), + sa.Column('travel_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ['travel_id'], + ['travels.id'], + ), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_locations_id'), 'locations', ['id'], unique=True) + op.create_table( + 'notes', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('file_id', sa.Text(), nullable=False), + sa.Column('file_name', sa.Text(), nullable=False), + sa.Column('file_type', sa.Text(), nullable=False), + sa.Column('public', sa.Boolean(), nullable=False), + sa.Column('author_id', sa.BigInteger(), nullable=False), + sa.Column('travel_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ['author_id'], + ['users.telegram_id'], + ), + sa.ForeignKeyConstraint( + ['travel_id'], + ['travels.id'], + ), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_notes_id'), 'notes', ['id'], unique=True) + op.create_table( + 'user_travel_association', + sa.Column('user_id', sa.BigInteger(), nullable=True), + sa.Column('travel_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ['travel_id'], + ['travels.id'], + ), + sa.ForeignKeyConstraint( + ['user_id'], + ['users.telegram_id'], + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_travel_association') + op.drop_index(op.f('ix_notes_id'), table_name='notes') + op.drop_table('notes') + op.drop_index(op.f('ix_locations_id'), table_name='locations') + op.drop_table('locations') + op.drop_index(op.f('ix_travels_id'), table_name='travels') + op.drop_table('travels') + # ### end Alembic commands ### diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..96b5267 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,7 @@ +# flake8: noqa + +from app.models.base import Base +import app.models.user +import app.models.travel + +Base.registry.configure() diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..1165e43 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,5 @@ +from typing import Any + +from sqlalchemy.ext.declarative import declarative_base + +Base: Any = declarative_base() diff --git a/app/models/travel.py b/app/models/travel.py new file mode 100644 index 0000000..08223b2 --- /dev/null +++ b/app/models/travel.py @@ -0,0 +1,124 @@ +__all__ = ("Travel", "Location") + +import sqlalchemy as sa +from sqlalchemy.orm import relationship, validates + +from app import session +from app.models import Base +from app.models.user import User + + +association_table = sa.Table( + "user_travel_association", + Base.metadata, + sa.Column("user_id", sa.BigInteger, sa.ForeignKey("users.telegram_id")), + sa.Column("travel_id", sa.Integer, sa.ForeignKey("travels.id")), +) + + +class Travel(Base): + __tablename__ = "travels" + + id = sa.Column( # noqa: A003 + sa.Integer, + unique=True, + primary_key=True, + nullable=False, + autoincrement=True, + index=True, + ) + title = sa.Column( + sa.String(50), + nullable=False, + unique=True, + ) + description = sa.Column( + sa.String(100), + nullable=True, + ) + + author_id = sa.Column( + sa.BigInteger, + sa.ForeignKey(User.telegram_id), + nullable=False, + ) + + users = relationship( + User, + secondary=association_table, + backref="travels", + ) + + locations = relationship("Location", backref="travel") + notes = relationship("Note", backref="travel") + + @validates("title") + def validate_title(self, key, value): + assert len(value) <= 30, "Title must be 30 characters or fewer." + + if session.query(Travel).filter(Travel.title == value).first(): + raise AssertionError("This title is already taken.") + + return value + + @validates("description") + def validate_description(self, key, value): + if value is not None: + assert ( + len(value) <= 100 + ), "Description must be 100 characters or fewer." + + return value + + +class Location(Base): + __tablename__ = "locations" + + id = sa.Column( # noqa: A003 + sa.Integer, + unique=True, + primary_key=True, + autoincrement=True, + index=True, + ) + name = sa.Column(sa.Text, nullable=False) + date_start = sa.Column(sa.Date(), nullable=False) + date_end = sa.Column(sa.Date(), nullable=False) + + travel_id = sa.Column( + sa.Integer, + sa.ForeignKey("travels.id"), + nullable=False, + ) + + +class Note(Base): + __tablename__ = "notes" + + id = sa.Column( # noqa: A003 + sa.Integer, + unique=True, + primary_key=True, + autoincrement=True, + index=True, + ) + + file_id = sa.Column(sa.Text, nullable=False) + file_name = sa.Column(sa.Text, nullable=False) + file_type = sa.Column(sa.Text, nullable=False) + public: sa.Column[bool] = sa.Column( + sa.Boolean(), + nullable=False, + default=False, + ) + + author_id = sa.Column( + sa.BigInteger, + sa.ForeignKey(User.telegram_id), + nullable=False, + ) + travel_id = sa.Column( + sa.Integer, + sa.ForeignKey("travels.id"), + nullable=False, + ) diff --git a/app/models/user.py b/app/models/user.py index a5b27cd..4da0dbe 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,20 +1,16 @@ __all__ = ("User",) import re -from typing import Any import sqlalchemy as sa -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import validates +from sqlalchemy.orm import relationship, validates from app import session +from app.models import Base from app.utils import geo from app.utils.db import utcnow -Base: Any = declarative_base() - - class User(Base): __tablename__ = "users" @@ -38,6 +34,9 @@ class User(Base): server_default=utcnow(), ) + notes = relationship("Note", backref="author") + owned_travels = relationship("Travel", backref="author") + @validates("username") def validate_username(self, key, value): regex_pattern = re.compile(r"^[a-zA-Z0-9_]{5,20}$") @@ -98,6 +97,9 @@ class User(Base): return normalized_value + def get_user_travels(self): + return self.owned_travels + self.travels + def get_human_readable_datejoined(self): return self.date_joined.strftime("%Y-%m-%d %H:%M:%S") diff --git a/app/states/travel.py b/app/states/travel.py new file mode 100644 index 0000000..6ed1f8b --- /dev/null +++ b/app/states/travel.py @@ -0,0 +1,9 @@ +__all__ = () + +from aiogram.fsm.state import State, StatesGroup + + +class TravelCreationState(StatesGroup): + error_message_id = State() + title = State() + description = State()