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