add calendar picker

This commit is contained in:
Oleg Oleg 2025-12-21 01:15:13 +04:00
parent d12a1105a1
commit cb9750d33e
3 changed files with 255 additions and 69 deletions

146
src/core/inline_calendar.py Normal file
View 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)

View File

@ -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)}"

View File

@ -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()],