mirror of
https://github.com/olegvodyanov/docbot.git
synced 2026-02-02 02:45:46 +03:00
add calendar picker
This commit is contained in:
parent
d12a1105a1
commit
cb9750d33e
146
src/core/inline_calendar.py
Normal file
146
src/core/inline_calendar.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarActions(str):
|
||||||
|
PREV_MONTH = "CAL_PREV_MONTH"
|
||||||
|
NEXT_MONTH = "CAL_NEXT_MONTH"
|
||||||
|
PREV_YEAR = "CAL_PREV_YEAR"
|
||||||
|
NEXT_YEAR = "CAL_NEXT_YEAR"
|
||||||
|
SELECT_DAY = "CAL_DAY"
|
||||||
|
SELECT_TIME = "CAL_TIME"
|
||||||
|
IGNORE = "CAL_IGNORE"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CalendarCallback:
|
||||||
|
action: str
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
day: int = 0
|
||||||
|
hour: int = 0
|
||||||
|
minute: int = 0
|
||||||
|
|
||||||
|
separator = "|"
|
||||||
|
|
||||||
|
def pack(self) -> str:
|
||||||
|
return self.separator.join(
|
||||||
|
[self.action, str(self.year), str(self.month), str(self.day), str(self.hour), str(self.minute)]
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unpack(cls, data: str) -> "CalendarCallback":
|
||||||
|
action, year, month, day, hour, minute = data.split(cls.separator)
|
||||||
|
return cls(action=action, year=int(year), month=int(month), day=int(day), hour=int(hour), minute=int(minute))
|
||||||
|
|
||||||
|
|
||||||
|
def build_month_keyboard(current: date) -> InlineKeyboardMarkup:
|
||||||
|
cal = calendar.Calendar(firstweekday=0)
|
||||||
|
keyboard: list[list[InlineKeyboardButton]] = []
|
||||||
|
|
||||||
|
title = current.strftime("%B %Y")
|
||||||
|
keyboard.append(
|
||||||
|
[InlineKeyboardButton(title, callback_data=CalendarCallback(CalendarActions.IGNORE, current.year, current.month).pack())]
|
||||||
|
)
|
||||||
|
|
||||||
|
# год назад / год вперёд
|
||||||
|
keyboard.append(
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
"⏮",
|
||||||
|
callback_data=CalendarCallback(CalendarActions.PREV_YEAR, current.year - 1, current.month).pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
"⏭",
|
||||||
|
callback_data=CalendarCallback(CalendarActions.NEXT_YEAR, current.year + 1, current.month).pack(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard.append(
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("Пн", callback_data=CalendarCallback(CalendarActions.IGNORE, current.year, current.month).pack()),
|
||||||
|
InlineKeyboardButton("Вт", callback_data=CalendarCallback(CalendarActions.IGNORE, current.year, current.month).pack()),
|
||||||
|
InlineKeyboardButton("Ср", callback_data=CalendarCallback(CalendarActions.IGNORE, current.year, current.month).pack()),
|
||||||
|
InlineKeyboardButton("Чт", callback_data=CalendarCallback(CalendarActions.IGNORE, current.year, current.month).pack()),
|
||||||
|
InlineKeyboardButton("Пт", callback_data=CalendarCallback(CalendarActions.IGNORE, current.year, current.month).pack()),
|
||||||
|
InlineKeyboardButton("Сб", callback_data=CalendarCallback(CalendarActions.IGNORE, current.year, current.month).pack()),
|
||||||
|
InlineKeyboardButton("Вс", callback_data=CalendarCallback(CalendarActions.IGNORE, current.year, current.month).pack()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for week in cal.monthdayscalendar(current.year, current.month):
|
||||||
|
row = []
|
||||||
|
for day in week:
|
||||||
|
if day == 0:
|
||||||
|
row.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
" ",
|
||||||
|
callback_data=CalendarCallback(CalendarActions.IGNORE, current.year, current.month).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
str(day),
|
||||||
|
callback_data=CalendarCallback(CalendarActions.SELECT_DAY, current.year, current.month, day).pack(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
keyboard.append(row)
|
||||||
|
|
||||||
|
prev_month = (current.replace(day=1) - timedelta(days=1)).replace(day=1)
|
||||||
|
next_month = (current.replace(day=28) + timedelta(days=4)).replace(day=1)
|
||||||
|
|
||||||
|
keyboard.append(
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
"«",
|
||||||
|
callback_data=CalendarCallback(CalendarActions.PREV_MONTH, prev_month.year, prev_month.month).pack(),
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
"»",
|
||||||
|
callback_data=CalendarCallback(CalendarActions.NEXT_MONTH, next_month.year, next_month.month).pack(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
|
||||||
|
def build_time_keyboard(selected: date, start_hour: int = 9, end_hour: int = 21, step_minutes: int = 60) -> InlineKeyboardMarkup:
|
||||||
|
buttons: list[list[InlineKeyboardButton]] = []
|
||||||
|
row: list[InlineKeyboardButton] = []
|
||||||
|
current = datetime.combine(selected, datetime.min.time()).replace(hour=start_hour, minute=0)
|
||||||
|
end = current.replace(hour=end_hour, minute=0)
|
||||||
|
|
||||||
|
while current <= end:
|
||||||
|
callback = CalendarCallback(
|
||||||
|
action=CalendarActions.SELECT_TIME,
|
||||||
|
year=current.year,
|
||||||
|
month=current.month,
|
||||||
|
day=current.day,
|
||||||
|
hour=current.hour,
|
||||||
|
minute=current.minute,
|
||||||
|
).pack()
|
||||||
|
row.append(InlineKeyboardButton(current.strftime("%H:%M"), callback_data=callback))
|
||||||
|
if len(row) == 3:
|
||||||
|
buttons.append(row)
|
||||||
|
row = []
|
||||||
|
current += timedelta(minutes=step_minutes)
|
||||||
|
|
||||||
|
if row:
|
||||||
|
buttons.append(row)
|
||||||
|
|
||||||
|
buttons.append(
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
"Назад к месяцам",
|
||||||
|
callback_data=CalendarCallback(CalendarActions.PREV_MONTH, selected.year, selected.month).pack(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return InlineKeyboardMarkup(buttons)
|
||||||
@ -83,4 +83,4 @@ def make_a_payment_link(base_link: str, code: str, phone: str, amount: Decimal,
|
|||||||
("products[0][currency]", currency),
|
("products[0][currency]", currency),
|
||||||
("do", "pay"),
|
("do", "pay"),
|
||||||
]
|
]
|
||||||
return f"{base_link}/?{urlencode(params, doseq=True)}"
|
return f"{base_link}?{urlencode(params, doseq=True)}"
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
from telegram import (
|
from telegram import (
|
||||||
Update, InlineKeyboardButton, InlineKeyboardMarkup
|
Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
)
|
)
|
||||||
|
import pytz
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
from telegram.constants import ParseMode
|
from telegram.constants import ParseMode
|
||||||
from telegram.ext import (
|
from telegram.ext import (
|
||||||
ContextTypes,
|
ContextTypes,
|
||||||
@ -24,6 +26,12 @@ from core.logging import ActorLogger
|
|||||||
from core.exceptions import DatabaseError
|
from core.exceptions import DatabaseError
|
||||||
from core.enums.dialog_helpers import ConfirmationMessage
|
from core.enums.dialog_helpers import ConfirmationMessage
|
||||||
from core.utils import is_phone_correct, make_a_payment_link
|
from core.utils import is_phone_correct, make_a_payment_link
|
||||||
|
from src.core.inline_calendar import (
|
||||||
|
build_month_keyboard,
|
||||||
|
build_time_keyboard,
|
||||||
|
CalendarCallback,
|
||||||
|
CalendarActions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SEND_ACKNOWLEDGEMENT_INFO = 1
|
SEND_ACKNOWLEDGEMENT_INFO = 1
|
||||||
@ -31,9 +39,10 @@ PROCEED_WITH_CONSULTATION = 2
|
|||||||
SELECT_CONSULTATION_TYPE = 3
|
SELECT_CONSULTATION_TYPE = 3
|
||||||
ENTER_PATIENT_PHONE = 4
|
ENTER_PATIENT_PHONE = 4
|
||||||
ENTER_DOCTOR_NUMBER = 5
|
ENTER_DOCTOR_NUMBER = 5
|
||||||
ENTER_CONSULTATION_DATE = 6
|
SELECT_CONSULTATION_SLOT = 6
|
||||||
PAY_CONSULTATION = 7
|
SELECT_CONSULTATION_TIME = 7
|
||||||
ERROR_PHONE_NUMBER = 8
|
PAY_CONSULTATION = 8
|
||||||
|
ERROR_PHONE_NUMBER = 9
|
||||||
actor_logger = ActorLogger()
|
actor_logger = ActorLogger()
|
||||||
|
|
||||||
|
|
||||||
@ -241,75 +250,96 @@ async def receive_patient_phone(update: Update, context: ContextTypes.DEFAULT_TY
|
|||||||
|
|
||||||
async def receive_doctor_number(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
async def receive_doctor_number(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
user_id = context.user_data['telegram_id']
|
user_id = context.user_data['telegram_id']
|
||||||
if not context.user_data.get('doctor_number'):
|
doctor_number = update.message.text.strip()
|
||||||
doctor_number = update.message.text.strip()
|
context.user_data['doctor_number'] = doctor_number
|
||||||
context.user_data['doctor_number'] = doctor_number
|
try:
|
||||||
try:
|
doctor = await get_doctor_by_code(doctor_number)
|
||||||
doctor = await get_doctor_by_code(doctor_number)
|
actor_logger.info(user_id, f"Doctor found: {doctor}")
|
||||||
actor_logger.info(user_id, f"Doctor found: {doctor}")
|
except DatabaseError as e:
|
||||||
except DatabaseError as e:
|
actor_logger.error(user_id, f"Failed to fetch doctor by code {doctor_number}: {e}")
|
||||||
actor_logger.error(user_id, f"Failed to fetch doctor by code {doctor_number}: {e}")
|
|
||||||
await update.message.reply_text(
|
|
||||||
"Не удалось проверить код врача. Попробуйте повторить запрос позже. /"
|
|
||||||
)
|
|
||||||
context.user_data.clear()
|
|
||||||
return ConversationHandler.END
|
|
||||||
|
|
||||||
if not doctor:
|
|
||||||
await update.message.reply_text(
|
|
||||||
"Врач с таким кодом не найден. Проверьте номер или запросите новый код."
|
|
||||||
)
|
|
||||||
context.user_data.pop('doctor_number', None)
|
|
||||||
return ENTER_DOCTOR_NUMBER
|
|
||||||
|
|
||||||
if not await is_user_has_phone(user_id):
|
|
||||||
patient_phone = context.user_data['phone']
|
|
||||||
await update_patient_phone(telegram_id=user_id, phone=patient_phone)
|
|
||||||
actor_logger.info(user_id, f"Saved patient's phone number {patient_phone}")
|
|
||||||
|
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
text=f"Вы ввели серийный номер врача: {doctor_number}\n"
|
"Не удалось проверить код врача. Попробуйте повторить запрос позже. /"
|
||||||
"Введите дату и время консультации в формате ДД.ММ.ГГ ЧЧ:ММ, например, 01.01.23 12:00.",
|
|
||||||
parse_mode="Markdown"
|
|
||||||
)
|
)
|
||||||
else:
|
context.user_data.clear()
|
||||||
actor_logger.info(user_id, f"User {user_id} requested correction of their consultation date")
|
return ConversationHandler.END
|
||||||
await update.effective_message.reply_text(
|
|
||||||
text="Введите дату и время консультации в формате ДД.ММ.ГГ ЧЧ:ММ, например, 01.01.23 12:00.",
|
if not doctor:
|
||||||
parse_mode="Markdown"
|
await update.message.reply_text(
|
||||||
|
"Врач с таким кодом не найден. Проверьте номер или запросите новый код."
|
||||||
)
|
)
|
||||||
|
context.user_data.pop('doctor_number', None)
|
||||||
|
return ENTER_DOCTOR_NUMBER
|
||||||
|
|
||||||
return ENTER_CONSULTATION_DATE
|
if not await is_user_has_phone(user_id):
|
||||||
|
patient_phone = context.user_data['phone']
|
||||||
|
await update_patient_phone(telegram_id=user_id, phone=patient_phone)
|
||||||
async def receive_consultation_date(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
actor_logger.info(user_id, f"Saved patient's phone number {patient_phone}")
|
||||||
consultation_date_time = update.message.text.strip()
|
|
||||||
context.user_data['consultation_date_time'] = consultation_date_time
|
|
||||||
|
|
||||||
# Здесь можно добавить логику для сохранения даты консультации в БД
|
|
||||||
# Например, вызов сервиса для создания записи о консультации
|
|
||||||
|
|
||||||
keyboard = [
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(
|
|
||||||
"Изменить",
|
|
||||||
callback_data="consult:back"
|
|
||||||
),
|
|
||||||
InlineKeyboardButton(
|
|
||||||
"Продолжить",
|
|
||||||
callback_data="consult:proceed"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
today = datetime.now(tz=pytz.timezone(doctor.time_zone) if doctor.time_zone else None).date()
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
text=f"Вы ввели следующую дату консультации: {consultation_date_time}\n"
|
text="Выберите дату консультации:",
|
||||||
"Если хотите изменить дату консультации, то нажмите кнопку Изменить\n"
|
reply_markup=build_month_keyboard(today),
|
||||||
"Если всё ок, то нажмите Продолжить.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
|
||||||
)
|
)
|
||||||
|
return SELECT_CONSULTATION_SLOT
|
||||||
|
|
||||||
|
async def handle_calendar_navigation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
callback = CalendarCallback.unpack(query.data)
|
||||||
|
current = date(callback.year, callback.month, 1)
|
||||||
|
|
||||||
|
if callback.action == CalendarActions.PREV_YEAR:
|
||||||
|
target = current.replace(year=current.year)
|
||||||
|
await query.edit_message_reply_markup(build_month_keyboard(target))
|
||||||
|
return SELECT_CONSULTATION_SLOT
|
||||||
|
|
||||||
|
if callback.action == CalendarActions.NEXT_YEAR:
|
||||||
|
target = current.replace(year=current.year)
|
||||||
|
await query.edit_message_reply_markup(build_month_keyboard(target))
|
||||||
|
return SELECT_CONSULTATION_SLOT
|
||||||
|
|
||||||
|
if callback.action == CalendarActions.IGNORE:
|
||||||
|
return SELECT_CONSULTATION_SLOT
|
||||||
|
|
||||||
|
if callback.action in (CalendarActions.PREV_MONTH, CalendarActions.NEXT_MONTH):
|
||||||
|
target = current
|
||||||
|
if callback.action == CalendarActions.PREV_MONTH:
|
||||||
|
target = current
|
||||||
|
elif callback.action == CalendarActions.NEXT_MONTH:
|
||||||
|
target = current
|
||||||
|
await query.edit_message_reply_markup(build_month_keyboard(target))
|
||||||
|
return SELECT_CONSULTATION_SLOT
|
||||||
|
|
||||||
|
if callback.action == CalendarActions.SELECT_DAY:
|
||||||
|
selected = date(callback.year, callback.month, callback.day)
|
||||||
|
context.user_data["consultation_date"] = selected.isoformat()
|
||||||
|
await query.edit_message_text(
|
||||||
|
text=f"Дата {selected:%d.%m.%Y}. Выберите время:",
|
||||||
|
reply_markup=build_time_keyboard(selected),
|
||||||
|
)
|
||||||
|
return SELECT_CONSULTATION_TIME
|
||||||
|
|
||||||
|
return SELECT_CONSULTATION_SLOT
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_time_selection(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
callback = CalendarCallback.unpack(query.data)
|
||||||
|
selected_dt = datetime(callback.year, callback.month, callback.day, callback.hour, callback.minute)
|
||||||
|
context.user_data["consultation_date_time"] = selected_dt.strftime("%d.%m.%y %H:%M")
|
||||||
|
|
||||||
|
buttons = InlineKeyboardMarkup([
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("Подтвердить", callback_data="consult:proceed"),
|
||||||
|
InlineKeyboardButton("Назад", callback_data="consult:back"),
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
await query.edit_message_text(
|
||||||
|
text=f"Вы выбрали {selected_dt:%d.%m.%Y %H:%M}. Подтвердите запись.",
|
||||||
|
reply_markup=buttons,
|
||||||
|
)
|
||||||
return PAY_CONSULTATION
|
return PAY_CONSULTATION
|
||||||
|
|
||||||
|
|
||||||
@ -369,10 +399,15 @@ async def pay_consultation(update: Update, context: ContextTypes.DEFAULT_TYPE) -
|
|||||||
base_link=base_link, code=session_code, phone=patient.phone, amount=price, service_name=service_name
|
base_link=base_link, code=session_code, phone=patient.phone, amount=price, service_name=service_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
buttons = InlineKeyboardMarkup([
|
||||||
|
[InlineKeyboardButton("Оплатить в браузере", url=payment_link)],
|
||||||
|
])
|
||||||
|
|
||||||
await update.callback_query.answer()
|
await update.callback_query.answer()
|
||||||
await update.callback_query.message.reply_text(
|
await update.callback_query.message.reply_text(
|
||||||
f"Чтобы оплатить консультацию перейдите по ссылке {payment_link}.",
|
"Чтобы оплатить консультацию перейдите по ссылке нажмите на кнопку.",
|
||||||
parse_mode=ParseMode.HTML
|
reply_markup=buttons,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
)
|
)
|
||||||
|
|
||||||
context.user_data.clear()
|
context.user_data.clear()
|
||||||
@ -397,10 +432,15 @@ def get_consultation_handler() -> ConversationHandler:
|
|||||||
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_number),
|
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_number),
|
||||||
CallbackQueryHandler(enter_patient_phone, pattern="^consult:back$")
|
CallbackQueryHandler(enter_patient_phone, pattern="^consult:back$")
|
||||||
],
|
],
|
||||||
ENTER_CONSULTATION_DATE: [MessageHandler(filters=filters.TEXT & ~filters.COMMAND, callback=receive_consultation_date)],
|
SELECT_CONSULTATION_SLOT: [
|
||||||
|
CallbackQueryHandler(handle_calendar_navigation, pattern=r"^CAL_")
|
||||||
|
],
|
||||||
|
SELECT_CONSULTATION_TIME: [
|
||||||
|
CallbackQueryHandler(handle_time_selection, pattern=r"^CAL_TIME")
|
||||||
|
],
|
||||||
PAY_CONSULTATION: [
|
PAY_CONSULTATION: [
|
||||||
CallbackQueryHandler(pay_consultation, pattern="^consult:proceed$"),
|
CallbackQueryHandler(pay_consultation, pattern="^consult:proceed$"),
|
||||||
CallbackQueryHandler(receive_doctor_number, pattern="^consult:back$")
|
CallbackQueryHandler(handle_calendar_navigation, pattern="^consult:back$")
|
||||||
],
|
],
|
||||||
ERROR_PHONE_NUMBER: [CallbackQueryHandler(enter_patient_phone, pattern="^consult:back$")],
|
ERROR_PHONE_NUMBER: [CallbackQueryHandler(enter_patient_phone, pattern="^consult:back$")],
|
||||||
STOPPING: [get_start_handler()],
|
STOPPING: [get_start_handler()],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user