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)