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),
|
||||
("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 (
|
||||
Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
)
|
||||
import pytz
|
||||
from datetime import date, datetime, timedelta
|
||||
from telegram.constants import ParseMode
|
||||
from telegram.ext import (
|
||||
ContextTypes,
|
||||
@ -24,6 +26,12 @@ from core.logging import ActorLogger
|
||||
from core.exceptions import DatabaseError
|
||||
from core.enums.dialog_helpers import ConfirmationMessage
|
||||
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
|
||||
@ -31,9 +39,10 @@ PROCEED_WITH_CONSULTATION = 2
|
||||
SELECT_CONSULTATION_TYPE = 3
|
||||
ENTER_PATIENT_PHONE = 4
|
||||
ENTER_DOCTOR_NUMBER = 5
|
||||
ENTER_CONSULTATION_DATE = 6
|
||||
PAY_CONSULTATION = 7
|
||||
ERROR_PHONE_NUMBER = 8
|
||||
SELECT_CONSULTATION_SLOT = 6
|
||||
SELECT_CONSULTATION_TIME = 7
|
||||
PAY_CONSULTATION = 8
|
||||
ERROR_PHONE_NUMBER = 9
|
||||
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:
|
||||
user_id = context.user_data['telegram_id']
|
||||
if not context.user_data.get('doctor_number'):
|
||||
doctor_number = update.message.text.strip()
|
||||
context.user_data['doctor_number'] = doctor_number
|
||||
try:
|
||||
doctor = await get_doctor_by_code(doctor_number)
|
||||
actor_logger.info(user_id, f"Doctor found: {doctor}")
|
||||
except DatabaseError as 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}")
|
||||
|
||||
doctor_number = update.message.text.strip()
|
||||
context.user_data['doctor_number'] = doctor_number
|
||||
try:
|
||||
doctor = await get_doctor_by_code(doctor_number)
|
||||
actor_logger.info(user_id, f"Doctor found: {doctor}")
|
||||
except DatabaseError as e:
|
||||
actor_logger.error(user_id, f"Failed to fetch doctor by code {doctor_number}: {e}")
|
||||
await update.message.reply_text(
|
||||
text=f"Вы ввели серийный номер врача: {doctor_number}\n"
|
||||
"Введите дату и время консультации в формате ДД.ММ.ГГ ЧЧ:ММ, например, 01.01.23 12:00.",
|
||||
parse_mode="Markdown"
|
||||
"Не удалось проверить код врача. Попробуйте повторить запрос позже. /"
|
||||
)
|
||||
else:
|
||||
actor_logger.info(user_id, f"User {user_id} requested correction of their consultation date")
|
||||
await update.effective_message.reply_text(
|
||||
text="Введите дату и время консультации в формате ДД.ММ.ГГ ЧЧ:ММ, например, 01.01.23 12:00.",
|
||||
parse_mode="Markdown"
|
||||
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
|
||||
|
||||
return ENTER_CONSULTATION_DATE
|
||||
|
||||
|
||||
async def receive_consultation_date(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
|
||||
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"
|
||||
),
|
||||
]
|
||||
]
|
||||
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}")
|
||||
|
||||
today = datetime.now(tz=pytz.timezone(doctor.time_zone) if doctor.time_zone else None).date()
|
||||
await update.message.reply_text(
|
||||
text=f"Вы ввели следующую дату консультации: {consultation_date_time}\n"
|
||||
"Если хотите изменить дату консультации, то нажмите кнопку Изменить\n"
|
||||
"Если всё ок, то нажмите Продолжить.",
|
||||
parse_mode="Markdown",
|
||||
reply_markup=InlineKeyboardMarkup(keyboard)
|
||||
text="Выберите дату консультации:",
|
||||
reply_markup=build_month_keyboard(today),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@ -368,11 +398,16 @@ async def pay_consultation(update: Update, context: ContextTypes.DEFAULT_TYPE) -
|
||||
payment_link = make_a_payment_link(
|
||||
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.message.reply_text(
|
||||
f"Чтобы оплатить консультацию перейдите по ссылке {payment_link}.",
|
||||
parse_mode=ParseMode.HTML
|
||||
"Чтобы оплатить консультацию перейдите по ссылке нажмите на кнопку.",
|
||||
reply_markup=buttons,
|
||||
parse_mode=ParseMode.HTML,
|
||||
)
|
||||
|
||||
context.user_data.clear()
|
||||
@ -397,10 +432,15 @@ def get_consultation_handler() -> ConversationHandler:
|
||||
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_number),
|
||||
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: [
|
||||
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$")],
|
||||
STOPPING: [get_start_handler()],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user