diff --git a/app/callbacks/travels.py b/app/callbacks/travels.py index fdb5eac..84e50b5 100644 --- a/app/callbacks/travels.py +++ b/app/callbacks/travels.py @@ -1,5 +1,7 @@ __all__ = ("router",) +import datetime + from aiogram import F, Router from aiogram.exceptions import TelegramBadRequest from aiogram.filters import StateFilter @@ -10,10 +12,11 @@ from app import messages, session from app.config import Config from app.filters.user import Registered, RegisteredCallback from app.keyboards.builders import travels_keyboard -from app.keyboards.travel import get -from app.models.travel import Travel +from app.keyboards.confirm_location import get as confirm_location_get +from app.keyboards.travel import get as travel_get +from app.models.travel import Location, Travel from app.models.user import User -from app.states.travel import TravelAlteringState +from app.states.travel import CreateLocationState, TravelAlteringState from app.utils.states import delete_message_from_state, handle_validation_error @@ -36,19 +39,25 @@ async def travels_index_callback(callback: CallbackQuery) -> None: travels = user.get_user_travels() if not travels or travels == []: - await callback.message.edit_text(messages.NO_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, - user.telegram_id, - ), - ) + try: + await callback.message.edit_text( + messages.TRAVELS, + reply_markup=travels_keyboard( + travels, + page, + pages, + user.telegram_id, + ), + ) + except TelegramBadRequest: + pass @router.callback_query( @@ -74,15 +83,18 @@ async def travels_callback(callback: CallbackQuery) -> None: 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, - user.telegram_id, - ), - ) + try: + await callback.message.edit_text( + messages.TRAVELS, + reply_markup=travels_keyboard( + travels, + page, + pages, + user.telegram_id, + ), + ) + except TelegramBadRequest: + pass @router.callback_query( @@ -103,7 +115,7 @@ async def travel_detail_callback(callback: CallbackQuery) -> None: await callback.message.edit_text( travel.get_travel_text(), - reply_markup=get(travel_id), + reply_markup=travel_get(travel_id), ) @@ -125,6 +137,11 @@ async def travel_change_callback( travel_id, column = callback.data.replace("travel_change_", "").split("_") + travel = Travel().get_travel_by_id(travel_id) + + if not travel: + return + if column == "title": message = await callback.message.answer( f"{messages.INPUT_TRAVEL_TITLE}\n{messages.CANCEL_CHANGE}", @@ -199,7 +216,8 @@ async def travel_change_entered(message: Message, state: FSMContext) -> None: else: try: validated_description = Travel().validate_description( - key="description", value=value, + key="description", + value=value, ) except AssertionError as e: await handle_validation_error(message, state, e) @@ -207,7 +225,8 @@ async def travel_change_entered(message: Message, state: FSMContext) -> None: return await state.update_data( - value=validated_description, successfully=True, + value=validated_description, + successfully=True, ) await message.delete() @@ -230,7 +249,7 @@ async def travel_change_entered(message: Message, state: FSMContext) -> None: travel.get_travel_text(), message.chat.id, state_data["travel_message_id"], - reply_markup=get(travel_id), + reply_markup=travel_get(travel_id), ) except TelegramBadRequest: pass @@ -240,3 +259,333 @@ async def travel_change_entered(message: Message, state: FSMContext) -> None: ) await state.clear() + + +@router.callback_query( + F.data.startswith("travel_add_location"), + RegisteredCallback(), + StateFilter(None), +) +async def add_travel_location_callback( + callback: CallbackQuery, + state: FSMContext, +) -> None: + if ( + callback.data is None + or callback.message is None + or not isinstance(callback.message, Message) + ): + return + + travel_id = int(callback.data.replace("travel_add_location_", "")) + + travel = Travel().get_travel_by_id(travel_id) + + if not travel: + return + + await state.update_data(travel_id=travel_id) + await state.set_state(CreateLocationState.temp_location) + + await callback.message.answer( + messages.CREATE_LOCATION, + ) + await callback.message.answer( + messages.ENTER_LOCATION, + ) + + await callback.answer() + + +@router.message(CreateLocationState.temp_location, F.text, Registered()) +async def location_entered(message: Message, state: FSMContext) -> None: + if ( + message.text is None + or message.from_user is None + or message.bot is None + ): + return + + location = message.text.strip() + + if location == "/cancel": + await message.answer( + messages.ACTION_CANCELED, + ) + + await state.update_data() + await message.delete() + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) + await state.clear() + + return + + try: + validated_location = Location().validate_location( + key="location", + value=location, + ) + 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( + temp_location=validated_location, + temp_location_message_id=message.message_id, + ) + await state.set_state(CreateLocationState.location) + + await message.answer( + messages.CONFIRM_LOCATION.format(location=validated_location), + reply_markup=confirm_location_get(), + ) + + +@router.callback_query( + F.data.in_(["confirm_location", "cancel_location"]), + RegisteredCallback(), + StateFilter(CreateLocationState.location), +) +async def confirm_location( + callback: CallbackQuery, + state: FSMContext, +) -> None: + if ( + not callback.message + or not isinstance(callback.message, Message) + or callback.bot is None + ): + return + + data = await state.get_data() + location = data.get("temp_location") + + if callback.data == "confirm_location": + await delete_message_from_state( + state, + callback.from_user.id, + callback.bot, + ) + + await state.update_data(location=location) + await state.set_state(CreateLocationState.date_start) + + await callback.message.answer( + messages.INPUT_TRAVEL_CALLBACK.format( + key="location", + value=location, + ), + ) + await callback.message.answer( + messages.ENTER_LOCATION_DATE_START, + ) + elif callback.data == "cancel_location": + error_message = await callback.message.answer( + messages.CONFIRMATION_REEJECTED, + ) + + try: + await callback.bot.delete_message( + callback.from_user.id, + data["temp_location_message_id"], + ) + except TelegramBadRequest: + pass + + await state.set_state(CreateLocationState.temp_location) + await state.update_data(error_message_id=error_message.message_id) + + try: + await callback.message.delete() + except TelegramBadRequest: + pass + + await callback.answer() + + +@router.message(CreateLocationState.date_start, F.text, Registered()) +async def location_date_start_entered( + message: Message, + state: FSMContext, +) -> None: + if ( + message.text is None + or message.from_user is None + or message.bot is None + ): + return + + date_start = message.text.strip() + + if date_start == "/cancel": + await message.answer( + messages.ACTION_CANCELED, + ) + + await state.update_data() + await message.delete() + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) + await state.clear() + + return + + try: + validated_date_start = Location().validate_date_end( + key="date_start", + value=date_start, + ) + 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( + date_start=datetime.datetime.strftime( + validated_date_start, + "%Y-%m-%d %H:%M:%S", + ), + ) + await state.set_state(CreateLocationState.date_end) + + await message.answer( + messages.INPUT_TRAVEL_CALLBACK.format( + key="start date", + value=date_start, + ), + ) + await message.answer( + messages.ENTER_LOCATION_DATE_END, + ) + + +@router.message(CreateLocationState.date_end, F.text, Registered()) +async def location_date_end_entered( + message: Message, + state: FSMContext, +) -> None: + if ( + message.text is None + or message.from_user is None + or message.bot is None + ): + return + + date_end = message.text.strip() + + if date_end == "/cancel": + await message.answer( + messages.ACTION_CANCELED, + ) + + await state.update_data() + await message.delete() + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) + await state.clear() + + return + + try: + validated_date_end = Location().validate_date_end( + key="date_end", + value=date_end, + ) + except AssertionError as e: + await handle_validation_error(message, state, e) + + return + + date_start = (await state.get_data()).get("date_start") + + if validated_date_end <= datetime.datetime.strptime( + str(date_start), + "%Y-%m-%d %H:%M:%S", + ).replace(tzinfo=datetime.UTC): + await handle_validation_error( + message, + state, + messages.INVALID_DATE_END, + ) + + return + + await delete_message_from_state(state, message.chat.id, message.bot) + + await state.update_data( + date_end=datetime.datetime.strftime( + validated_date_end, + "%Y-%m-%d %H:%M:%S", + ), + ) + + data = await state.get_data() + + if "temp_location" in data: + del data["temp_location"] + + if "temp_location_message_id" in data: + del data["temp_location_message_id"] + + if "error_message_id" in data: + del data["error_message_id"] + + data["date_start"] = datetime.datetime.strptime( + data["date_start"], + "%Y-%m-%d %H:%M:%S", + ) + + data["date_end"] = datetime.datetime.strptime( + data["date_end"], + "%Y-%m-%d %H:%M:%S", + ) + + session.add(Location(**data)) + session.commit() + + await message.answer( + messages.LOCATION_ADDED, + ) + + await state.clear() + + +@router.callback_query( + F.data.startswith("travel_delete"), + RegisteredCallback(), + StateFilter(None), +) +async def delete_travel_callback( + callback: CallbackQuery, +): + if callback.data is None or not isinstance(callback.message, Message): + return + + travel_id = int(callback.data.replace("travel_delete_", "")) + + travel = Travel.get_travel_queryset_by_id(travel_id) + + travel.delete() + + session.commit() + + await callback.message.answer(messages.DELETED_TRAVEL) + + await callback.message.delete() + + await callback.answer() diff --git a/app/keyboards/confirm_location.py b/app/keyboards/confirm_location.py new file mode 100644 index 0000000..84f157e --- /dev/null +++ b/app/keyboards/confirm_location.py @@ -0,0 +1,18 @@ +__all__ = ("get",) + +from aiogram import types +from aiogram.utils.keyboard import InlineKeyboardBuilder + + +def get(): + builder = InlineKeyboardBuilder() + + builder.row( + types.InlineKeyboardButton( + text="Yes", + callback_data="confirm_location", + ), + types.InlineKeyboardButton(text="No", callback_data="cancel_location"), + ) + + return builder.as_markup() diff --git a/app/keyboards/travel.py b/app/keyboards/travel.py index 7cb57bb..caab9f0 100644 --- a/app/keyboards/travel.py +++ b/app/keyboards/travel.py @@ -23,18 +23,34 @@ def get(travel_id: int): callback_data=f"travel_locations_{travel_id}", ), types.InlineKeyboardButton( - text="👤 Users", - callback_data=f"travel_users_{travel_id}", + text="➕ Add location", + callback_data=f"travel_add_location_{travel_id}", ), ) builder.row( types.InlineKeyboardButton( - text="➕ Add location", - callback_data=f"travel_add_{travel_id}_location", + text="👤 Users", + callback_data=f"travel_users_{travel_id}", ), types.InlineKeyboardButton( text="➕ Add user", - callback_data=f"travel_add_{travel_id}_user", + callback_data=f"travel_add_user_{travel_id}", + ), + ) + builder.row( + types.InlineKeyboardButton( + text="📝 Notes", + callback_data=f"travel_notes_{travel_id}", + ), + types.InlineKeyboardButton( + text="➕ Add note", + callback_data=f"travel_add_note_{travel_id}", + ), + ) + builder.row( + types.InlineKeyboardButton( + text="❌ Delete travel", + callback_data=f"travel_delete_{travel_id}", ), ) builder.row( diff --git a/app/messages.py b/app/messages.py index e4bce33..c6ed85b 100644 --- a/app/messages.py +++ b/app/messages.py @@ -2,6 +2,18 @@ MENU = "Menu:" +CREATE_LOCATION = "✈️ Lets create new location!" +ENTER_LOCATION = "Enter location:" +CONFIRM_LOCATION = "Is this location correct: {location}?" +CONFIRMATION_REEJECTED = ( + "❌ Confirmation rejected. Please re-enter the location." +) +ENTER_LOCATION_DATE_START = "Enter location start datetime(in UTC) in this format:\nFormat: YYYY-MM-DD HH:MM\nExample: 2022-01-01 00:00" +ENTER_LOCATION_DATE_END = "Enter location end datetime(in UTC) in this format:\nFormat: YYYY-MM-DD HH:MM\nExample: 2022-01-01 00:00" +INVALID_DATE_END = "End date can't be earlier or equal to start date." +LOCATION_ADDED = "✅ Location added" + +DELETED_TRAVEL = "✅ Travel deleted" TRAVELS = "📃 Travels:\n👑 - owner" NO_TRAVELS = "No travels yet. You can create one with /create_travel command." CREATE_TRAVEL = ( @@ -21,6 +33,7 @@ TRAVEL_CREATED = "Travel {title} successfully created! You can now view a ACTION_CANCELED = "❌ Action canceled" TRAVEL_DETAIL = ( "📝 Travel detail\n\n" + "\tID: {travel_id}\n" "\tTitle: {title}\n" "\tDescription: {description}\n" ) diff --git a/app/migrations/versions/fe4ace4196fb_added_travel_models.py b/app/migrations/versions/4dea8f302149_added_travel_models.py similarity index 90% rename from app/migrations/versions/fe4ace4196fb_added_travel_models.py rename to app/migrations/versions/4dea8f302149_added_travel_models.py index bbed24d..9ec0c17 100644 --- a/app/migrations/versions/fe4ace4196fb_added_travel_models.py +++ b/app/migrations/versions/4dea8f302149_added_travel_models.py @@ -1,8 +1,8 @@ """Added travel models -Revision ID: fe4ace4196fb +Revision ID: 4dea8f302149 Revises: 4914f00ae14a -Create Date: 2024-03-22 19:19:36.662090 +Create Date: 2024-03-24 17:56:20.975589 """ @@ -13,7 +13,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = 'fe4ace4196fb' +revision: str = '4dea8f302149' down_revision: Union[str, None] = '4914f00ae14a' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -38,9 +38,9 @@ def upgrade() -> None: 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('location', sa.Text(), nullable=False), + sa.Column('date_start', sa.DateTime(timezone=True), nullable=False), + sa.Column('date_end', sa.DateTime(timezone=True), nullable=False), sa.Column('travel_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint( ['travel_id'], diff --git a/app/models/travel.py b/app/models/travel.py index 509cd48..45bbba6 100644 --- a/app/models/travel.py +++ b/app/models/travel.py @@ -1,11 +1,14 @@ __all__ = ("Travel", "Location") +import datetime + import sqlalchemy as sa from sqlalchemy.orm import relationship, validates from app import messages, session from app.models import Base from app.models.user import User +from app.utils.geo import get_location_by_name association_table = sa.Table( @@ -73,6 +76,7 @@ class Travel(Base): def get_travel_text(self): return messages.TRAVEL_DETAIL.format( + travel_id=self.id, title=self.title, description=( self.description if self.description else messages.NOT_SET @@ -98,9 +102,15 @@ class Location(Base): 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) + location = sa.Column(sa.Text, nullable=False) + date_start = sa.Column( + sa.DateTime(timezone=True), + nullable=False, + ) + date_end = sa.Column( + sa.DateTime(timezone=True), + nullable=False, + ) travel_id = sa.Column( sa.Integer, @@ -108,6 +118,46 @@ class Location(Base): nullable=False, ) + @validates("location") + def validate_location(self, key, value): + geocoder = get_location_by_name(value) + + assert geocoder[0], "Invalid location." + + return geocoder[1].raw["display_name"] + + def validate_date_start(self, key, value): + try: + value_datetime = datetime.datetime.strptime( + value, + "%Y-%m-%d %H:%M", + ) + value_datetime = value_datetime.replace(tzinfo=datetime.UTC) + except ValueError: + raise AssertionError("Invalid datetime format.") + + assert value_datetime >= datetime.datetime.now( + datetime.UTC, + ), "Invalid datetime." + + return value_datetime + + def validate_date_end(self, key, value): + try: + value_datetime = datetime.datetime.strptime( + value, + "%Y-%m-%d %H:%M", + ) + value_datetime = value_datetime.replace(tzinfo=datetime.UTC) + except ValueError: + raise AssertionError("Invalid datetime format.") + + assert value_datetime >= datetime.datetime.now( + datetime.UTC, + ), "Invalid datetime." + + return value_datetime + class Note(Base): __tablename__ = "notes" diff --git a/app/states/travel.py b/app/states/travel.py index 3543954..1e98d53 100644 --- a/app/states/travel.py +++ b/app/states/travel.py @@ -17,3 +17,14 @@ class TravelAlteringState(StatesGroup): travel_id = State() column = State() value = State() + + +class CreateLocationState(StatesGroup): + temp_location_message_id = State() + error_message_id = State() + travel_id = State() + location = State() + temp_location = State() + location = State() + date_start = State() + date_end = State() diff --git a/app/utils/geo.py b/app/utils/geo.py index 9d7e2a5..5728a02 100644 --- a/app/utils/geo.py +++ b/app/utils/geo.py @@ -1,5 +1,5 @@ # type: ignore -__all__ = ("validate_country", "validate_city") +__all__ = ("validate_country", "validate_city", "get_location_by_name") from geopy.exc import GeocoderTimedOut from geopy.geocoders import Nominatim @@ -72,3 +72,24 @@ def validate_city(city: str, country: str): return True, normalized_country return False, None + + +def get_location_by_name(location: str) -> None: + geolocator = Nominatim(user_agent="travel_agent_bot") + + for _ in range(3): + try: + geocode = geolocator.geocode( + location, + featuretype="city", + ) + break + except GeocoderTimedOut: + continue + else: + return False, None + + if not geocode: + return False, None + + return True, geocode diff --git a/template.env b/template.env index 5db8394..85b13e4 100644 --- a/template.env +++ b/template.env @@ -15,4 +15,4 @@ REDIS_PORT = # default: 6379 PGADMIN_PORT = # default: 5050 PGADMIN_EMAIL = # default: admin@mail.com -PGADMIN_PASSWORD # default: admin +PGADMIN_PASSWORD = # default: admin