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
+375 -26
View File
@@ -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()
+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}",
),
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(
+13
View File
@@ -2,6 +2,18 @@
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>"
NO_TRAVELS = "No travels yet. You can create one with /create_travel command."
CREATE_TRAVEL = (
@@ -21,6 +33,7 @@ TRAVEL_CREATED = "Travel <b>{title}</b> successfully created! You can now view a
ACTION_CANCELED = "❌ Action canceled"
TRAVEL_DETAIL = (
"📝 <b>Travel detail</b>\n\n"
"\tID: <b>{travel_id}</b>\n"
"\tTitle: <b>{title}</b>\n"
"\tDescription: <b>{description}</b>\n"
)
@@ -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'],
+53 -3
View File
@@ -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"
+11
View File
@@ -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()
+22 -1
View File
@@ -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
+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_EMAIL = <email> # default: admin@mail.com
PGADMIN_PASSWORD <password> # default: admin
PGADMIN_PASSWORD = <password> # default: admin