feat: Added travel models, travel creation command, travels list command with pagination, set help message, improvements and fixes

This commit is contained in:
ITQ
2024-03-23 02:47:05 +03:00
parent 675e5ab891
commit 1802ce81b0
15 changed files with 623 additions and 15 deletions
+7 -1
View File
@@ -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)
+60 -1
View File
@@ -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()
+40
View File
@@ -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),
)
+1
View File
@@ -17,3 +17,4 @@ class Config:
"REDIS_URL",
"redis://localhost:6379",
)
PAGE_SIZE = 6
+135
View File
@@ -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"]))
+36
View File
@@ -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),
)
+67 -4
View File
@@ -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()
+26 -2
View File
@@ -2,10 +2,34 @@
MENU = "<b>Menu:</b>"
TRAVELS = "📃 <b>Travels:</b>"
NO_TRAVELS = "No travels yet. You can create one with /create_travel command."
CREATE_TRAVEL = "🧳 Let's create new travel!\n<i>Enter /cancel to cancel creating.</i>"
INPUT_TRAVEL_TITLE = "Enter travel title:\n<i>Maximum length: 30 characters</i>"
INPUT_TRAVEL_CALLBACK = (
"All right, travel <b>{key}</b> is set to: <b>{value}</b>"
)
INPUT_TRAVEL_DESCRIPTION = (
"Enter travel description (enter /skip if you want to skip this step):\n<i>Maximum length: 100 characters</i>"
)
INPUT_TRAVEL_DESCRIPTION_SKIPPED = "Sure. You can always fill it later."
TRAVEL_CREATED = "Travel <b>{title}</b> successfully created! You can now view and edit it in the travels list (/travels command)."
ACTION_CANCELED = "❌ Action canceled"
WELCOME_MESSAGE = "Hello, <b>{name}</b>! Welcome to the ✈️ Travel Agent bot! Let's start our journey by filling out some information about you."
WELCOME_AGAIN_MESSAGE = "Hello, <b>{name}</b>! 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: <b>{country}</b>\n"
"\tCity: <b>{city}</b>\n"
"\tBio: <b>{bio}</b>\n"
"\tDate joined: <b>{date_joined}</b>\n"
"\tDate joined: <b>{date_joined} UTC</b>\n"
)
NOT_SET = "<i>Not set</i>"
EDIT_USERNAME = "Enter your username:\n<i>Allowed characters: a-z, A-Z, 0-9, _</i>\n<i>Length: 5-20 characters</i>"
+1 -1
View File
@@ -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()
@@ -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 ###
+7
View File
@@ -0,0 +1,7 @@
# flake8: noqa
from app.models.base import Base
import app.models.user
import app.models.travel
Base.registry.configure()
+5
View File
@@ -0,0 +1,5 @@
from typing import Any
from sqlalchemy.ext.declarative import declarative_base
Base: Any = declarative_base()
+124
View File
@@ -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,
)
+8 -6
View File
@@ -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")
+9
View File
@@ -0,0 +1,9 @@
__all__ = ()
from aiogram.fsm.state import State, StatesGroup
class TravelCreationState(StatesGroup):
error_message_id = State()
title = State()
description = State()