From cb9750d33ebf2ede02defd638cb13efd8d7e4682 Mon Sep 17 00:00:00 2001 From: Oleg Oleg Date: Sun, 21 Dec 2025 01:15:13 +0400 Subject: [PATCH] add calendar picker --- src/core/inline_calendar.py | 146 +++++++++++++++ src/core/utils.py | 2 +- .../handlers/patients/consultation_handler.py | 176 +++++++++++------- 3 files changed, 255 insertions(+), 69 deletions(-) create mode 100644 src/core/inline_calendar.py diff --git a/src/core/inline_calendar.py b/src/core/inline_calendar.py new file mode 100644 index 0000000..72f1586 --- /dev/null +++ b/src/core/inline_calendar.py @@ -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) diff --git a/src/core/utils.py b/src/core/utils.py index ef7de4d..639eca6 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -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)}" diff --git a/src/docbot/handlers/patients/consultation_handler.py b/src/docbot/handlers/patients/consultation_handler.py index cc25104..9fe9a1b 100644 --- a/src/docbot/handlers/patients/consultation_handler.py +++ b/src/docbot/handlers/patients/consultation_handler.py @@ -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()],