diff --git a/Dockerfile b/Dockerfile index 1f72b91..9e0e5d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,3 @@ RUN pip install --no-cache-dir -r prod.txt # Copy the rest of the application files COPY . . - -# Apply migrations -# RUN alembic -c app/alembic.ini upgrade head - -CMD ["python", "-m", "app"] diff --git a/app/bot.py b/app/bot.py index 918fac6..892b576 100644 --- a/app/bot.py +++ b/app/bot.py @@ -4,6 +4,7 @@ from typing import Optional from aiogram import Bot, Dispatcher from aiogram.enums import ParseMode +from aiogram.fsm.storage.redis import RedisStorage from app.callbacks import profile from app.config import Config @@ -12,22 +13,23 @@ from app.middlewares.throttling import ThrottlingMiddleware async def main() -> None: - dp = Dispatcher() - bot_token: Optional[str] = Config.BOT_TOKEN - if bot_token is not None: - bot = Bot(bot_token, parse_mode=ParseMode.HTML) - dp.message.middleware(ThrottlingMiddleware(0.5)) - # type: ignore - dp.include_routers( - start_command.router, - profile_command.router, - profile.router, - help_command.router, - ) - - await bot.delete_webhook(drop_pending_updates=True) - await dp.start_polling(bot) - else: + if bot_token is None: exit("BOT_TOKEN is not set") + + storage = RedisStorage.from_url(Config.REDIS_URL) + dp = Dispatcher(storage=storage) + bot = Bot(bot_token, parse_mode=ParseMode.HTML) + + dp.message.middleware(ThrottlingMiddleware(0.5)) + # type: ignore + dp.include_routers( + start_command.router, + profile_command.router, + profile.router, # type: ignore + help_command.router, + ) + + await bot.delete_webhook(drop_pending_updates=True) + await dp.start_polling(bot) diff --git a/app/callbacks/profile.py b/app/callbacks/profile.py index 8a7d4d1..e574033 100644 --- a/app/callbacks/profile.py +++ b/app/callbacks/profile.py @@ -33,23 +33,31 @@ async def profile_change_callback( column = callback.data.replace("profile_change_", "") if column == "username": - message = await callback.message.answer(messages.EDIT_USERNAME) + message = await callback.message.answer( + f"{messages.EDIT_USERNAME}\n{messages.CANCEL_CHANGE}", + ) elif column == "age": - message = await callback.message.answer(messages.INPUT_AGE) + message = await callback.message.answer( + f"{messages.INPUT_AGE}\n{messages.CANCEL_CHANGE}", + ) elif column == "bio": - message = await callback.message.answer(messages.EDIT_BIO) + message = await callback.message.answer( + f"{messages.EDIT_BIO}\n{messages.CANCEL_CHANGE}", + ) elif column == "sex": message = await callback.message.answer( - messages.INPUT_SEX, + f"{messages.INPUT_SEX}\n{messages.CANCEL_CHANGE}", reply_markup=profile(["Male", "Female"]), ) elif column == "location": - message = await callback.message.answer(messages.INPUT_LOCATION) + message = await callback.message.answer( + f"{messages.INPUT_LOCATION}\n{messages.CANCEL_CHANGE}", + ) await state.update_data( column=column, message_id=callback.message.message_id, - input_message=message, + input_message_id=message.message_id, ) await state.set_state(UserAltering.value) @@ -61,6 +69,23 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: column = (await state.get_data()).get("column") value = message.text.strip() + if value == "/cancel": + await message.answer( + messages.CHANGE_CANCELED, + reply_markup=ReplyKeyboardRemove(), + ) + + await state.update_data(successfully=True) + await message.delete() + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) + await state.clear() + + return + if column == "username": try: validated_value = User().validate_username( @@ -69,10 +94,16 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: ) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data( + previous_message_id=error_message.message_id, + ) return @@ -82,10 +113,16 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: validated_age = User().validate_age(key="age", value=value) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data( + previous_message_id=error_message.message_id, + ) return @@ -93,16 +130,26 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: elif column == "bio": if value == "/skip": await state.update_data(value=None, successfully=True) - await delete_message_from_state(state) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) else: try: validated_bio = User().validate_bio(key="bio", value=value) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data( + previous_message_id=error_message.message_id, + ) return @@ -114,10 +161,16 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: validated_sex = User().validate_sex(key="sex", value=value) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data( + previous_message_id=error_message.message_id, + ) return @@ -127,12 +180,18 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: if len(location) != 2: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) error_message = await message.answer( messages.VALIDATION_ERROR_MESSAGE, ) - await state.update_data(previous_message=error_message) + await state.update_data( + previous_message_id=error_message.message_id, + ) return @@ -145,10 +204,16 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: ) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data( + previous_message_id=error_message.message_id, + ) return @@ -159,18 +224,27 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: ) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data( + previous_message_id=error_message.message_id, + ) return - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) - await state.update_data(value=[validated_country, validated_city]) + await state.update_data( + value=[validated_country, validated_city], + successfully=True, + ) - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) state_data = await state.get_data() diff --git a/app/config.py b/app/config.py index 034d344..831a73a 100644 --- a/app/config.py +++ b/app/config.py @@ -13,3 +13,7 @@ class Config: "SQLALCHEMY_DATABASE_URI", "sqlite:///database.db", ) + REDIS_URL = os.getenv( + "REDIS_URL", + "redis://localhost:6379", + ) diff --git a/app/handlers/start_command.py b/app/handlers/start_command.py index e58a903..9248be2 100644 --- a/app/handlers/start_command.py +++ b/app/handlers/start_command.py @@ -52,14 +52,14 @@ async def username_handler(message: Message, state: FSMContext) -> None: ) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data(previous_message_id=error_message.message_id) return - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) await state.update_data(username=validated_username) await state.set_state(RegistrationForm.age) @@ -84,14 +84,14 @@ async def age_handler(message: Message, state: FSMContext) -> None: validated_age = User().validate_age(key="age", value=age) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data(previous_message_id=error_message.message_id) return - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) await state.update_data(age=validated_age) await state.set_state(RegistrationForm.sex) @@ -116,14 +116,14 @@ async def sex_handler(message: Message, state: FSMContext) -> None: validated_sex = User().validate_sex(key="sex", value=sex) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data(previous_message_id=error_message.message_id) return - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) await state.update_data(sex=validated_sex) await state.set_state(RegistrationForm.bio) @@ -146,7 +146,7 @@ async def bio_handler(message: Message, state: FSMContext) -> None: await state.update_data(bio=None) await state.set_state(RegistrationForm.location) - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) await message.answer(messages.INPUT_BIO_SKIPPED) await message.answer(messages.INPUT_LOCATION) @@ -155,14 +155,20 @@ async def bio_handler(message: Message, state: FSMContext) -> None: validated_bio = User().validate_bio(key="bio", value=bio) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data( + previous_message_id=error_message.message_id, + ) return - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) await state.update_data(bio=validated_bio) await state.set_state(RegistrationForm.location) @@ -182,10 +188,10 @@ async def location_handler(message: Message, state: FSMContext) -> None: if len(location) != 2: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) error_message = await message.answer(messages.VALIDATION_ERROR_MESSAGE) - await state.update_data(previous_message=error_message) + await state.update_data(previous_message_id=error_message.message_id) return @@ -198,10 +204,10 @@ async def location_handler(message: Message, state: FSMContext) -> None: ) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data(previous_message_id=error_message.message_id) return @@ -212,14 +218,14 @@ async def location_handler(message: Message, state: FSMContext) -> None: ) except AssertionError as e: await message.delete() - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) error_message = await message.answer(str(e)) - await state.update_data(previous_message=error_message) + await state.update_data(previous_message_id=error_message.message_id) return - await delete_message_from_state(state) + await delete_message_from_state(state, message.chat.id, message.bot) await state.update_data(location=[validated_country, validated_city]) data = await state.get_data() @@ -236,7 +242,9 @@ async def location_handler(message: Message, state: FSMContext) -> None: data["country"] = data["location"][0] data["city"] = data["location"][1] del data["location"] - del data["previous_message"] + + if "previous_message_id" in data: + del data["previous_message_id"] session.add(User(**data)) session.commit() diff --git a/app/messages.py b/app/messages.py index db287d0..72d2d0e 100644 --- a/app/messages.py +++ b/app/messages.py @@ -15,6 +15,7 @@ INPUT_BIO_SKIPPED = "Sure. You can always fill it later." INPUT_LOCATION = "Enter your location in this format:\nFormat: country, city\nExample: Russia, Moscow" INPUT_CALLBACK = "All right, your {key} is set to: {value}" VALIDATION_ERROR_MESSAGE = "Invalid input. Please try again." +CANCEL_CHANGE = "Enter /cancel to cancel change." PROFILE = ( "Your profile:\n\n" @@ -22,10 +23,11 @@ PROFILE = ( "\tAge: {age}\n" "\tSex: {sex}\n" "\tCountry: {country}\n" - "\tCity: {city}" + "\tCity: {city}\n" "\tBio: {bio}\n" ) NOT_SET = "Not set" EDIT_USERNAME = "Enter your username:\nAllowed characters: a-z, A-Z, 0-9, _\nLength: 5-20 characters" EDIT_BIO = "Enter your bio (enter /skip if you want to set it to None):\nMaximum length: 100 characters" PROFILE_UPDATED = "✅ Profile updated" +CHANGE_CANCELED = "❌ Change canceled" diff --git a/app/migrations/versions/2ed063a74f39_added_user_model.py b/app/migrations/versions/2ed063a74f39_added_user_model.py new file mode 100644 index 0000000..556adaa --- /dev/null +++ b/app/migrations/versions/2ed063a74f39_added_user_model.py @@ -0,0 +1,46 @@ +"""Added user model + +Revision ID: 2ed063a74f39 +Revises: +Create Date: 2024-03-21 21:40:05.789793 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2ed063a74f39' +down_revision: Union[str, None] = None +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( + 'users', + sa.Column('telegram_id', sa.BigInteger(), nullable=False), + sa.Column('username', sa.String(length=32), nullable=False), + sa.Column('age', sa.SmallInteger(), nullable=False), + sa.Column('bio', sa.String(length=100), nullable=True), + sa.Column('sex', sa.String(length=6), nullable=True), + sa.Column('country', sa.Text(), nullable=False), + sa.Column('city', sa.Text(), nullable=False), + sa.PrimaryKeyConstraint('telegram_id'), + sa.UniqueConstraint('username'), + ) + op.create_index( + op.f('ix_users_telegram_id'), 'users', ['telegram_id'], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_telegram_id'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/app/migrations/versions/50fa8edaaf94_added_user_model.py b/app/migrations/versions/50fa8edaaf94_added_user_model.py deleted file mode 100644 index 152235f..0000000 --- a/app/migrations/versions/50fa8edaaf94_added_user_model.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Added user model - -Revision ID: 50fa8edaaf94 -Revises: -Create Date: 2024-03-21 18:31:15.864426 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '50fa8edaaf94' -down_revision: Union[str, None] = None -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('users', - sa.Column('telegram_id', sa.BigInteger(), nullable=False), - sa.Column('username', sa.String(length=20), nullable=False), - sa.Column('age', sa.SmallInteger(), nullable=False), - sa.Column('bio', sa.String(length=100), nullable=True), - sa.Column('sex', sa.String(length=6), nullable=True), - sa.Column('country', sa.Text(), nullable=False), - sa.Column('city', sa.Text(), nullable=False), - sa.PrimaryKeyConstraint('telegram_id'), - sa.UniqueConstraint('username') - ) - op.create_index(op.f('ix_users_telegram_id'), 'users', ['telegram_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_users_telegram_id'), table_name='users') - op.drop_table('users') - # ### end Alembic commands ### diff --git a/app/models/user.py b/app/models/user.py index 582ab35..d9963b1 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -18,7 +18,7 @@ class User(Base): __tablename__ = "users" telegram_id = sa.Column(sa.BigInteger, primary_key=True, index=True) - username = sa.Column(sa.String(20), nullable=False, unique=True) + username = sa.Column(sa.String(32), nullable=False, unique=True) age = sa.Column(sa.SmallInteger, nullable=False) bio = sa.Column(sa.String(100), nullable=True) sex = sa.Column(sa.String(6), nullable=True) @@ -29,7 +29,7 @@ class User(Base): def validate_username(self, key, value): regex_pattern = re.compile(r"^[a-zA-Z0-9_]{5,20}$") - assert len(value) <= 20, "Username must be 20 characters or fewer." + assert len(value) <= 32, "Username must be 20 characters or fewer." assert len(value) >= 5, "Username must be at least 5 characters." assert ( re.match(regex_pattern, value) is not None @@ -42,7 +42,7 @@ class User(Base): @validates("age") def validate_age(self, key, value): - assert str(value).isnumeric(), "Invalid input. Please try again." + assert str(value).isdigit(), "Invalid input. Please try again." value = int(value) assert value >= 13, "You must be at least 13 years old." assert value <= 120, "You must be less than 120 years old." diff --git a/app/utils/states.py b/app/utils/states.py index 6aee704..0b3b016 100644 --- a/app/utils/states.py +++ b/app/utils/states.py @@ -1,12 +1,13 @@ __all__ = ("RegistrationForm",) +from aiogram import Bot from aiogram.exceptions import TelegramBadRequest -from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup class RegistrationForm(StatesGroup): - previous_message = State() + previous_message_id = State() username = State() age = State() bio = State() @@ -15,34 +16,50 @@ class RegistrationForm(StatesGroup): class UserAltering(StatesGroup): - successfully = State() - message_id = State() - input_message = State() - previous_message = State() column = State() value = State() + message_id = State() + input_message_id = State() + previous_message_id = State() + successfully = State() -async def delete_message_from_state(state: FSMContext) -> None: +async def delete_message_from_state( + state: FSMContext, + chat_id: int, + bot: Bot | None, +) -> None: + if bot is None: + return + data = await state.get_data() - if "previous_message" in data and data["previous_message"] is not None: + if ( + "previous_message_id" in data + and data["previous_message_id"] is not None + ): try: - await data["previous_message"].delete() + await bot.delete_message( + message_id=data["previous_message_id"], + chat_id=chat_id, + ) except TelegramBadRequest: pass - await state.update_data(previous_message=None) + await state.update_data(previous_message_id=None) if ( - "input_message" in data - and data["input_message"] is not None + "input_message_id" in data + and data["input_message_id"] is not None and "successfully" in data and data["successfully"] ): try: - await data["input_message"].delete() + await bot.delete_message( + message_id=data["input_message_id"], + chat_id=chat_id, + ) except TelegramBadRequest: pass - await state.update_data(info_message=None) + await state.update_data(input_message_id=None) diff --git a/docker-compose.yml b/docker-compose.yml index 3204a16..0f3abf2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,62 @@ name: travel_agent_bot -version: '3' +version: "3" services: - app: - build: . - container_name: bot - depends_on: - - postgres - postgres: image: postgres:latest - container_name: db + container_name: postgres + healthcheck: + test: pg_isready -U postgres -h localhost + interval: 5s + timeout: 5s + retries: 10 environment: POSTGRES_DB: postgres POSTGRES_USER: postgres POSTGRES_PASSWORD: wTAb5KoZ4dBtscg ports: - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:latest + container_name: redis + healthcheck: + test: ["CMD", "redis-cli", "--raw", "incr", "ping"] + ports: + - "6379:6379" + volumes: + - redis_data:/data + + app: + build: . + container_name: bot + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + BOT_TOKEN: ${BOT_TOKEN} + REDIS_URL: redis://redis:6379/ + SQLALCHEMY_DATABASE_URI: postgresql://postgres:wTAb5KoZ4dBtscg@postgres:5432/postgres + entrypoint: ["bash", "-c"] + command: ["alembic -c app/alembic.ini upgrade head && python -m app"] + + pgadmin: + image: dpage/pgadmin4 + container_name: pgadmin + environment: + PGADMIN_DEFAULT_EMAIL: admin@mail.com + PGADMIN_DEFAULT_PASSWORD: admin + ports: + - "5050:80" + restart: always + volumes: + - pgadmin_data:/var/lib/pgadmin volumes: - travel_agent_bot_data: + postgres_data: + redis_data: + pgadmin_data: diff --git a/requirements/prod.txt b/requirements/prod.txt index 575c28b..efcd7e9 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -4,4 +4,5 @@ cachetools==5.3.3 geopy==2.4.1 psycopg2-binary==2.9.9 python-dotenv==1.0.1 +redis==5.0.3 sqlalchemy==2.0.28