feat: Added travel deletion and added ability to add locations

This commit is contained in:
ITQ
2024-03-25 02:51:25 +03:00
parent dd16eda94a
commit 65719a61ef
9 changed files with 520 additions and 42 deletions
+356 -7
View File
@@ -1,5 +1,7 @@
__all__ = ("router",) __all__ = ("router",)
import datetime
from aiogram import F, Router from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import StateFilter from aiogram.filters import StateFilter
@@ -10,10 +12,11 @@ from app import messages, session
from app.config import Config from app.config import Config
from app.filters.user import Registered, RegisteredCallback from app.filters.user import Registered, RegisteredCallback
from app.keyboards.builders import travels_keyboard from app.keyboards.builders import travels_keyboard
from app.keyboards.travel import get from app.keyboards.confirm_location import get as confirm_location_get
from app.models.travel import Travel from app.keyboards.travel import get as travel_get
from app.models.travel import Location, Travel
from app.models.user import User 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 from app.utils.states import delete_message_from_state, handle_validation_error
@@ -36,10 +39,14 @@ async def travels_index_callback(callback: CallbackQuery) -> None:
travels = user.get_user_travels() travels = user.get_user_travels()
if not travels or travels == []: if not travels or travels == []:
try:
await callback.message.edit_text(messages.NO_TRAVELS) await callback.message.edit_text(messages.NO_TRAVELS)
except TelegramBadRequest:
pass
else: else:
pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE
try:
await callback.message.edit_text( await callback.message.edit_text(
messages.TRAVELS, messages.TRAVELS,
reply_markup=travels_keyboard( reply_markup=travels_keyboard(
@@ -49,6 +56,8 @@ async def travels_index_callback(callback: CallbackQuery) -> None:
user.telegram_id, user.telegram_id,
), ),
) )
except TelegramBadRequest:
pass
@router.callback_query( @router.callback_query(
@@ -74,6 +83,7 @@ async def travels_callback(callback: CallbackQuery) -> None:
else: else:
pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE
try:
await callback.message.edit_text( await callback.message.edit_text(
messages.TRAVELS, messages.TRAVELS,
reply_markup=travels_keyboard( reply_markup=travels_keyboard(
@@ -83,6 +93,8 @@ async def travels_callback(callback: CallbackQuery) -> None:
user.telegram_id, user.telegram_id,
), ),
) )
except TelegramBadRequest:
pass
@router.callback_query( @router.callback_query(
@@ -103,7 +115,7 @@ async def travel_detail_callback(callback: CallbackQuery) -> None:
await callback.message.edit_text( await callback.message.edit_text(
travel.get_travel_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_id, column = callback.data.replace("travel_change_", "").split("_")
travel = Travel().get_travel_by_id(travel_id)
if not travel:
return
if column == "title": if column == "title":
message = await callback.message.answer( message = await callback.message.answer(
f"{messages.INPUT_TRAVEL_TITLE}\n{messages.CANCEL_CHANGE}", f"{messages.INPUT_TRAVEL_TITLE}\n{messages.CANCEL_CHANGE}",
@@ -199,7 +216,8 @@ async def travel_change_entered(message: Message, state: FSMContext) -> None:
else: else:
try: try:
validated_description = Travel().validate_description( validated_description = Travel().validate_description(
key="description", value=value, key="description",
value=value,
) )
except AssertionError as e: except AssertionError as e:
await handle_validation_error(message, state, e) await handle_validation_error(message, state, e)
@@ -207,7 +225,8 @@ async def travel_change_entered(message: Message, state: FSMContext) -> None:
return return
await state.update_data( await state.update_data(
value=validated_description, successfully=True, value=validated_description,
successfully=True,
) )
await message.delete() await message.delete()
@@ -230,7 +249,7 @@ async def travel_change_entered(message: Message, state: FSMContext) -> None:
travel.get_travel_text(), travel.get_travel_text(),
message.chat.id, message.chat.id,
state_data["travel_message_id"], state_data["travel_message_id"],
reply_markup=get(travel_id), reply_markup=travel_get(travel_id),
) )
except TelegramBadRequest: except TelegramBadRequest:
pass pass
@@ -240,3 +259,333 @@ async def travel_change_entered(message: Message, state: FSMContext) -> None:
) )
await state.clear() 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()
+18
View File
@@ -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()
+21 -5
View File
@@ -23,18 +23,34 @@ def get(travel_id: int):
callback_data=f"travel_locations_{travel_id}", callback_data=f"travel_locations_{travel_id}",
), ),
types.InlineKeyboardButton( types.InlineKeyboardButton(
text="👤 Users", text=" Add location",
callback_data=f"travel_users_{travel_id}", callback_data=f"travel_add_location_{travel_id}",
), ),
) )
builder.row( builder.row(
types.InlineKeyboardButton( types.InlineKeyboardButton(
text=" Add location", text="👤 Users",
callback_data=f"travel_add_{travel_id}_location", callback_data=f"travel_users_{travel_id}",
), ),
types.InlineKeyboardButton( types.InlineKeyboardButton(
text=" Add user", 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( builder.row(
+13
View File
@@ -2,6 +2,18 @@
MENU = "<b>Menu:</b>" MENU = "<b>Menu:</b>"
CREATE_LOCATION = "✈️ Lets create new location!"
ENTER_LOCATION = "Enter location:"
CONFIRM_LOCATION = "Is this location correct: <b>{location}</b>?"
CONFIRMATION_REEJECTED = (
"❌ Confirmation rejected. Please re-enter the location."
)
ENTER_LOCATION_DATE_START = "Enter location start datetime(in UTC) in this format:\n<i>Format: YYYY-MM-DD HH:MM</i>\n<i>Example: 2022-01-01 00:00</i>"
ENTER_LOCATION_DATE_END = "Enter location end datetime(in UTC) in this format:\n<i>Format: YYYY-MM-DD HH:MM</i>\n<i>Example: 2022-01-01 00:00</i>"
INVALID_DATE_END = "End date can't be earlier or equal to start date."
LOCATION_ADDED = "✅ Location added"
DELETED_TRAVEL = "✅ Travel deleted"
TRAVELS = "📃 <b>Travels:</b>\n<i>👑 - owner</i>" TRAVELS = "📃 <b>Travels:</b>\n<i>👑 - owner</i>"
NO_TRAVELS = "No travels yet. You can create one with /create_travel command." NO_TRAVELS = "No travels yet. You can create one with /create_travel command."
CREATE_TRAVEL = ( CREATE_TRAVEL = (
@@ -21,6 +33,7 @@ TRAVEL_CREATED = "Travel <b>{title}</b> successfully created! You can now view a
ACTION_CANCELED = "❌ Action canceled" ACTION_CANCELED = "❌ Action canceled"
TRAVEL_DETAIL = ( TRAVEL_DETAIL = (
"📝 <b>Travel detail</b>\n\n" "📝 <b>Travel detail</b>\n\n"
"\tID: <b>{travel_id}</b>\n"
"\tTitle: <b>{title}</b>\n" "\tTitle: <b>{title}</b>\n"
"\tDescription: <b>{description}</b>\n" "\tDescription: <b>{description}</b>\n"
) )
@@ -1,8 +1,8 @@
"""Added travel models """Added travel models
Revision ID: fe4ace4196fb Revision ID: 4dea8f302149
Revises: 4914f00ae14a 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 identifiers, used by Alembic.
revision: str = 'fe4ace4196fb' revision: str = '4dea8f302149'
down_revision: Union[str, None] = '4914f00ae14a' down_revision: Union[str, None] = '4914f00ae14a'
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@@ -38,9 +38,9 @@ def upgrade() -> None:
op.create_table( op.create_table(
'locations', 'locations',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.Text(), nullable=False), sa.Column('location', sa.Text(), nullable=False),
sa.Column('date_start', sa.Date(), nullable=False), sa.Column('date_start', sa.DateTime(timezone=True), nullable=False),
sa.Column('date_end', sa.Date(), nullable=False), sa.Column('date_end', sa.DateTime(timezone=True), nullable=False),
sa.Column('travel_id', sa.Integer(), nullable=False), sa.Column('travel_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
['travel_id'], ['travel_id'],
+53 -3
View File
@@ -1,11 +1,14 @@
__all__ = ("Travel", "Location") __all__ = ("Travel", "Location")
import datetime
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import relationship, validates from sqlalchemy.orm import relationship, validates
from app import messages, session from app import messages, session
from app.models import Base from app.models import Base
from app.models.user import User from app.models.user import User
from app.utils.geo import get_location_by_name
association_table = sa.Table( association_table = sa.Table(
@@ -73,6 +76,7 @@ class Travel(Base):
def get_travel_text(self): def get_travel_text(self):
return messages.TRAVEL_DETAIL.format( return messages.TRAVEL_DETAIL.format(
travel_id=self.id,
title=self.title, title=self.title,
description=( description=(
self.description if self.description else messages.NOT_SET self.description if self.description else messages.NOT_SET
@@ -98,9 +102,15 @@ class Location(Base):
autoincrement=True, autoincrement=True,
index=True, index=True,
) )
name = sa.Column(sa.Text, nullable=False) location = sa.Column(sa.Text, nullable=False)
date_start = sa.Column(sa.Date(), nullable=False) date_start = sa.Column(
date_end = sa.Column(sa.Date(), nullable=False) sa.DateTime(timezone=True),
nullable=False,
)
date_end = sa.Column(
sa.DateTime(timezone=True),
nullable=False,
)
travel_id = sa.Column( travel_id = sa.Column(
sa.Integer, sa.Integer,
@@ -108,6 +118,46 @@ class Location(Base):
nullable=False, 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): class Note(Base):
__tablename__ = "notes" __tablename__ = "notes"
+11
View File
@@ -17,3 +17,14 @@ class TravelAlteringState(StatesGroup):
travel_id = State() travel_id = State()
column = State() column = State()
value = 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()
+22 -1
View File
@@ -1,5 +1,5 @@
# type: ignore # type: ignore
__all__ = ("validate_country", "validate_city") __all__ = ("validate_country", "validate_city", "get_location_by_name")
from geopy.exc import GeocoderTimedOut from geopy.exc import GeocoderTimedOut
from geopy.geocoders import Nominatim from geopy.geocoders import Nominatim
@@ -72,3 +72,24 @@ def validate_city(city: str, country: str):
return True, normalized_country return True, normalized_country
return False, None 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
+1 -1
View File
@@ -15,4 +15,4 @@ REDIS_PORT = <port_to_be_forwared> # default: 6379
PGADMIN_PORT = <port_to_be_forwared> # default: 5050 PGADMIN_PORT = <port_to_be_forwared> # default: 5050
PGADMIN_EMAIL = <email> # default: admin@mail.com PGADMIN_EMAIL = <email> # default: admin@mail.com
PGADMIN_PASSWORD <password> # default: admin PGADMIN_PASSWORD = <password> # default: admin