From 88dfe1704dacb424cc1d6b6f4f84df508b79df25 Mon Sep 17 00:00:00 2001 From: ITQ Date: Tue, 26 Mar 2024 07:49:50 +0300 Subject: [PATCH] feat: Added notes creation, view and deletion, added route planning, added location list with current weather and nearby locations, code improvements and fixes --- app/bot.py | 6 +- app/callbacks/{travels.py => location.py} | 515 +++++++++--------- app/callbacks/menu.py | 13 + app/callbacks/notes.py | 300 ++++++++++ app/callbacks/profile.py | 15 +- app/callbacks/travel.py | 303 +++++++++++ app/config.py | 7 + app/keyboards/builders.py | 177 +++++- app/keyboards/location.py | 48 ++ app/keyboards/menu.py | 4 +- app/keyboards/note.py | 46 ++ app/keyboards/travel.py | 143 ++++- app/messages.py | 38 +- ...py => 78ab1b779ca8_added_travel_models.py} | 24 +- app/models/travel.py | 85 ++- app/states/travel.py | 7 + app/utils/geo.py | 3 +- app/utils/map.py | 12 + app/utils/sights.py | 108 ++++ app/utils/weather.py | 12 + docker-compose.yml | 2 + requirements/dev.txt | 1 + requirements/prod.txt | 1 + template.env | 2 + 24 files changed, 1571 insertions(+), 301 deletions(-) rename app/callbacks/{travels.py => location.py} (59%) create mode 100644 app/callbacks/notes.py create mode 100644 app/callbacks/travel.py create mode 100644 app/keyboards/location.py create mode 100644 app/keyboards/note.py rename app/migrations/versions/{4dea8f302149_added_travel_models.py => 78ab1b779ca8_added_travel_models.py} (85%) create mode 100644 app/utils/map.py create mode 100644 app/utils/sights.py create mode 100644 app/utils/weather.py diff --git a/app/bot.py b/app/bot.py index 2aed010..eee7c72 100644 --- a/app/bot.py +++ b/app/bot.py @@ -6,7 +6,7 @@ from aiogram import Bot, Dispatcher from aiogram.enums import ParseMode from aiogram.fsm.storage.redis import RedisStorage -from app.callbacks import menu, profile, travels +from app.callbacks import location, menu, notes, profile, travel from app.config import Config from app.handlers import ( create_travel_command, @@ -40,7 +40,9 @@ async def main() -> None: travels_command.router, menu.router, profile.router, - travels.router, + travel.router, + location.router, + notes.router, ) await bot.delete_webhook(drop_pending_updates=True) diff --git a/app/callbacks/travels.py b/app/callbacks/location.py similarity index 59% rename from app/callbacks/travels.py rename to app/callbacks/location.py index 84e50b5..25bf6d9 100644 --- a/app/callbacks/travels.py +++ b/app/callbacks/location.py @@ -7,258 +7,25 @@ from aiogram.exceptions import TelegramBadRequest from aiogram.filters import StateFilter from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message +import sqlalchemy as sa 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.builders import locations_keyboard, sights_keyboard from app.keyboards.confirm_location import get as confirm_location_get -from app.keyboards.travel import get as travel_get +from app.keyboards.location import get as location_get from app.models.travel import Location, Travel -from app.models.user import User -from app.states.travel import CreateLocationState, TravelAlteringState +from app.states.travel import ( + CreateLocationState, +) +from app.utils.geo import get_location_by_name +from app.utils.sights import find_trips, get_info_by_xid from app.utils.states import delete_message_from_state, handle_validation_error +from app.utils.weather import get_current_weather -router = Router(name="menu_callback") - - -@router.callback_query( - F.data == "travels", - RegisteredCallback(), - StateFilter(None), -) -async def travels_index_callback(callback: CallbackQuery) -> None: - page = 0 - - if callback.from_user is None or not isinstance(callback.message, Message): - return - - 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 - - try: - await callback.message.edit_text( - messages.TRAVELS, - reply_markup=travels_keyboard( - travels, - page, - pages, - user.telegram_id, - ), - ) - except TelegramBadRequest: - pass - - -@router.callback_query( - F.data.startswith("travels_page"), - RegisteredCallback(), - StateFilter(None), -) -async def travels_callback(callback: CallbackQuery) -> None: - if callback.data is None or not isinstance(callback.message, Message): - return - - 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 - - try: - await callback.message.edit_text( - messages.TRAVELS, - reply_markup=travels_keyboard( - travels, - page, - pages, - user.telegram_id, - ), - ) - except TelegramBadRequest: - pass - - -@router.callback_query( - F.data.startswith("travel_detail"), - RegisteredCallback(), - StateFilter(None), -) -async def travel_detail_callback(callback: CallbackQuery) -> None: - if callback.data is None or not isinstance(callback.message, Message): - return - - travel_id = int(callback.data.replace("travel_detail_", "")) - - travel = Travel().get_travel_by_id(travel_id) - - if not travel: - return - - await callback.message.edit_text( - travel.get_travel_text(), - reply_markup=travel_get(travel_id), - ) - - -@router.callback_query( - F.data.startswith("travel_change"), - RegisteredCallback(), - StateFilter(None), -) -async def travel_change_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, 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}", - ) - elif column == "description": - message = await callback.message.answer( - f"{messages.EDIT_TRAVEL_DESCRIPTION}\n{messages.CANCEL_CHANGE}", - ) - - await state.update_data( - column=column, - travel_message_id=callback.message.message_id, - input_message_id=message.message_id, - travel_id=travel_id, - ) - await state.set_state(TravelAlteringState.value) - - await callback.answer() - - -@router.message(TravelAlteringState.value, F.text, Registered()) -async def travel_change_entered(message: Message, state: FSMContext) -> None: - if ( - message.text is None - or message.from_user is None - or message.bot is None - ): - return - - data = await state.get_data() - - column = data["column"] - travel_id = data["travel_id"] - value = message.text.strip() - - if value == "/cancel": - await message.answer( - messages.CHANGE_CANCELED, - ) - - 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 == "title": - try: - validated_title = Travel().validate_title( - key="title", - value=value, - ) - except AssertionError as e: - await handle_validation_error(message, state, e) - - return - - await state.update_data(value=validated_title, successfully=True) - elif column == "description": - if value == "/skip": - await state.update_data(value=None, successfully=True) - await delete_message_from_state( - state, - message.chat.id, - message.bot, - ) - else: - try: - validated_description = Travel().validate_description( - key="description", - value=value, - ) - except AssertionError as e: - await handle_validation_error(message, state, e) - - return - - await state.update_data( - value=validated_description, - successfully=True, - ) - - await message.delete() - await delete_message_from_state(state, message.chat.id, message.bot) - - state_data = await state.get_data() - - travel = Travel().get_travel_queryset_by_id(travel_id) - - data = {state_data["column"]: state_data["value"]} - travel.update(data) - - session.commit() - - travel = travel.first() - session.refresh(travel) - - try: - await message.bot.edit_message_text( - travel.get_travel_text(), - message.chat.id, - state_data["travel_message_id"], - reply_markup=travel_get(travel_id), - ) - except TelegramBadRequest: - pass - - await message.answer( - messages.TRAVEL_UPDATED, - ) - - await state.clear() +router = Router(name="location_callback") @router.callback_query( @@ -525,8 +292,6 @@ async def location_date_end_entered( return - await delete_message_from_state(state, message.chat.id, message.bot) - await state.update_data( date_end=datetime.datetime.strftime( validated_date_end, @@ -536,6 +301,35 @@ async def location_date_end_entered( data = await state.get_data() + overlapping_location = ( + session.query(Location) + .filter( + sa.and_( + Location.travel_id == data["travel_id"], + Location.date_start < data["date_end"], + Location.date_end > data["date_start"], + ), + ) + .first() + ) + if overlapping_location: + await handle_validation_error( + message, + state, + messages.OVERLAPPING_LOCATION, + ) + + return + + await message.answer( + messages.INPUT_TRAVEL_CALLBACK.format( + key="end date", + value=date_end, + ), + ) + + await delete_message_from_state(state, message.chat.id, message.bot) + if "temp_location" in data: del data["temp_location"] @@ -566,26 +360,241 @@ async def location_date_end_entered( @router.callback_query( - F.data.startswith("travel_delete"), + F.data.startswith("travel_locations_page"), RegisteredCallback(), StateFilter(None), ) -async def delete_travel_callback( +async def travel_locations_page_callback( callback: CallbackQuery, ): - if callback.data is None or not isinstance(callback.message, Message): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): return - travel_id = int(callback.data.replace("travel_delete_", "")) + travel_id, page = map( + int, + callback.data.replace("travel_locations_page_", "").split("_"), + ) - travel = Travel.get_travel_queryset_by_id(travel_id) + travel = Travel.get_travel_by_id(travel_id) - travel.delete() + if not travel or travel == []: + return + + locations = Travel.get_sorted_locations(travel) + + pages = (len(locations) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE + + try: + await callback.message.edit_text( + messages.LOCATIONS, + reply_markup=locations_keyboard( + locations, + page, + pages, + travel_id, + ), + ) + except TelegramBadRequest: + pass + + +@router.callback_query( + F.data.startswith("travel_location_detail"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_detail_location_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + location_id = int(callback.data.replace("travel_location_detail_", "")) + + location = Location.get_location_by_id(location_id) + + if not location or location == []: + return + + try: + await callback.message.edit_text( + location.get_location_text(), + reply_markup=location_get(location.travel.id, location.id), + ) + except TelegramBadRequest: + pass + + +@router.callback_query( + F.data.startswith("travel_locationdelete"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_locations_delete_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + location_id = int(callback.data.replace("travel_locationdelete_", "")) + + location_queryset = Location.get_location_queryset_by_id(location_id) + + if not location_queryset or location_queryset == []: + return + + travel = location_queryset.first().travel + + location_queryset.delete() session.commit() - await callback.message.answer(messages.DELETED_TRAVEL) + locations = Travel.get_sorted_locations(travel) - await callback.message.delete() + pages = (len(locations) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE + + try: + await callback.message.edit_text( + messages.LOCATIONS, + reply_markup=locations_keyboard( + locations, + 0, + pages, + travel.id, + ), + ) + except TelegramBadRequest: + pass + + await callback.message.answer( + messages.LOCATION_DELETED, + ) + + await callback.answer() + + +@router.callback_query( + F.data.startswith("travel_locationsights"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_locationsights_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + location_id = int(callback.data.replace("travel_locationsights_", "")) + + location = Location.get_location_by_id(location_id) + + if not location or location == []: + return + + geocode = get_location_by_name(location.location) + + sights = find_trips(geocode[1].raw.get("lat"), geocode[1].raw.get("lon")) + + if sights is None or len(sights) == 0: + await callback.message.answer( + messages.NO_SIGHTS_FOUND.format( + location=location.location, + distance=Config.NEARBY_SIGHTS_RADIUS, + ), + ) + else: + await callback.message.answer( + messages.SIGHTS_HEADER + + messages.SIGHTS_FOOTER.format( + location=location.location, + sights_count=len(sights), + distance=Config.NEARBY_SIGHTS_RADIUS, + ), + reply_markup=sights_keyboard(sights[:20]), + ) + + await callback.answer() + + +@router.callback_query( + F.data.startswith("travel_sight_detail"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_sight_detail_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + sight_xid = callback.data.replace("travel_sight_detail_", "") + + await get_info_by_xid(callback, sight_xid) + + await callback.answer() + + +@router.callback_query( + F.data.startswith("travel_locationweather"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_locationweather_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + location_id = int(callback.data.replace("travel_locationweather_", "")) + + location = Location.get_location_by_id(location_id) + + if not location or location == []: + return + + geocode = get_location_by_name(location.location) + + weather = get_current_weather( + geocode[1].raw.get("lat"), + geocode[1].raw.get("lon"), + ) + + await callback.message.answer( + messages.LOCATION_WEATHER.format( + location=location.location, + weather_main=weather.get("weather")[0].get("main"), + temp=weather.get("main").get("temp"), + feels_like=weather.get("main").get("feels_like"), + temp_min=weather.get("main").get("temp_min"), + temp_max=weather.get("main").get("temp_max"), + pressure=weather.get("main").get("pressure"), + humidity=weather.get("main").get("humidity"), + ), + reply_to_message_id=callback.message.message_id, + ) await callback.answer() diff --git a/app/callbacks/menu.py b/app/callbacks/menu.py index 43eadea..d321868 100644 --- a/app/callbacks/menu.py +++ b/app/callbacks/menu.py @@ -91,3 +91,16 @@ async def travels_callback( ) await callback.answer() + + +@router.callback_query( + F.data == "menu_help", + RegisteredCallback(), + StateFilter(None), +) +async def help_callback(callback: CallbackQuery) -> None: + if not isinstance(callback.message, Message): + return + + await callback.message.answer(messages.HELP_MESSAGE) + await callback.answer() diff --git a/app/callbacks/notes.py b/app/callbacks/notes.py new file mode 100644 index 0000000..3ba5a6d --- /dev/null +++ b/app/callbacks/notes.py @@ -0,0 +1,300 @@ +__all__ = ("router",) + +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, Message + +from app import messages, session +from app.config import Config +from app.filters.user import RegisteredCallback +from app.keyboards.builders import notes_keyboard +from app.keyboards.note import get as notes_get +from app.models.travel import Note, Travel +from app.states.travel import ( + CreateNoteState, +) + + +router = Router(name="menu_callback") + + +@router.callback_query( + F.data.startswith("travel_add_note"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_add_note_callback( + callback: CallbackQuery, + state: FSMContext, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + travel_id = int(callback.data.replace("travel_add_note_", "")) + + travel = Travel.get_travel_by_id(travel_id) + + if not travel or travel == []: + return + + await state.set_state(CreateNoteState.file_id) + await state.update_data(travel_id=travel_id) + await callback.message.answer( + messages.ADD_NOTE, + ) + + await callback.answer() + + +@router.message( + CreateNoteState.file_id, +) +async def create_note_file_id(message: Message, state: FSMContext): + if message.from_user is None: + return + + if message.text == "/cancel": + await message.answer( + messages.ACTION_CANCELED, + ) + + await state.update_data() + await message.delete() + await state.clear() + + return + + if message.photo is None and message.document is None: + return + + if message.photo is not None: + await state.update_data( + file_type="photo", + file_id=message.photo[-1].file_id, + file_name="photo", + ) + + # await message.answer_photo(message.photo[-1].file_id) + + elif message.document is not None: + await state.update_data( + file_type="document", + file_id=message.document.file_id, + file_name=message.document.file_name, + ) + + # await message.answer_document(message.document.file_id) + + data = await state.get_data() + + data["author_id"] = message.from_user.id + + session.add(Note(**data)) + + session.commit() + + await message.answer( + messages.NOTE_ADDED.format(file_name=data["file_name"]), + ) + + await state.clear() + + +@router.callback_query( + F.data.startswith("travel_notes_page"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_notes_page_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + travel_id, page = map( + int, + callback.data.replace("travel_notes_page_", "").split("_"), + ) + + travel = Travel.get_travel_queryset_by_id(travel_id) + + if not travel or travel == []: + return + + travel = travel.first() + + notes = Travel().get_notes(callback.from_user.id, travel, public=False) + + pages = (len(notes) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE + + try: + await callback.message.edit_text( + messages.NOTES, + reply_markup=notes_keyboard(notes, page, pages, travel.id), + ) + except TelegramBadRequest: + pass + + +@router.callback_query( + F.data.startswith("travel_note_detail"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_note_detail_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + note_id = int(callback.data.replace("travel_note_detail_", "")) + + note = Note.get_note_by_id(note_id) + + if not note or note == []: + return + + try: + await callback.message.edit_text( + note.get_note_text(), + reply_markup=notes_get(travel_id=note.travel.id, note=note), + ) + except TelegramBadRequest: + pass + + +@router.callback_query( + F.data.startswith("travel_notesend"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_notesend_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + note_id = int(callback.data.replace("travel_notesend_", "")) + + note = Note.get_note_by_id(note_id) + + if not note or note == []: + return + + if note.file_type == "photo": + await callback.message.answer_photo( + note.file_id, + reply_to_message_id=callback.message.message_id, + ) + + elif note.file_type == "document": + await callback.message.answer_document( + note.file_id, + reply_to_message_id=callback.message.message_id, + ) + + await callback.answer() + + +@router.callback_query( + F.data.startswith("travel_note_change_privacy"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_note_change_privacy_callback( + callback: CallbackQuery, + state: FSMContext, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + note_id = int(callback.data.replace("travel_note_change_privacy_", "")) + + note = Note().get_note_by_id(note_id) + + if not note or note == []: + return + + if note.public: + note.public = False + else: + note.public = True + + session.commit() + + try: + await callback.message.edit_text( + note.get_note_text(), + reply_markup=notes_get(travel_id=note.travel.id, note=note), + ) + except TelegramBadRequest: + pass + + +@router.callback_query( + F.data.startswith("travel_notedelete"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_notedelete_callback( + callback: CallbackQuery, +): + if ( + callback.message is None + or callback.data is None + or not isinstance(callback.message, Message) + ): + return + + note_id = int(callback.data.replace("travel_notedelete_", "")) + + 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.delete() + + session.commit() + + notes = Travel().get_notes(callback.from_user.id, travel, public=False) + + pages = (len(notes) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE + + try: + await callback.message.edit_text( + messages.NOTES, + reply_markup=notes_keyboard(notes, 0, pages, travel.id), + ) + except TelegramBadRequest: + pass + + await callback.message.answer( + messages.NOTE_DELETED.format(file_name=file_name), + ) diff --git a/app/callbacks/profile.py b/app/callbacks/profile.py index 7087ff1..29b197b 100644 --- a/app/callbacks/profile.py +++ b/app/callbacks/profile.py @@ -22,7 +22,7 @@ router = Router(name="profile_callback") @router.callback_query( - F.data.startswith("profile_change_"), + F.data.startswith("profile_change"), StateFilter(None), RegisteredCallback(), ) @@ -227,8 +227,12 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: state_data = await state.get_data() user = User.get_user_queryset_by_telegram_id(message.from_user.id) + user_first = user.first() if isinstance(state_data["value"], list): + old_value = user_first.country + ", " + user_first.city + new_value = state_data["value"][0] + ", " + state_data["value"][1] + user.update( { "country": state_data["value"][0], @@ -241,6 +245,9 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: except TelegramBadRequest: pass else: + old_value = getattr(user.first(), str(column)) + new_value = state_data["value"] + data = {state_data["column"]: state_data["value"]} user.update(data) @@ -260,7 +267,11 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None: pass await message.answer( - messages.PROFILE_UPDATED, + messages.PROFILE_UPDATED.format( + key=state_data["column"], + old_value=old_value if old_value else messages.NOT_SET, + new_value=(new_value if new_value else messages.NOT_SET), + ), reply_markup=ReplyKeyboardRemove(), ) diff --git a/app/callbacks/travel.py b/app/callbacks/travel.py new file mode 100644 index 0000000..d48d7f1 --- /dev/null +++ b/app/callbacks/travel.py @@ -0,0 +1,303 @@ +__all__ = ("router",) + +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, Message + +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 as travel_get +from app.models.travel import Travel +from app.models.user import User +from app.states.travel import ( + TravelAlteringState, +) +from app.utils.states import delete_message_from_state, handle_validation_error + + +router = Router(name="menu_callback") + + +@router.callback_query( + F.data == "travels", + RegisteredCallback(), + StateFilter(None), +) +async def travels_index_callback(callback: CallbackQuery) -> None: + page = 0 + + if callback.from_user is None or not isinstance(callback.message, Message): + return + + 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 + + try: + await callback.message.edit_text( + messages.TRAVELS, + reply_markup=travels_keyboard( + travels, + page, + pages, + user.telegram_id, + ), + ) + except TelegramBadRequest: + pass + + +@router.callback_query( + F.data.startswith("travels_page"), + RegisteredCallback(), + StateFilter(None), +) +async def travels_callback(callback: CallbackQuery) -> None: + if callback.data is None or not isinstance(callback.message, Message): + return + + 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 + + try: + await callback.message.edit_text( + messages.TRAVELS, + reply_markup=travels_keyboard( + travels, + page, + pages, + user.telegram_id, + ), + ) + except TelegramBadRequest: + pass + + +@router.callback_query( + F.data.startswith("travel_detail"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_detail_callback(callback: CallbackQuery) -> None: + if callback.data is None or not isinstance(callback.message, Message): + return + + travel_id = int(callback.data.replace("travel_detail_", "")) + + travel = Travel().get_travel_by_id(travel_id) + + if not travel: + return + + try: + await callback.message.edit_text( + travel.get_travel_text(), + reply_markup=travel_get(travel), + ) + except TelegramBadRequest: + pass + + +@router.callback_query( + F.data.startswith("travel_change"), + RegisteredCallback(), + StateFilter(None), +) +async def travel_change_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, 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}", + ) + elif column == "description": + message = await callback.message.answer( + f"{messages.EDIT_TRAVEL_DESCRIPTION}\n{messages.CANCEL_CHANGE}", + ) + + await state.update_data( + column=column, + travel_message_id=callback.message.message_id, + input_message_id=message.message_id, + travel_id=travel_id, + ) + await state.set_state(TravelAlteringState.value) + + await callback.answer() + + +@router.message(TravelAlteringState.value, F.text, Registered()) +async def travel_change_entered(message: Message, state: FSMContext) -> None: + if ( + message.text is None + or message.from_user is None + or message.bot is None + ): + return + + data = await state.get_data() + + column = data["column"] + travel_id = data["travel_id"] + value = message.text.strip() + + if value == "/cancel": + await message.answer( + messages.CHANGE_CANCELED, + ) + + 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 == "title": + try: + validated_title = Travel().validate_title( + key="title", + value=value, + ) + except AssertionError as e: + await handle_validation_error(message, state, e) + + return + + await state.update_data(value=validated_title, successfully=True) + elif column == "description": + if value == "/skip": + await state.update_data(value=None, successfully=True) + await delete_message_from_state( + state, + message.chat.id, + message.bot, + ) + else: + try: + validated_description = Travel().validate_description( + key="description", + value=value, + ) + except AssertionError as e: + await handle_validation_error(message, state, e) + + return + + await state.update_data( + value=validated_description, + successfully=True, + ) + + await message.delete() + await delete_message_from_state(state, message.chat.id, message.bot) + + state_data = await state.get_data() + + travel = Travel().get_travel_queryset_by_id(travel_id) + + data = {state_data["column"]: state_data["value"]} + travel.update(data) + + session.commit() + + travel = travel.first() + session.refresh(travel) + + try: + await message.bot.edit_message_text( + travel.get_travel_text(), + message.chat.id, + state_data["travel_message_id"], + reply_markup=travel_get(travel), + ) + except TelegramBadRequest: + pass + + await message.answer( + messages.TRAVEL_UPDATED, + ) + + 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_", "")) + + user = User().get_user_by_telegram_id(callback.from_user.id) + + travel = Travel.get_travel_queryset_by_id(travel_id) + + travel.delete() + + session.commit() + + travels = user.get_user_travels() + + pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE + + await callback.message.answer(messages.DELETED_TRAVEL) + + await callback.message.edit_text( + messages.TRAVELS, + reply_markup=travels_keyboard( + travels, + 0, + pages, + callback.from_user.id, + ), + ) + + await callback.answer() diff --git a/app/config.py b/app/config.py index 79e434b..00215f0 100644 --- a/app/config.py +++ b/app/config.py @@ -17,4 +17,11 @@ class Config: "REDIS_URL", "redis://localhost:6379", ) + OPENTRIPMAP_API_KEY = os.getenv( + "OPENTRIPMAP_API_KEY", + ) + OPENWEATHERMAP_API_KEY = os.getenv( + "OPENWEATHERMAP_API_KEY", + ) + NEARBY_SIGHTS_RADIUS = 2000 PAGE_SIZE = 6 diff --git a/app/keyboards/builders.py b/app/keyboards/builders.py index 290a3dc..bbf08a4 100644 --- a/app/keyboards/builders.py +++ b/app/keyboards/builders.py @@ -58,9 +58,10 @@ def travels_keyboard(travels: list, page: int, pages: int, user_id: int): ), ) + total_pages = 1 if pages == 0 else pages navigation_row.append( InlineKeyboardButton( - text=f"{page + 1}/{pages}", + text=f"{page + 1}/{total_pages}", callback_data="pass", ), ) @@ -83,3 +84,177 @@ def travels_keyboard(travels: list, page: int, pages: int, user_id: int): builder.row(*navigation_row) return builder.as_markup() + + +def locations_keyboard(locations: list, page: int, pages: int, travel_id: int): + builder = InlineKeyboardBuilder() + rows = [] + + start_index = page * Config.PAGE_SIZE + end_index = min((page + 1) * Config.PAGE_SIZE, len(locations)) + + for location in locations[start_index:end_index]: + button_text = location.location + + rows.append( + InlineKeyboardButton( + text=button_text, + callback_data=f"travel_location_detail_{location.id}", + ), + ) + + for _ in range(0, Config.PAGE_SIZE - len(rows)): + rows.append(InlineKeyboardButton(text=" ", callback_data="pass")) + + builder.row(*rows, width=2) + + navigation_row = [] + + if page > 0: + navigation_row.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"travel_locations_{travel_id}_{page - 1}", + ), + ) + else: + navigation_row.append( + InlineKeyboardButton( + text=" ", + callback_data="pass", + ), + ) + + total_pages = 1 if pages == 0 else pages + navigation_row.append( + InlineKeyboardButton( + text=f"{page + 1}/{total_pages}", + callback_data="pass", + ), + ) + + if page < pages - 1: + navigation_row.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"travel_locations_{travel_id}_{page + 1}", + ), + ) + else: + navigation_row.append( + InlineKeyboardButton( + text=" ", + callback_data="pass", + ), + ) + + builder.row(*navigation_row) + builder.row( + InlineKeyboardButton( + text="⬅️", + callback_data=f"travel_detail_{travel_id}", + ), + ) + + return builder.as_markup() + + +def notes_keyboard(notes, page: int, pages: int, travel_id: int): + builder = InlineKeyboardBuilder() + + rows = [] + + start_index = page * Config.PAGE_SIZE + end_index = min((page + 1) * Config.PAGE_SIZE, len(notes)) + + for note in notes[start_index:end_index]: + if note.file_type == "photo": + button_text = f"Photo ID: {note.id}" + else: + button_text = note.file_name + + rows.append( + InlineKeyboardButton( + text=button_text, + callback_data=f"travel_note_detail_{note.id}", + ), + ) + + for _ in range(0, Config.PAGE_SIZE - len(rows)): + rows.append(InlineKeyboardButton(text=" ", callback_data="pass")) + + builder.row(*rows, width=2) + + navigation_row = [] + + if page > 0: + navigation_row.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"travel_notes_page_{travel_id}_{page - 1}", + ), + ) + else: + navigation_row.append( + InlineKeyboardButton( + text=" ", + callback_data="pass", + ), + ) + + total_pages = 1 if pages == 0 else pages + navigation_row.append( + InlineKeyboardButton( + text=f"{page + 1}/{total_pages}", + callback_data="pass", + ), + ) + + if page < pages - 1: + navigation_row.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"travel_notes_page_{travel_id}_{page + 1}", + ), + ) + else: + navigation_row.append( + InlineKeyboardButton( + text=" ", + callback_data="pass", + ), + ) + + builder.row(*navigation_row) + + builder.row( + InlineKeyboardButton( + text="⬅️", + callback_data=f"travel_detail_{travel_id}", + ), + ) + + return builder.as_markup() + + +def sights_keyboard(sights: list): + builder = InlineKeyboardBuilder() + + rows = [] + + for sight in sights: + button_text = sight[0] + + rows.append( + InlineKeyboardButton( + text=button_text, + callback_data=f"travel_sight_detail_{sight[1]}", + ), + ) + + for _ in range(0, 20 - len(rows)): + rows.append(InlineKeyboardButton(text=" ", callback_data="pass")) + + builder.row(*rows, width=2) + + return builder.as_markup() diff --git a/app/keyboards/location.py b/app/keyboards/location.py new file mode 100644 index 0000000..2ea9adc --- /dev/null +++ b/app/keyboards/location.py @@ -0,0 +1,48 @@ +__all__ = ("get",) + +from aiogram import types +from aiogram.utils.keyboard import InlineKeyboardBuilder + + +def get(travel_id: int, location_id: int): + builder = InlineKeyboardBuilder() + + builder.row( + types.InlineKeyboardButton( + text="⏩ Get nearby sights", + callback_data=f"travel_locationsights_{location_id}", + ), + ) + builder.row( + types.InlineKeyboardButton( + text="☁️ Current weather", + callback_data=f"travel_locationweather_{location_id}", + ), + ) + builder.row( + types.InlineKeyboardButton( + text="❌ Delete location", + callback_data=f"travel_locationdelete_{location_id}", + ), + ) + builder.row( + types.InlineKeyboardButton( + text="⬅️", + callback_data=f"travel_locations_page_{travel_id}_0", + ), + ) + + return builder.as_markup() + + +def get_public(travel_id: int, location_id: int): + builder = InlineKeyboardBuilder() + + builder.row( + types.InlineKeyboardButton( + text="⬅️", + callback_data=f"travel_locations_page_{travel_id}_0", + ), + ) + + return builder.as_markup() diff --git a/app/keyboards/menu.py b/app/keyboards/menu.py index 8cb6dac..49538d8 100644 --- a/app/keyboards/menu.py +++ b/app/keyboards/menu.py @@ -23,8 +23,8 @@ def get(): callback_data="menu_travels", ), types.InlineKeyboardButton( - text="🔵 Temp", - callback_data="menu_temp", + text="❓ Help", + callback_data="menu_help", ), ) diff --git a/app/keyboards/note.py b/app/keyboards/note.py new file mode 100644 index 0000000..ec99501 --- /dev/null +++ b/app/keyboards/note.py @@ -0,0 +1,46 @@ +__all__ = ("get",) + +from aiogram import types +from aiogram.utils.keyboard import InlineKeyboardBuilder + + +def get(travel_id: int, note): + builder = InlineKeyboardBuilder() + + builder.row( + types.InlineKeyboardButton( + text="⏩ View note", + callback_data=f"travel_notesend_{note.id}", + ), + ) + + if note.public: + builder.row( + types.InlineKeyboardButton( + text="🔒 Make private", + callback_data=f"travel_note_change_privacy_{note.id}", + ), + ) + else: + builder.row( + types.InlineKeyboardButton( + text="🔓 Make public", + callback_data=f"travel_note_change_privacy_{note.id}", + ), + ) + + builder.row( + types.InlineKeyboardButton( + text="❌ Delete note", + callback_data=f"travel_notedelete_{note.id}", + ), + ) + + 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 caab9f0..4e72b60 100644 --- a/app/keyboards/travel.py +++ b/app/keyboards/travel.py @@ -3,54 +3,177 @@ __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")], + ) -def get(travel_id: int): builder = InlineKeyboardBuilder() builder.row( types.InlineKeyboardButton( text="📝 Change title", - callback_data=f"travel_change_{travel_id}_title", + callback_data=f"travel_change_{travel.id}_title", ), types.InlineKeyboardButton( text="ℹ️ Change description", - callback_data=f"travel_change_{travel_id}_description", + callback_data=f"travel_change_{travel.id}_description", ), ) builder.row( types.InlineKeyboardButton( text="🗺️ Locations", - callback_data=f"travel_locations_{travel_id}", + callback_data=f"travel_locations_page_{travel.id}_0", ), types.InlineKeyboardButton( text="➕ Add location", - callback_data=f"travel_add_location_{travel_id}", + callback_data=f"travel_add_location_{travel.id}", ), ) builder.row( types.InlineKeyboardButton( text="👤 Users", - callback_data=f"travel_users_{travel_id}", + callback_data=f"travel_users_page_{travel.id}_0", ), types.InlineKeyboardButton( text="➕ Add user", - callback_data=f"travel_add_user_{travel_id}", + callback_data=f"travel_add_user_{travel.id}", ), ) builder.row( types.InlineKeyboardButton( text="📝 Notes", - callback_data=f"travel_notes_{travel_id}", + callback_data=f"travel_notes_page_{travel.id}_0", ), types.InlineKeyboardButton( text="➕ Add note", - callback_data=f"travel_add_note_{travel_id}", + callback_data=f"travel_add_note_{travel.id}", + ), + ) + 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="❌ Delete travel", - callback_data=f"travel_delete_{travel_id}", + callback_data=f"travel_delete_{travel.id}", + ), + ) + builder.row( + types.InlineKeyboardButton( + text="⬅️", + callback_data="travels", + ), + ) + + return builder.as_markup() + + +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( + types.InlineKeyboardButton( + text="🗺️ Locations", + callback_data=f"travel_locations_{travel.id}", + ), + ) + builder.row( + types.InlineKeyboardButton( + text="👤 Users", + callback_data=f"travel_users_{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="🗺️ 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( diff --git a/app/messages.py b/app/messages.py index c6ed85b..a3d33d5 100644 --- a/app/messages.py +++ b/app/messages.py @@ -1,18 +1,48 @@ # flake8: noqa MENU = "Menu:" - +NOTES = "📝 Notes:\n" +NOTE_DETAIL = "📝 Note detail:\n\n\tFile name: {file_name}\n\tFile type: {file_type}\n\tPublic: {public}" +NOTE_ADDED = "✅ Note {file_name} added successfully." +NOTE_DELETED = "❌ Note {file_name} deleted." +LOCATIONS = "🗺️ Locations:" +LOCATION_DELETED = "❌ Location deleted." +LOCATION_DETAIL = ( + "🗺️ Location detail:\n\n" + "\tLocation: {location}\n" + "\tDate start: {date_start}\n" + "\tDate end: {date_end}" +) +LOCATION_WEATHER = ( + "🌤️ {location} weather:\n\n" + "\t☁️ Weather: {weather_main}\n" + "\t🌡️ Current tempurature: {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⬇️ Pressure: {pressure} hektopascals\n" + "\t💨 Humidity: {humidity}%\n" +) +SIGHTS_HEADER = "🗺️ Sights:\n" +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:" +ENTER_LOCATION = "Enter location:\nFormat: country, city, ... etc\nExample: Kremlin, Moscow, Russia\nEnter /cancel to cancel creating." CONFIRM_LOCATION = "Is this location correct: {location}?" CONFIRMATION_REEJECTED = ( "❌ 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" INVALID_DATE_END = "End date can't be earlier or equal to start date." LOCATION_ADDED = "✅ Location added" +ADD_NOTE = "✏️ Send me file or photo to add note.\nEnter /cancel to cancel creating." + DELETED_TRAVEL = "✅ Travel deleted" TRAVELS = "📃 Travels:\n👑 - owner" NO_TRAVELS = "No travels yet. You can create one with /create_travel command." @@ -47,7 +77,7 @@ HELP_MESSAGE = ( "/help - Show this message\n" "/menu - Show the main menu\n" "/profile - View and edit your profile\n" - "/create_travel - Create new travel\n" + "/create_travel - Create a 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." @@ -78,7 +108,7 @@ PROFILE = ( 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" +PROFILE_UPDATED = "✅ Profile {key} updated\n\tOld value: {old_value}\n\tNew value: {new_value}" CHANGE_CANCELED = "❌ Change canceled" PROCCESSING = "⌛️ Processing..." diff --git a/app/migrations/versions/4dea8f302149_added_travel_models.py b/app/migrations/versions/78ab1b779ca8_added_travel_models.py similarity index 85% rename from app/migrations/versions/4dea8f302149_added_travel_models.py rename to app/migrations/versions/78ab1b779ca8_added_travel_models.py index 9ec0c17..142552c 100644 --- a/app/migrations/versions/4dea8f302149_added_travel_models.py +++ b/app/migrations/versions/78ab1b779ca8_added_travel_models.py @@ -1,8 +1,8 @@ """Added travel models -Revision ID: 4dea8f302149 +Revision ID: 78ab1b779ca8 Revises: 4914f00ae14a -Create Date: 2024-03-24 17:56:20.975589 +Create Date: 2024-03-25 17:23:37.917899 """ @@ -13,7 +13,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = '4dea8f302149' +revision: str = '78ab1b779ca8' down_revision: Union[str, None] = '4914f00ae14a' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -28,8 +28,7 @@ def upgrade() -> None: sa.Column('description', sa.String(length=100), nullable=True), sa.Column('author_id', sa.BigInteger(), nullable=False), sa.ForeignKeyConstraint( - ['author_id'], - ['users.telegram_id'], + ['author_id'], ['users.telegram_id'], ondelete='CASCADE' ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('title'), @@ -43,8 +42,7 @@ def upgrade() -> None: sa.Column('date_end', sa.DateTime(timezone=True), nullable=False), sa.Column('travel_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint( - ['travel_id'], - ['travels.id'], + ['travel_id'], ['travels.id'], ondelete='CASCADE' ), sa.PrimaryKeyConstraint('id'), ) @@ -59,12 +57,10 @@ def upgrade() -> None: sa.Column('author_id', sa.BigInteger(), nullable=False), sa.Column('travel_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint( - ['author_id'], - ['users.telegram_id'], + ['author_id'], ['users.telegram_id'], ondelete='CASCADE' ), sa.ForeignKeyConstraint( - ['travel_id'], - ['travels.id'], + ['travel_id'], ['travels.id'], ondelete='CASCADE' ), sa.PrimaryKeyConstraint('id'), ) @@ -74,12 +70,10 @@ def upgrade() -> None: sa.Column('user_id', sa.BigInteger(), nullable=True), sa.Column('travel_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint( - ['travel_id'], - ['travels.id'], + ['travel_id'], ['travels.id'], ondelete='CASCADE' ), sa.ForeignKeyConstraint( - ['user_id'], - ['users.telegram_id'], + ['user_id'], ['users.telegram_id'], ondelete='CASCADE' ), ) # ### end Alembic commands ### diff --git a/app/models/travel.py b/app/models/travel.py index 45bbba6..480c047 100644 --- a/app/models/travel.py +++ b/app/models/travel.py @@ -14,8 +14,16 @@ from app.utils.geo import get_location_by_name 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")), + sa.Column( + "user_id", + sa.BigInteger, + sa.ForeignKey("users.telegram_id", ondelete="CASCADE"), + ), + sa.Column( + "travel_id", + sa.Integer, + sa.ForeignKey("travels.id", ondelete="CASCADE"), + ), ) @@ -42,7 +50,7 @@ class Travel(Base): author_id = sa.Column( sa.BigInteger, - sa.ForeignKey(User.telegram_id), + sa.ForeignKey(User.telegram_id, ondelete="CASCADE"), nullable=False, ) @@ -83,6 +91,18 @@ class Travel(Base): ), ) + @classmethod + def get_notes(cls, author_id, travel, public=True): + return ( + session.query(Note) + .filter( + Note.author_id == author_id, + Note.travel_id == travel.id, + Note.public == public, + ) + .all() + ) + @classmethod def get_travel_by_id(cls, travel_id): return session.query(Travel).filter(Travel.id == travel_id).first() @@ -91,6 +111,14 @@ class Travel(Base): def get_travel_queryset_by_id(cls, travel_id): return session.query(Travel).filter(Travel.id == travel_id) + @classmethod + def get_sorted_locations(cls, travel, asc=True): + return sorted( + travel.locations, + key=lambda location: location.date_end, + reverse=asc, + ) + class Location(Base): __tablename__ = "locations" @@ -114,17 +142,16 @@ class Location(Base): travel_id = sa.Column( sa.Integer, - sa.ForeignKey("travels.id"), + sa.ForeignKey("travels.id", ondelete="CASCADE"), nullable=False, ) - @validates("location") def validate_location(self, key, value): - geocoder = get_location_by_name(value) + geocode = get_location_by_name(value) - assert geocoder[0], "Invalid location." + assert geocode[0], "Invalid location." - return geocoder[1].raw["display_name"] + return geocode[1].raw["display_name"] def validate_date_start(self, key, value): try: @@ -158,6 +185,29 @@ class Location(Base): return value_datetime + def get_location_text(self): + return messages.LOCATION_DETAIL.format( + location=self.location, + date_start=datetime.datetime.strftime( + self.date_start, + "%Y-%m-%d %H:%M", + ), + date_end=datetime.datetime.strftime( + self.date_end, + "%Y-%m-%d %H:%M", + ), + ) + + @classmethod + def get_location_by_id(cls, location_id): + return ( + session.query(Location).filter(Location.id == location_id).first() + ) + + @classmethod + def get_location_queryset_by_id(cls, location_id): + return session.query(Location).filter(Location.id == location_id) + class Note(Base): __tablename__ = "notes" @@ -181,11 +231,26 @@ class Note(Base): author_id = sa.Column( sa.BigInteger, - sa.ForeignKey(User.telegram_id), + sa.ForeignKey(User.telegram_id, ondelete="CASCADE"), nullable=False, ) travel_id = sa.Column( sa.Integer, - sa.ForeignKey("travels.id"), + sa.ForeignKey("travels.id", ondelete="CASCADE"), nullable=False, ) + + def get_note_text(self): + return messages.NOTE_DETAIL.format( + file_name=self.file_name, + file_type=self.file_type, + public="Yes" if self.public else "No", + ) + + @classmethod + def get_note_by_id(cls, note_id): + return session.query(Note).filter(Note.id == note_id).first() + + @classmethod + def get_note_queryset_by_id(cls, note_id): + return session.query(Note).filter(Note.id == note_id) diff --git a/app/states/travel.py b/app/states/travel.py index 1e98d53..898df08 100644 --- a/app/states/travel.py +++ b/app/states/travel.py @@ -28,3 +28,10 @@ class CreateLocationState(StatesGroup): location = State() date_start = State() date_end = State() + + +class CreateNoteState(StatesGroup): + travel_id = State() + file_id = State() + file_type = State() + file_name = State() diff --git a/app/utils/geo.py b/app/utils/geo.py index 5728a02..12ee08b 100644 --- a/app/utils/geo.py +++ b/app/utils/geo.py @@ -1,5 +1,5 @@ # type: ignore -__all__ = ("validate_country", "validate_city", "get_location_by_name") +__all__ = ("get_location_by_name", "validate_country", "validate_city") from geopy.exc import GeocoderTimedOut from geopy.geocoders import Nominatim @@ -81,7 +81,6 @@ def get_location_by_name(location: str) -> None: try: geocode = geolocator.geocode( location, - featuretype="city", ) break except GeocoderTimedOut: diff --git a/app/utils/map.py b/app/utils/map.py new file mode 100644 index 0000000..17e8787 --- /dev/null +++ b/app/utils/map.py @@ -0,0 +1,12 @@ +__all__ = ("get_url_map",) + + +def get_url_map(coordinats: list, profile: str): + result_url = "https://graphhopper.com/maps/?" + + for coordinat in coordinats: + result_url += f"point={coordinat[0]}, {coordinat[1]}&" + + result_url += f"profile={profile}&layer=OpenStreetMap" + + return result_url diff --git a/app/utils/sights.py b/app/utils/sights.py new file mode 100644 index 0000000..6867669 --- /dev/null +++ b/app/utils/sights.py @@ -0,0 +1,108 @@ +__all__ = ("find_trips", "get_info_by_xid") + +from aiogram.types import CallbackQuery, Message +import requests + +from app import messages +from app.config import Config + + +def find_trips(lat, lon, type_of_trip="unclassified_objects"): + api_key = Config.OPENTRIPMAP_API_KEY + radius = Config.NEARBY_SIGHTS_RADIUS + + result_url = ( + "https://api.opentripmap.com/0.1/ru/places/radius" + f"?radius={radius}&" + f"kinds={type_of_trip}&" + f"lon={lon}&" + f"lat={lat}&" + f"limit=20&" + f"apikey={api_key}" + ) + + data = requests.get(result_url).json() + + if data["features"]: + sights = [] + + for feature in data["features"]: + button_text = ( + feature["properties"]["name"] + + " (" + + str(round(feature["properties"]["dist"])) + + "m)" + ) + + sights.append((button_text, feature["properties"]["xid"])) + + return sights + + return None + + +async def get_info_by_xid(callback: CallbackQuery, xid): + if not isinstance(callback.message, Message): + return + + api_key = Config.OPENTRIPMAP_API_KEY + + result_url = ( + f"https://api.opentripmap.com/0.1/ru/places/xid/{xid}?apikey={api_key}" + ) + + data = requests.get(result_url).json() + + text = messages.SIGHT_DETAIL + + if data.get("name", ""): + text += ( + "\n\t📝 Name: " + data.get("name", "Missing") + "\n" + ) + + if data.get("address", ""): + address_string = "" + key_order = [ + "country", + "state", + "city", + "city_district", + "suburb", + "road", + "house_number", + ] + address = data["address"] + + for key in key_order: + if address.get(key, ""): + address_string += f" {address.get(key)}, " + + text += "\t📫 Address: " + address_string + "\n" + + if "wikipedia_extracts" in data: + wikipedia_extracts = data["wikipedia_extracts"] + wikipedia_title = wikipedia_extracts.get("title", "Missing") + wikipedia_description = wikipedia_extracts.get( + "text", + "Missing", + ) + + text += f"\n\t📝 Wikipedia title: {wikipedia_title}\n" + text += f"\tℹ️ Wikipedia description: {wikipedia_description}\n" + + if "wikipedia" in data: + wikipedia_link = data["wikipedia"] + + text += f"\t🔗 Wikipedia link: {wikipedia_link}\n" + + if "image" in data: + await callback.message.answer_photo( + data["image"], + text, + reply_to_message_id=callback.message.message_id, + ) + else: + await callback.message.answer( + text, + reply_to_message_id=callback.message.message_id, + ) diff --git a/app/utils/weather.py b/app/utils/weather.py new file mode 100644 index 0000000..6eeabab --- /dev/null +++ b/app/utils/weather.py @@ -0,0 +1,12 @@ +__all__ = "get_weather" + +import requests + +from app.config import Config + + +def get_current_weather(lat, lot): + api_key = Config.OPENWEATHERMAP_API_KEY + result_url = f"https://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lot}&appid={api_key}&lang=en&units=metric" # noqa + + return requests.get(result_url).json() diff --git a/docker-compose.yml b/docker-compose.yml index 9949560..9d1dedd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,8 @@ services: BOT_TOKEN: ${BOT_TOKEN:-6943803094:AAFxMjuiaqLlQbITUOVPlKx6SKIofKrThwk} REDIS_URL: redis://redis:${REDIS_PORT:-6379}/ SQLALCHEMY_DATABASE_URI: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres} + OPENTRIPMAP_API_KEY: ${OPENTRIPMAP_API_KEY:-5ae2e3f221c38a28845f05b65004e18feef4a0f1a06c9554424e9f14} + OPENWEATHERMAP_API_KEY: ${OPENWEATHERMAP_API_KEY:-3673b2ff64c097b3f79fc93e429e7ed1} entrypoint: ["bash", "-c"] command: ["alembic -c app/alembic.ini upgrade head && python -m app"] diff --git a/requirements/dev.txt b/requirements/dev.txt index 9696600..4611d73 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,6 +1,7 @@ black mypy sort-requirements +types-requests==2.31 -r prod.txt -r test.txt diff --git a/requirements/prod.txt b/requirements/prod.txt index efcd7e9..8553b61 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -5,4 +5,5 @@ geopy==2.4.1 psycopg2-binary==2.9.9 python-dotenv==1.0.1 redis==5.0.3 +requests==2.31.0 sqlalchemy==2.0.28 diff --git a/template.env b/template.env index 85b13e4..3f82b89 100644 --- a/template.env +++ b/template.env @@ -3,6 +3,8 @@ BOT_TOKEN = # default: 6943803094:AAFxMjuiaqLlQbITUOVPlKx6SKIofKrThwk SQLALCHEMY_DATABASE_URI = # no need to specify if docker is used REDIS_URL = # no need to specify if docker is used +OPENTRIPMAP_API_KEY = # get it from https://dev.opentripmap.org/ +OPENWEATHERMAP_API_KEY = # get it from https://openweathermap.org/ # For docker(remove if you want to keep defaults)