diff --git a/app/bot.py b/app/bot.py index eee7c72..a640815 100644 --- a/app/bot.py +++ b/app/bot.py @@ -6,17 +6,8 @@ from aiogram import Bot, Dispatcher from aiogram.enums import ParseMode from aiogram.fsm.storage.redis import RedisStorage -from app.callbacks import location, menu, notes, profile, travel +from app import callbacks, handlers, middlewares 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 async def main() -> None: @@ -29,20 +20,21 @@ async def main() -> None: dp = Dispatcher(storage=storage) bot = Bot(bot_token, parse_mode=ParseMode.HTML) - dp.message.middleware(ThrottlingMiddleware(0.5)) + dp.message.middleware(middlewares.throttling.ThrottlingMiddleware(0.5)) dp.include_routers( - start_command.router, - help_command.router, - menu_command.router, - profile_command.router, - create_travel_command.router, - travels_command.router, - menu.router, - profile.router, - travel.router, - location.router, - notes.router, + handlers.start_command.router, + handlers.help_command.router, + handlers.menu_command.router, + handlers.profile_command.router, + handlers.create_travel_command.router, + handlers.travels_command.router, + callbacks.menu.router, + callbacks.profile.router, + callbacks.travel.router, + callbacks.location.router, + callbacks.notes.router, + callbacks.fallback.router, ) await bot.delete_webhook(drop_pending_updates=True) diff --git a/app/callbacks/__init__.py b/app/callbacks/__init__.py new file mode 100644 index 0000000..829ff92 --- /dev/null +++ b/app/callbacks/__init__.py @@ -0,0 +1,3 @@ +__all__ = ("fallback", "location", "menu", "profile", "travel", "notes") + +from app.callbacks import fallback, location, menu, notes, profile, travel diff --git a/app/callbacks/fallback.py b/app/callbacks/fallback.py new file mode 100644 index 0000000..30b0bb9 --- /dev/null +++ b/app/callbacks/fallback.py @@ -0,0 +1,17 @@ +__all__ = ("router",) + +from aiogram import Router +from aiogram.filters import StateFilter +from aiogram.types import CallbackQuery + +router = Router(name="fallback_callback") + + +@router.callback_query(~StateFilter(None)) +async def in_state_callback(callback: CallbackQuery): + await callback.answer("Fallback text in state", show_alert=True) + + +@router.callback_query() +async def fallback_callback(callback: CallbackQuery): + await callback.answer("Fallback text", show_alert=True) diff --git a/app/callbacks/notes.py b/app/callbacks/notes.py index 6a331b5..a71f177 100644 --- a/app/callbacks/notes.py +++ b/app/callbacks/notes.py @@ -17,7 +17,7 @@ from app.states.travel import ( ) -router = Router(name="menu_callback") +router = Router(name="notes_callback") @router.callback_query( @@ -110,12 +110,26 @@ async def create_note_file_id(message: Message, state: FSMContext): data["author_id"] = message.from_user.id - session.add(Note(**data)) + note = Note(**data) + + session.add(note) session.commit() + if not note or note == []: + return + + if note.file_type == "photo": + file_name = f"Photo ID: {note.id}" + elif note.file_type == "video": + file_name = f"Video ID: {note.id}" + elif note.file_type == "voice": + file_name = f"Voice ID: {note.id}" + else: + file_name = note.file_name + await message.answer( - messages.NOTE_ADDED.format(file_name=data["file_name"]), + messages.NOTE_ADDED.format(file_name=file_name), ) await state.clear() @@ -299,13 +313,21 @@ async def travel_notedelete_callback( note = Note().get_note_queryset_by_id(note_id) - note_first = note.first() - file_name = note_first.file_name - travel = note_first.travel - if not note or note == []: return + note_first = note.first() + travel = note_first.travel + + if note_first.file_type == "photo": + file_name = f"Photo ID: {note_first.id}" + elif note_first.file_type == "video": + file_name = f"Video ID: {note_first.id}" + elif note_first.file_type == "voice": + file_name = f"Voice ID: {note_first.id}" + else: + file_name = note_first.file_name + note.delete() session.commit() diff --git a/app/callbacks/travel.py b/app/callbacks/travel.py index d48d7f1..8953cae 100644 --- a/app/callbacks/travel.py +++ b/app/callbacks/travel.py @@ -11,6 +11,7 @@ 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 as travel_get +from app.keyboards.routes import get as routes_get from app.models.travel import Travel from app.models.user import User from app.states.travel import ( @@ -19,7 +20,7 @@ from app.states.travel import ( from app.utils.states import delete_message_from_state, handle_validation_error -router = Router(name="menu_callback") +router = Router(name="travel_callback") @router.callback_query( @@ -301,3 +302,35 @@ async def delete_travel_callback( ) await callback.answer() + + +@router.callback_query( + F.data.startswith("travel_routes"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_routes_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + travel_id = int(callback.data.replace("travel_routes_", "")) + + travel = Travel().get_travel_by_id(travel_id) + + if not travel: + await callback.answer() + + return + + await callback.message.edit_text( + travel.get_travel_text(), + reply_markup=routes_get(travel), + ) + + await callback.answer() diff --git a/app/handlers/__init__.py b/app/handlers/__init__.py new file mode 100644 index 0000000..d416092 --- /dev/null +++ b/app/handlers/__init__.py @@ -0,0 +1,17 @@ +__all__ = ( + "start_command", + "help_command", + "menu_command", + "profile_command", + "create_travel_command", + "travels_command", +) + +from app.handlers import ( + create_travel_command, + help_command, + menu_command, + profile_command, + start_command, + travels_command, +) diff --git a/app/keyboards/__init__.py b/app/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/keyboards/routes.py b/app/keyboards/routes.py new file mode 100644 index 0000000..19fb829 --- /dev/null +++ b/app/keyboards/routes.py @@ -0,0 +1,63 @@ +__all__ = ("get",) + +from aiogram import types +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from app.models.travel import Travel +from app.utils.geo import get_location_by_name +from app.utils.map import get_url_map + + +def get(travel: Travel): + locations = Travel().get_sorted_locations(travel, asc=False) + coordinats = [] + + for location in locations: + geocode = get_location_by_name(location.location) + coordinats.append( + [geocode[1].raw.get("lat"), geocode[1].raw.get("lon")], + ) + + builder = InlineKeyboardBuilder() + + builder.row( + types.InlineKeyboardButton( + text="πŸ—ΊοΈ Route by car", + web_app=types.WebAppInfo( + url=get_url_map( + coordinats=coordinats, + profile="car", + ), + ), + ), + ) + builder.row( + types.InlineKeyboardButton( + text="πŸ—ΊοΈ Route on foot", + web_app=types.WebAppInfo( + url=get_url_map( + coordinats=coordinats, + profile="foot", + ), + ), + ), + ) + builder.row( + types.InlineKeyboardButton( + text="πŸ—ΊοΈ Route by bike", + web_app=types.WebAppInfo( + url=get_url_map( + coordinats=coordinats, + profile="bike", + ), + ), + ), + ) + builder.row( + types.InlineKeyboardButton( + text="⬅️", + callback_data=f"travel_detail_{travel.id}", + ), + ) + + return builder.as_markup() diff --git a/app/keyboards/travel.py b/app/keyboards/travel.py index 4e72b60..b9cbd9f 100644 --- a/app/keyboards/travel.py +++ b/app/keyboards/travel.py @@ -4,20 +4,9 @@ from aiogram import types from aiogram.utils.keyboard import InlineKeyboardBuilder from app.models.travel import Travel -from app.utils.geo import get_location_by_name -from app.utils.map import get_url_map def get(travel: Travel): - locations = Travel().get_sorted_locations(travel, asc=False) - coordinats = [] - - for location in locations: - geocode = get_location_by_name(location.location) - coordinats.append( - [geocode[1].raw.get("lat"), geocode[1].raw.get("lon")], - ) - builder = InlineKeyboardBuilder() builder.row( @@ -62,35 +51,8 @@ def get(travel: Travel): ) builder.row( types.InlineKeyboardButton( - text="πŸ—ΊοΈ Route by car", - web_app=types.WebAppInfo( - url=get_url_map( - coordinats=coordinats, - profile="car", - ), - ), - ), - ) - builder.row( - types.InlineKeyboardButton( - text="πŸ—ΊοΈ Route on foot", - web_app=types.WebAppInfo( - url=get_url_map( - coordinats=coordinats, - profile="foot", - ), - ), - ), - ) - builder.row( - types.InlineKeyboardButton( - text="πŸ—ΊοΈ Route by bike", - web_app=types.WebAppInfo( - url=get_url_map( - coordinats=coordinats, - profile="bike", - ), - ), + text="πŸ—ΊοΈ Routes", + callback_data=f"travel_routes_{travel.id}", ), ) builder.row( @@ -110,15 +72,6 @@ def get(travel: Travel): def get_public(travel: Travel): - locations = Travel().get_sorted_locations(travel, asc=False) - coordinats = [] - - for location in locations: - geocode = get_location_by_name(location.location) - coordinats.append( - [geocode[1].raw.get("lat"), geocode[1].raw.get("lon")], - ) - builder = InlineKeyboardBuilder() builder.row( @@ -145,35 +98,8 @@ def get_public(travel: Travel): ) builder.row( types.InlineKeyboardButton( - text="πŸ—ΊοΈ Route by car", - web_app=types.WebAppInfo( - url=get_url_map( - coordinats=coordinats, - profile="car", - ), - ), - ), - ) - builder.row( - types.InlineKeyboardButton( - text="πŸ—ΊοΈ Route on foot", - web_app=types.WebAppInfo( - url=get_url_map( - coordinats=coordinats, - profile="foot", - ), - ), - ), - ) - builder.row( - types.InlineKeyboardButton( - text="πŸ—ΊοΈ Route by bike", - web_app=types.WebAppInfo( - url=get_url_map( - coordinats=coordinats, - profile="bike", - ), - ), + text="πŸ—ΊοΈ Routes", + callback_data=f"travel_routes_{travel.id}", ), ) builder.row( diff --git a/app/messages.py b/app/messages.py index 8f1a93a..94b9261 100644 --- a/app/messages.py +++ b/app/messages.py @@ -1,10 +1,24 @@ # flake8: noqa +# Menu MENU = "Menu:" + +# Notes NOTES = "πŸ“ Notes:\n" -NOTE_DETAIL = "πŸ“ Note detail:\n\n\tFile name: {file_name}\n\tFile type: {file_type}\n\tPublic: {public}" +NOTE_DETAIL = ( + "πŸ“ Note detail:\n\n" + "\tFile name: {file_name}\n" + "\tFile type: {file_type}\n" + "\tPublic: {public}" +) +ADD_NOTE = ( + "✏️ Send me a file, photo, video, or voice message to add a note." + "\nEnter /cancel to cancel creating." +) NOTE_ADDED = "βœ… Note {file_name} added successfully." NOTE_DELETED = "❌ Note {file_name} deleted." + +# Locations LOCATIONS = "πŸ—ΊοΈ Locations:" LOCATION_DELETED = "❌ Location deleted." LOCATION_DETAIL = ( @@ -16,48 +30,77 @@ LOCATION_DETAIL = ( LOCATION_WEATHER = ( "🌀️ {location} weather:\n\n" "\t☁️ Weather: {weather_main}\n" - "\t🌑️ Current tempurature: {temp} Β°C\n" + "\t🌑️ Current temperature: {temp} Β°C\n" "\tπŸ€— Feels like: {feels_like} Β°C\n" - "\t❄️ Min. tempurature: {temp_min} Β°C\n" - "\tπŸ”₯ Max. tempurature: {temp_max} Β°C\n" + "\t❄️ Min. temperature: {temp_min} Β°C\n" + "\tπŸ”₯ Max. temperature: {temp_max} Β°C\n" "\t⬇️ Pressure: {pressure} hektopascals\n" "\tπŸ’¨ Humidity: {humidity}%\n" ) + +# Sights SIGHTS_HEADER = "πŸ—ΊοΈ Sights:\n" -SIGHTS_FOOTER = "Found {sights_count} sights within {distance} m from: {location}." +SIGHTS_FOOTER = ( + "Found {sights_count} sights within {distance} m from: {location}." +) NO_SIGHTS_FOUND = ( "No sights found within {distance} m from: {location}." ) SIGHT_DETAIL = "πŸ—ΊοΈ Sight detail:\n\n" -CREATE_LOCATION = "✈️ Lets create new location!" -ENTER_LOCATION = "Enter location:\nFormat: country, city, ... etc\nExample: Kremlin, Moscow, Russia\nEnter /cancel to cancel creating." + +# Location Creation +CREATE_LOCATION = "✈️ Lets create a new location!" +ENTER_LOCATION = ( + "Enter location:\n" + "Format: country, city, ... etc\n" + "Example: Kremlin, Moscow, Russia\n" + "Enter /cancel to cancel creating." +) CONFIRM_LOCATION = "Is this location correct: {location}?" -CONFIRMATION_REEJECTED = ( +CONFIRMATION_REJECTED = ( "❌ Confirmation rejected. Please re-enter the location." ) -OVERLAPPING_LOCATION = "Dates overlap with another location in the same travel(enter /cancel if you cant fix this)." -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" +OVERLAPPING_LOCATION = "Dates overlap with another location in the same travel(enter /cancel if you can't fix this)." +ENTER_LOCATION_DATE_START = ( + "Enter location start datetime(in UTC) in this format:\n" + "Format: YYYY-MM-DD HH:MM\n" + "Example: 2022-01-01 00:00" +) +ENTER_LOCATION_DATE_END = ( + "Enter location end datetime(in UTC) in this format:\n" + "Format: YYYY-MM-DD HH:MM\n" + "Example: 2022-01-01 00:00>" +) INVALID_DATE_END = "End date can't be earlier or equal to start date." LOCATION_ADDED = "βœ… Location added" -ADD_NOTE = "✏️ Send me file, photo, video or voice nessage to add note.\nEnter /cancel to cancel creating." - +# Travel Management DELETED_TRAVEL = "βœ… Travel deleted" -TRAVELS = "πŸ“ƒ Travels:\nπŸ‘‘ - owner" +TRAVELS = ( + "πŸ“ƒ Travels:" + "\nπŸ‘‘ - owner" +) 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." + "🧳 Let's create a new travel!\n" + "Enter /cancel to cancel creating." ) TRAVEL_UPDATED = "βœ… Travel updated" -EDIT_TRAVEL_DESCRIPTION = "Enter travel description (enter /skip if you want to set it to None):\nMaximum length: 100 characters" +EDIT_TRAVEL_DESCRIPTION = ( + "Enter travel description (enter /skip if you want to set it to None):\n" + "Maximum length: 100 characters" +) INPUT_TRAVEL_TITLE = ( - "Enter travel title:\nMaximum length: 30 characters" + "Enter travel title:\n" + "Maximum 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 = ( + "Enter travel description (enter /skip if you want to skip this step):\n" + "Maximum 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" @@ -68,11 +111,12 @@ TRAVEL_DETAIL = ( "\tDescription: {description}\n" ) +# User Interaction 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 = ( - "Welcome to the ✈️ Travel Agent bot! Here is list of commands you can use:\n\n" + "Welcome to the ✈️ Travel Agent bot! Here is a list of commands you can use:\n\n" "/start - Start the bot\n" "/help - Show this message\n" "/menu - Show the main menu\n" @@ -83,18 +127,39 @@ HELP_MESSAGE = ( "❓ 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." +REGISTERED_MESSAGE = ( + "You have successfully registered. Welcome to the ✈️ Travel Agent bot!\n" + "You can view and edit your profile using the /profile command." +) -INPUT_USERNAME = "Enter your username (this will be used to interact with other users):\nAllowed characters: a-z, A-Z, 0-9, _\nLength: 5-20 characters" -INPUT_AGE = "Enter your age:\nRange: 13-120" -INPUT_SEX = "Enter your sex:\nOptions: Male or Female" -INPUT_BIO = "Enter your bio (enter /skip if you want to skip this step):\nMaximum length: 100 characters" +INPUT_USERNAME = ( + "Enter your username (this will be used to interact with other users):\n" + "Allowed characters: a-z, A-Z, 0-9, _\n" + "Length: 5-20 characters" +) +INPUT_AGE = ( + "Enter your age:\n" + "Range: 13-120" +) +INPUT_SEX = ( + "Enter your sex:\n" + "Options: Male or Female" +) +INPUT_BIO = ( + "Enter your bio (enter /skip if you want to skip this step):\n" + "Maximum length: 100 characters" +) 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_LOCATION = ( + "Enter your location in this format:\n" + "Format: country, city\n" + "Example: Russia, Moscow" +) INPUT_CALLBACK = "βœ… All right, your {key} is set to: {value}" VALIDATION_ERROR = "❌ Invalid input. Please try again." CANCEL_CHANGE = "Enter /cancel to cancel change." +# User Profile PROFILE = ( "πŸ‘€ Your profile:\n\n" "\tUsername: {username}\n" @@ -106,9 +171,21 @@ PROFILE = ( "\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" -EDIT_BIO = "Enter your bio (enter /skip if you want to set it to None):\nMaximum length: 100 characters" -PROFILE_UPDATED = "βœ… Profile {key} updated\n\tOld value: {old_value}\n\tNew value: {new_value}" +EDIT_USERNAME = ( + "Enter your username:\n" + "Allowed characters: a-z, A-Z, 0-9, _\n" + "Length: 5-20 characters" +) +EDIT_BIO = ( + "Enter your bio (enter /skip if you want to set it to None):\n" + "Maximum length: 100 characters" +) +PROFILE_UPDATED = ( + "βœ… Profile {key} updated\n" + "\tOld value: {old_value}\n" + "\tNew value: {new_value}" +) CHANGE_CANCELED = "❌ Change canceled" -PROCCESSING = "βŒ›οΈ Processing..." +# Processing +PROCESSING = "βŒ›οΈ Processing..." diff --git a/app/middlewares/__init__.py b/app/middlewares/__init__.py new file mode 100644 index 0000000..ba11b70 --- /dev/null +++ b/app/middlewares/__init__.py @@ -0,0 +1,3 @@ +__all__ = ("throttling",) + +from app.middlewares import throttling diff --git a/app/models/travel.py b/app/models/travel.py index 480c047..2343a3a 100644 --- a/app/models/travel.py +++ b/app/models/travel.py @@ -243,7 +243,7 @@ class Note(Base): def get_note_text(self): return messages.NOTE_DETAIL.format( file_name=self.file_name, - file_type=self.file_type, + file_type=self.file_type.capitalize(), public="Yes" if self.public else "No", )