├── .gitattributes
├── requirements.txt
├── keyboards.py
├── clear_dict.py
├── .gitignore
├── README.md
├── telebot_calendar.py
├── google_sheet.py
└── main.py
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frolovelo/saloon_bot/HEAD/requirements.txt
--------------------------------------------------------------------------------
/keyboards.py:
--------------------------------------------------------------------------------
1 | """
2 | Создание клавиатуры главного меню и кнопок "Назад"/"Отмена"
3 | """
4 | from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton
5 |
6 |
7 | def create_markup_menu():
8 | """
9 | Создаёт клавиатуру главного меню
10 |
11 | :return: InlineKeyboardMarkup
12 | """
13 | menu_buttons = ['Запись✅', 'Отмена записи❌', 'Мои записи📝']
14 | markup = InlineKeyboardMarkup(row_width=2)
15 | markup.add(InlineKeyboardButton(text=menu_buttons[0], callback_data='RECORD'))
16 | markup.add(InlineKeyboardButton(text=menu_buttons[1], callback_data='CANCEL_RECORD'))
17 | markup.add(InlineKeyboardButton(text=menu_buttons[2], callback_data='MY_RECORD'))
18 |
19 | return markup
20 |
21 |
22 | def button_to_menu(return_callback: str | None, return_text='Назад', menu_text='Вернуться в меню') \
23 | -> list[InlineKeyboardButton]:
24 | """
25 | Создает кнопки "Назад" и "В главное меню".
26 |
27 | :param return_callback: Callback-данные для кнопки "Назад".
28 | Если значение - None, то кнопка не будет создана.
29 | :param return_text: Текст на кнопке "Назад" (по умолчанию - "Назад")
30 | :param menu_text: Текст на кнопке "В главное меню" (по умолчанию - "Вернуться в меню")
31 |
32 | :return: Список объектов InlineKeyboardButton
33 | """
34 | if return_callback:
35 | return [InlineKeyboardButton(text=return_text, callback_data=return_callback),
36 | InlineKeyboardButton(text=menu_text, callback_data='MENU')]
37 | return [InlineKeyboardButton(text=menu_text, callback_data='MENU')]
38 |
--------------------------------------------------------------------------------
/clear_dict.py:
--------------------------------------------------------------------------------
1 | """
2 | Хранение информации о пользователе и отчистка
3 | """
4 | from datetime import datetime, timedelta
5 | from threading import Lock, Thread
6 | from time import sleep
7 |
8 | # хранит объекты GoogleSheet по ключу id
9 | CLIENT_DICT = {}
10 | # хранит название календаря по ключу id
11 | CALENDAR_DICT = {}
12 | # хранит время создания объекта GoogleSheet
13 | TIMER_DICT = {}
14 | # Lock для синхронизации доступа к словарям
15 | lock = Lock()
16 |
17 |
18 | def clear_unused_info(chat_id) -> None:
19 | """
20 | Отчищает данные из GoogleSheet,
21 | не затрагивает кэшированные данные
22 |
23 | :param chat_id: id пользователя
24 | """
25 | if CLIENT_DICT.get(chat_id):
26 | client = CLIENT_DICT[chat_id]
27 | client.lst_currant_date = None
28 | client.dct_currant_time = None
29 | # client.lst_records = None
30 | client.name_service = None
31 | client.name_master = None
32 | client.date_record = None
33 | client.time_record = None
34 |
35 | if CALENDAR_DICT.get(chat_id):
36 | del CALENDAR_DICT[chat_id]
37 |
38 |
39 | def clear_all_dict(chat_id) -> None:
40 | """
41 | Отчищает все словари по chat_id
42 |
43 | :param chat_id: id пользователя
44 | """
45 | if CLIENT_DICT.get(chat_id):
46 | del CLIENT_DICT[chat_id]
47 | if CALENDAR_DICT.get(chat_id):
48 | del CALENDAR_DICT[chat_id]
49 | if TIMER_DICT.get(chat_id):
50 | del TIMER_DICT[chat_id]
51 |
52 |
53 | def clear_client_dict(period_clear_minutes=60) -> None:
54 | """
55 | Отчищает все неактивные элементы словарей
56 |
57 | :param period_clear_minutes: периодичность отчистки в минутах
58 | """
59 | while True:
60 | sleep(period_clear_minutes * 60)
61 | at_now = datetime.now()
62 | lst_to_del = []
63 | with lock:
64 | for key, val in TIMER_DICT.items():
65 | if val + timedelta(minutes=period_clear_minutes) >= at_now:
66 | lst_to_del.append(key)
67 | for chat_id in lst_to_del:
68 | clear_all_dict(chat_id)
69 |
70 |
71 | clear_thread = Thread(target=clear_client_dict)
72 | clear_thread.daemon = True
73 | clear_thread.start()
74 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
105 | __pypackages__/
106 |
107 | # Celery stuff
108 | celerybeat-schedule
109 | celerybeat.pid
110 |
111 | # SageMath parsed files
112 | *.sage.py
113 |
114 | # Environments
115 | .env
116 | .venv
117 | env/
118 | venv/
119 | ENV/
120 | env.bak/
121 | venv.bak/
122 |
123 | # Spyder project settings
124 | .spyderproject
125 | .spyproject
126 |
127 | # Rope project settings
128 | .ropeproject
129 |
130 | # mkdocs documentation
131 | /site
132 |
133 | # mypy
134 | .mypy_cache/
135 | .dmypy.json
136 | dmypy.json
137 |
138 | # Pyre type checker
139 | .pyre/
140 |
141 | # pytype static type analyzer
142 | .pytype/
143 |
144 | # Cython debug symbols
145 | cython_debug/
146 |
147 | # PyCharm
148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
150 | # and can be added to the global gitignore or merged into this file. For a more nuclear
151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
152 | #.idea/
153 | /beautysaloon.json
154 | /config.py
155 | /tetstik.py
156 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # saloon_bot 💅
2 |
3 | **saloon_bot** - *Telegram bot* для записи в салон красоты с использованием *Google Sheets*
4 |
5 | 
6 | 
7 | 
8 | 
9 |
10 |
11 |
12 |
13 |
14 | ------
15 |
16 | ## Описание
17 | Данный проект представляет собой ***телеграмм бота***, который позволяет пользователям записываться в салон красоты.
18 | Бот использует ***Google Sheets*** для хранения информации о клиентах и их записях.
19 |
20 | **Пример таблицы:** https://docs.google.com/spreadsheets/d/1VmucIj0jhJcIDv3tkfpXtlLoDRh4Zhoa8DuCTzOuhuQ/edit?usp=sharing
21 |
22 |
23 | **telebot documentation:** [https://github.com/eternnoir/pyTelegramBotAPI](https://github.com/eternnoir/pyTelegramBotAPI)
24 |
25 | **gspread documentation:** [https://docs.gspread.org/en/v5.7.2/](https://docs.gspread.org/en/v5.7.2/)
26 |
27 |
28 | ## Установка
29 |
30 | ```
31 | git clone https://github.com/frolovelo/saloon_bot.git
32 | ```
33 | ## Зависимости
34 |
35 | **Windows**
36 |
37 | ```bash
38 | pip install -r requirements.txt
39 | ```
40 |
41 | **macOS/Linux:**
42 |
43 | ```bash
44 | pip3 install -r requirements.txt
45 | ```
46 |
47 | **Активация виртуального окружения (Windows):**
48 |
49 | ```bash
50 | \venv\Scripts\activate
51 | ```
52 |
53 | **Активация виртуального окружения (macOS/Linux):**
54 |
55 | ```bash
56 | source venv/bin/activate
57 | ```
58 |
59 | ## Использование
60 |
61 | 1. Создайте ***config.py*** с содержимым:
62 | ```python
63 | TOKEN = "YOUR_BOT_TOKEN"
64 | ```
65 | 2. Получите **json key** от *Google Sheets*
66 |
67 | Как получить:
68 |
69 | - Создать аккаунт Google. Аккаунт позволяет пользоваться большинством сервисов Google без необходимости регистрироваться в каждом из них.
70 | - Открыть страницу console.developers.google.com и нажать «Создать проект».
71 | - Ввести имя проекта и нажать «Создать».
72 | - Выбрать на своём проекте меню «Настройки».
73 | - Перейти в пункт меню «Сервисные аккаунты», а затем «Создать сервисный аккаунт».
74 | - Ввести название аккаунта и нажать «Создать и продолжить».
75 | - Выбрать роль «Владелец» и нажать «Продолжить».
76 | - Завершить создание сервисного аккаунта.
77 | - Открыть пункт управления ключами.
78 | - Нажать «Добавить ключ».
79 | - Нажать «Сгенерировать новый ключ».
80 | - Выбрать тип JSON и нажать «Создать».
81 | - Скопировать адрес электронной почты (он будет необходим при выдаче прав на доступ сервисного аккаунта к таблице).
82 | - Открыть навигационное меню и перейти на вкладку «API & Сервисы».
83 | - В поле поиска ввести «Google Sheets API» и кликнуть на соответствующий вариант результата поиска.
84 | - Активировать сервис «Google Sheets API».
85 | - Для работы с Google таблицей необходимо предоставить доступ к ней созданному аккаунту. Для этого нужно открыть Google таблицу и нажать «Настройки Доступа».
86 | - Ввести в поле поиска адрес электронной почты сервисного аккаунта (из пункта 14 раздела «Регистрация сервисного аккаунта»).
87 | - Указать права доступа и кликнуть «Отправить».
88 |
89 | Структура ключа:
90 | ```json
91 | {
92 | "type": "service_account",
93 | "project_id": "beautysaloon",
94 | "private_key_id": "fGEFEfeEWR343253235",
95 | "private_key": "-----BEGIN PRIVATE KEY-----\n",
96 | "client_email": "my-account-service@beautysaloon.iam.gserviceaccount.com",
97 | "client_id": "10275785785778592",
98 | "auth_uri": "https://accounts.google.com/o/oauth2/auth",
99 | "token_uri": "https://oauth2.googleapis.com/token",
100 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
101 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-account-service",
102 | "universe_domain": "googleapis.com"
103 | }
104 | ```
105 | 3. Замените название ключа в [google_sheet.py](google_sheet.py)
106 | ```python
107 | # Название файла json ключа
108 | creds = Credentials.from_service_account_file('YOUR_NAME_KEY.json', scopes=myscope)
109 | client_main = gspread.Client(creds)
110 | ```
111 |
112 | 4. Для тестового запуска *рекомендуется* скопировать данные из ***примера таблицы:*** https://docs.google.com/spreadsheets/d/1VmucIj0jhJcIDv3tkfpXtlLoDRh4Zhoa8DuCTzOuhuQ/edit?usp=sharing
113 |
114 |
115 | 5. Смените данные на свои в [google_sheet.py](google_sheet.py)
116 | ```python
117 | # Название таблицы
118 | sh = client_main.open('YOUR_TABLE_NAME')
119 | # Страницы таблицы, которые должны игнорироваться во избежание проблем
120 | IGNOR_WORKSHEETS = ['Работники']
121 | # Страница таблицы, на которой перечислены все действующие работники и услуги
122 | NAME_SHEET_WORKERS = 'Работники'
123 | # Названия основных колонок(очередность важна!)
124 | NAME_COL_SERVICE = 'Услуга'
125 | NAME_COL_MASTER = 'Мастер'
126 | ```
127 | * ``` sh = client_main.open('YOUR_TABLE_NAME')``` - имя вашей таблицы
128 | * ```IGNOR_WORKSHEETS``` - имена листов, структура которых отличается от листов для записи
129 | * ```NAME_SHEET_WORKERS``` - имя листа со всеми услугами и работниками
130 | * ``` NAME_COL_SERVICE``` и ```NAME_COL_MASTER``` - названия колонок в вашей таблице
131 |
132 | #### Примечание:
133 | 1. Лист ```NAME_SHEET_WORKERS``` требуется для выдачи клиентам списка мастеров и услуг;
134 |
135 |
136 |
137 |
138 |
139 | 2. Листы для записи должны иметь определенный формат имени: 'дд.мм.гг';
140 |
141 |
142 |
143 |
144 |
145 | 3. В листах для записи следует соблюдать лишь первые две колонки: 'Услуга', 'Мастер',
146 | время для записи вы можете ставить на своё усмотрение.
147 |
148 |
149 |
150 |
151 |
152 | ## Структура проекта
153 |
154 | * [config.py]() - токен бота
155 | * [main.py](main.py) - telegram бот
156 | * [google_sheet.py](google_sheet.py) - работа с Google Sheet
157 | * [clear_dict.py](clear_dict.py) - хранение информации о пользователях и периодичная отчистка
158 | * [keyboards.py](keyboards.py) - клавиатуры и кнопки Telebot
159 | * [telebot_calendar.py](telebot_calendar.py) - клавиатура в виде календаря
160 | * [requirements.txt](requirements.txt) - библиотеки
161 |
162 | ## Вклад и разработка
163 | Если вы обнаружили ошибки или у вас есть предложения по улучшению проекта, пожалуйста, создайте Issue или Pull Request в репозитории проекта.
164 |
165 | ## TO-DO
166 |
167 | - [x] Безопасность потоков
168 | - [x] Дополнительные запросы к *Google Sheets* при возникновении ошибок ```google_sheet.py```
169 | - [x] Оптимальное использование памяти, отчистка по таймауту ```clear-dict.py```
170 | - [x] Кэширование данных из *Google Sheets* для экономии кол-ва запросов к api
171 | - [ ] SQLAlchemy/MongoDB для хранения номеров телефона пользователя
172 | - [ ] Удаление дат, которые были свободны, но в процессе бронирования заполнились
173 | - [ ] Асинхронный Telebot + Анимация загрузки
174 | - [ ] Функционал напоминаний о записи
175 | - [ ] Создание вспомогательного бота админа для удаленной настройки бота
176 | - [ ] Отправка уведомлений о новых записях администратору салона на доп. аккаунт telegram
177 |
178 | ## Референсы
179 |
180 | [purgy](https://github.com/purgy/telebot-calendar) - Telebot календарь
181 |
--------------------------------------------------------------------------------
/telebot_calendar.py:
--------------------------------------------------------------------------------
1 | """
2 | Календарь из inline-кнопок
3 | """
4 | import datetime
5 | import calendar
6 | import typing
7 |
8 | from telebot import TeleBot
9 | from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery
10 |
11 | MONTHS = (
12 | "Январь",
13 | "Февраль",
14 | "Март",
15 | "Апрель",
16 | "Май",
17 | "Июнь",
18 | "Июль",
19 | "Август",
20 | "Сентябрь",
21 | "Октябрь",
22 | "Ноябрь",
23 | "Декабрь",
24 | )
25 | DAYS = ("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс")
26 |
27 |
28 | class CallbackData:
29 | """
30 | Callback data factory
31 | """
32 |
33 | def __init__(self, prefix, *parts, sep=":"):
34 | if not isinstance(prefix, str):
35 | raise TypeError(
36 | f"Prefix must be instance of str not {type(prefix).__name__}"
37 | )
38 | if not prefix:
39 | raise ValueError("Prefix can't be empty")
40 | if sep in prefix:
41 | raise ValueError(f"Separator {sep!r} can't be used in prefix")
42 | if not parts:
43 | raise TypeError("Parts were not passed!")
44 | # print(prefix, parts)
45 | self.prefix = prefix
46 | self.sep = sep
47 |
48 | self._part_names = parts
49 |
50 | def new(self, *args, **kwargs) -> str:
51 | """
52 | Generate callback data
53 |
54 | :param args:
55 | :param kwargs:
56 | :return:
57 | """
58 |
59 | args = list(args)
60 |
61 | data = [self.prefix]
62 |
63 | for part in self._part_names:
64 | value = kwargs.pop(part, None)
65 | if value is None:
66 | if args:
67 | value = args.pop(0)
68 | else:
69 | raise ValueError(f"Value for {part!r} was not passed!")
70 |
71 | if value is not None and not isinstance(value, str):
72 | value = str(value)
73 |
74 | if not value:
75 | raise ValueError(f"Value for part {part!r} can't be empty!'")
76 | if self.sep in value:
77 | raise ValueError(
78 | f"Symbol {self.sep!r} is defined as the separator and can't be used in parts' values"
79 | )
80 |
81 | data.append(value)
82 |
83 | if args or kwargs:
84 | raise TypeError("Too many arguments were passed!")
85 |
86 | callback_data = self.sep.join(data)
87 | if len(callback_data) > 64:
88 | raise ValueError("Resulted callback data is too long!")
89 |
90 | return callback_data
91 |
92 | def parse(self, callback_data: str) -> typing.Dict[str, str]:
93 | """
94 | Parse data from the callback data
95 |
96 | :param callback_data:
97 | :return:
98 | """
99 |
100 | prefix, *parts = callback_data.split(self.sep)
101 |
102 | if prefix != self.prefix:
103 | raise ValueError(
104 | "Passed callback data can't be parsed with that prefix.")
105 | elif len(parts) != len(self._part_names):
106 | raise ValueError("Invalid parts count!")
107 |
108 | result = {"@": prefix}
109 | result.update(zip(self._part_names, parts))
110 |
111 | return result
112 |
113 | def filter(self, **config):
114 | """
115 | Generate filter
116 |
117 | :param config:
118 | :return:
119 | """
120 |
121 | # print(config, self._part_names)
122 | for key in config.keys():
123 | if key not in self._part_names:
124 | return False
125 |
126 | return True
127 |
128 |
129 | def create_calendar(lst_current_date: list[datetime.date], name: str = "calendar", year: int = None, month: int = None,
130 | ) -> InlineKeyboardMarkup:
131 | """
132 | Create a built-in inline keyboard with calendar
133 |
134 | :param lst_current_date: Список доступных дат для записи в формате datetime.date
135 | :param name: Имя календаря
136 | :param year: Год используемый календарём, если не используете текущий год.
137 | :param month: Месяц используемый календарём, если не используете текущий месяц.
138 | :return: Возвращает объект InlineKeyboardMarkup с календарём.
139 | """
140 |
141 | now_day = datetime.datetime.now()
142 |
143 | if year is None:
144 | year = now_day.year
145 | if month is None:
146 | month = now_day.month
147 |
148 | calendar_callback = CallbackData(name, "action", "year", "month", "day")
149 | data_ignore = calendar_callback.new("IGNORE", year, month, "!")
150 | data_months = calendar_callback.new("MONTHS", year, month, "!")
151 |
152 | keyboard = InlineKeyboardMarkup(row_width=7)
153 |
154 | keyboard.add(
155 | InlineKeyboardButton(
156 | MONTHS[month - 1] + " " + str(year), callback_data=data_months
157 | )
158 | )
159 |
160 | keyboard.add(
161 | *[InlineKeyboardButton(day, callback_data=data_ignore) for day in DAYS]
162 | )
163 |
164 | for week in calendar.monthcalendar(year, month):
165 | row = list()
166 | for day in week:
167 | if day == 0:
168 | row.append(InlineKeyboardButton(
169 | " ", callback_data=data_ignore))
170 | else:
171 | if datetime.date(year, month, day) in lst_current_date:
172 | row.append(
173 | InlineKeyboardButton(
174 | str(day) + '✅',
175 | callback_data=calendar_callback.new(
176 | "DAY", year, month, day),
177 | )
178 | )
179 | else:
180 | row.append(
181 | InlineKeyboardButton(
182 | str(day),
183 | callback_data=calendar_callback.new(
184 | "DAY_EMPTY", year, month, day),
185 | )
186 | )
187 | keyboard.add(*row)
188 |
189 | keyboard.add(
190 | InlineKeyboardButton(
191 | "<", callback_data=calendar_callback.new("PREVIOUS-MONTH", year, month, "!")
192 | ),
193 | InlineKeyboardButton(
194 | "Назад", callback_data=calendar_callback.new("RETURN", year, month, "!")
195 | ),
196 | InlineKeyboardButton(
197 | "Меню", callback_data=calendar_callback.new("MENU", year, month, "!")
198 | ),
199 | InlineKeyboardButton(
200 | ">", callback_data=calendar_callback.new("NEXT-MONTH", year, month, "!")
201 | ),
202 | )
203 |
204 | return keyboard
205 |
206 |
207 | def create_months_calendar(
208 | name: str = "calendar", year: int = None
209 | ) -> InlineKeyboardMarkup:
210 | """
211 | Создаёт календарь из месяцев
212 |
213 | param name:
214 | param year:
215 | return:
216 | """
217 |
218 | if year is None:
219 | year = datetime.datetime.now().year
220 |
221 | calendar_callback = CallbackData(name, "action", "year", "month", "day")
222 |
223 | keyboard = InlineKeyboardMarkup()
224 |
225 | for i, month in enumerate(zip(MONTHS[0::2], MONTHS[1::2])):
226 | keyboard.add(
227 | InlineKeyboardButton(
228 | month[0], callback_data=calendar_callback.new(
229 | "MONTH", year, 2 * i + 1, "!")
230 | ),
231 | InlineKeyboardButton(
232 | month[1],
233 | callback_data=calendar_callback.new(
234 | "MONTH", year, 2 * i + 2, "!"),
235 | ),
236 | )
237 | return keyboard
238 |
239 |
240 | def calendar_query_handler(
241 | bot: TeleBot,
242 | call: CallbackQuery,
243 | name: str,
244 | action: str,
245 | year: int,
246 | month: int,
247 | day: int,
248 | lst_currant_date: list
249 | ) -> None or datetime.datetime:
250 | """
251 | The method creates a new calendar if the forward or backward button is pressed
252 | This method should be called inside CallbackQueryHandler.
253 |
254 |
255 | :param bot: The object of the bot CallbackQueryHandler
256 | :param call: CallbackQueryHandler data
257 | :param day:
258 | :param month:
259 | :param year:
260 | :param action:
261 | :param name:
262 | :param lst_currant_date: Список доступных дат для записи в формате date
263 | :return: Returns a tuple
264 | """
265 |
266 | current = datetime.datetime(int(year), int(month), 1)
267 | if action == "IGNORE":
268 | bot.answer_callback_query(callback_query_id=call.id, text='Тут ничего нет')
269 | return False, None
270 | elif action == "DAY_EMPTY":
271 | bot.answer_callback_query(callback_query_id=call.id, text='Свободного времени нет')
272 | return False, None
273 | elif action == "DAY":
274 | return datetime.datetime(int(year), int(month), int(day))
275 | elif action == "PREVIOUS-MONTH":
276 | preview_month = current - datetime.timedelta(days=1)
277 | bot.edit_message_text(
278 | text=call.message.text,
279 | chat_id=call.message.chat.id,
280 | message_id=call.message.message_id,
281 | reply_markup=create_calendar(
282 | name=name, year=int(preview_month.year), month=int(preview_month.month),
283 | lst_current_date=lst_currant_date
284 | ),
285 | )
286 | return None
287 | elif action == "NEXT-MONTH":
288 | next_month = current + datetime.timedelta(days=31)
289 | bot.edit_message_text(
290 | text=call.message.text,
291 | chat_id=call.message.chat.id,
292 | message_id=call.message.message_id,
293 | reply_markup=create_calendar(
294 | name=name, year=int(next_month.year), month=int(next_month.month),
295 | lst_current_date=lst_currant_date
296 | ),
297 | )
298 | return None
299 | elif action == "MONTHS":
300 | bot.edit_message_text(
301 | text=call.message.text,
302 | chat_id=call.message.chat.id,
303 | message_id=call.message.message_id,
304 | reply_markup=create_months_calendar(name=name, year=current.year),
305 | )
306 | return None
307 | elif action == "MONTH":
308 | bot.edit_message_text(
309 | text=call.message.text,
310 | chat_id=call.message.chat.id,
311 | message_id=call.message.message_id,
312 | reply_markup=create_calendar(
313 | name=name, year=int(year), month=int(month),
314 | lst_current_date=lst_currant_date),
315 | )
316 | return None
317 | elif action == "MENU":
318 | # bot.delete_message(
319 | # chat_id=call.message.chat.id, message_id=call.message.message_id
320 | # )
321 | return "MENU", None
322 | elif action == "RETURN":
323 | return "RETURN", None
324 | else:
325 | bot.answer_callback_query(callback_query_id=call.id, text="ERROR!")
326 | # bot.delete_message(
327 | # chat_id=call.message.chat.id, message_id=call.message.message_id
328 | # )
329 | return None
330 |
--------------------------------------------------------------------------------
/google_sheet.py:
--------------------------------------------------------------------------------
1 | """
2 | Взаимодействие с Google Sheets
3 | """
4 | from datetime import datetime, timedelta
5 | from time import time
6 | from threading import Lock
7 | import json
8 | from concurrent.futures import ThreadPoolExecutor
9 | from google.oauth2.service_account import Credentials
10 | from retrying import retry
11 | from cachetools import TTLCache
12 | import gspread
13 | from pytz import timezone
14 |
15 | myscope = ["https://www.googleapis.com/auth/spreadsheets",
16 | "https://www.googleapis.com/auth/drive"]
17 |
18 | # Временная зона
19 | tz = timezone("Europe/Moscow")
20 | # Название файла json ключа
21 | creds = Credentials.from_service_account_file('beautysaloon.json', scopes=myscope)
22 | client_main = gspread.Client(creds)
23 | # Название таблицы
24 | sh = client_main.open('SaloonSheet')
25 | # Страницы таблицы, которые должны игнорироваться во избежание проблем
26 | IGNOR_WORKSHEETS = ['Работники']
27 | # Страница таблицы, на которой перечислены все действующие работники и услуги
28 | NAME_SHEET_WORKERS = 'Работники'
29 | # Названия основных колонок(очередность важна!)
30 | NAME_COL_SERVICE = 'Услуга'
31 | NAME_COL_MASTER = 'Мастер'
32 |
33 | # Кэш листов с TTL (временем жизни) в 12 часов
34 | CACHE_WORKSHEETS = TTLCache(maxsize=2, ttl=12 * 60 * 60)
35 | # Кэш доступных дат для услуги-мастера с TTL (временем жизни) в 15 минут
36 | CACHE_DAYS = TTLCache(maxsize=6, ttl=15 * 60)
37 | # Lock для синхронизации доступа к словарям
38 | lock = Lock()
39 |
40 |
41 | # Функция для сериализации словаря в JSON-строку
42 | def serialize_dict(dct: dict) -> str:
43 | """Сериализатор json"""
44 | return json.dumps(dct)
45 |
46 |
47 | # Функция для десериализации JSON-строки в словарь
48 | def deserialize_dict(json_str: str) -> dict:
49 | """Десериализатор json"""
50 | return json.loads(json_str)
51 |
52 |
53 | def get_cache_days(service_name: str, master_name: str) -> list | None:
54 | """
55 | Запрашивает свободные даты из кэша cashe_days
56 |
57 | :param service_name: Название услуги
58 | :param master_name: Имя мастера
59 | """
60 | if service_name in CACHE_DAYS:
61 | cached_value = CACHE_DAYS[service_name]
62 | cached_dict = deserialize_dict(cached_value)
63 | if master_name in cached_dict:
64 | return cached_dict[master_name]
65 | return None
66 |
67 |
68 | def update_cache_days(service_name: str, master_name: str, available_dates: list) -> None:
69 | """
70 | Обновляет свободные даты для кэша cache_days
71 |
72 | :param service_name: Название услуги
73 | :param master_name: Имя мастера
74 | :param available_dates: Доступные даты
75 | """
76 | if master_name is None:
77 | master_name = 'null'
78 |
79 | if service_name in CACHE_DAYS:
80 | cached_value = CACHE_DAYS[service_name]
81 | cached_dict = deserialize_dict(cached_value)
82 | if master_name not in cached_dict:
83 | cached_dict[master_name] = available_dates
84 | cache_value = serialize_dict(cached_dict)
85 | CACHE_DAYS[service_name] = cache_value
86 | else:
87 | cache_value = serialize_dict({master_name: available_dates})
88 | CACHE_DAYS[service_name] = cache_value
89 |
90 |
91 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000)
92 | def get_sheet_names() -> list:
93 | """
94 | Запрашивает все имена листов таблицы
95 | """
96 | # Проверяем, есть ли результат в кэше
97 | if 'worksheets' in CACHE_WORKSHEETS:
98 | return CACHE_WORKSHEETS['worksheets']
99 | with lock:
100 | worksheets = sh.worksheets()
101 |
102 | # Кэшируем результат
103 | CACHE_WORKSHEETS['worksheets'] = worksheets
104 | return worksheets
105 |
106 |
107 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000)
108 | def get_cache_services() -> dict:
109 | """
110 | Запрашивает все услуги
111 | """
112 | if 'services' in CACHE_WORKSHEETS:
113 | return CACHE_WORKSHEETS['services']
114 | dct = {}
115 | with lock:
116 | ws = sh.worksheet(NAME_SHEET_WORKERS)
117 | for i in ws.get_all_records():
118 | dct[i[NAME_COL_SERVICE].strip()] = dct.get(i[NAME_COL_SERVICE].strip(), [])
119 | dct[i[NAME_COL_SERVICE].strip()].append(i[NAME_COL_MASTER].strip())
120 |
121 | CACHE_WORKSHEETS['services'] = dct
122 | return dct
123 |
124 |
125 | def time_score(func):
126 | """Декоратор для трекинга времени выполнения функции"""
127 |
128 | def wrapper(*args, **kwargs):
129 | start = time()
130 | res = func(*args, **kwargs)
131 | print(f"---{func.__name__} = %s seconds ---" % round(time() - start, 2))
132 | return res
133 |
134 | return wrapper
135 |
136 |
137 | class GoogleSheets:
138 | """Взаимодействие с GoogleSheet"""
139 |
140 | def __init__(self, client_id: str):
141 | # уникальный идентификатор для создания объекта
142 | self.client_id = client_id
143 | # доступные даты
144 | self.lst_currant_date = None
145 | # доступное время
146 | self.dct_currant_time = None
147 | # записи клиента
148 | self.lst_records = None
149 |
150 | self.name_service = None
151 | self.name_master = None
152 | self.date_record = None
153 | self.time_record = None
154 |
155 | def __str__(self):
156 | return f'Инфо о клиенте:\n' \
157 | f'{self.client_id=}\n' \
158 | f'{self.name_service=}\n' \
159 | f'{self.name_master=}\n' \
160 | f'{self.date_record=}\n' \
161 | f'{self.time_record=}'
162 |
163 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=9000)
164 | def get_all_days(self) -> list:
165 | """Все доступные дни для записи на определенную услугу"""
166 |
167 | check = get_cache_days(self.name_service, self.name_master)
168 | if check:
169 | return check
170 |
171 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000)
172 | def actual_date(sheet_obj, count_days=7) -> bool:
173 | """
174 | Проверяет по объекту листа актуальные даты для записи на ближайшие count_days дней,
175 | а также наличие свободного времени.
176 |
177 | :param sheet_obj: Объект листа (объект gspread)
178 | :param count_days: Количество ближайших дней для поиска (int)
179 |
180 | :return: True - есть доступные свободные слоты; False - все слоты заняты или нет актуальных дат
181 | """
182 | if sheet_obj.title in IGNOR_WORKSHEETS:
183 | return False
184 | try:
185 | date_sheet = datetime.strptime(sheet_obj.title.strip(), '%d.%m.%y').date()
186 | except Exception as ex:
187 | print(ex)
188 | print(sheet_obj.title, '- Добавьте лист в IGNOR_WORKSHEETS')
189 | return False
190 | date_today = datetime.now(tz=tz)
191 | if not date_today.date() <= date_sheet <= (datetime.now(tz=tz).date() + timedelta(days=count_days)):
192 | return False
193 | with lock:
194 | val = sheet_obj.get_all_records()
195 | for dct in val:
196 |
197 | if date_today.date() == date_sheet:
198 | if (self.name_master is not None and
199 | dct[NAME_COL_MASTER].strip() == self.name_master and
200 | dct[NAME_COL_SERVICE].strip() == self.name_service) or \
201 | (self.name_master is None and
202 | dct[NAME_COL_SERVICE].strip() == self.name_service):
203 | for k, v in dct.items():
204 | if str(v).strip() == '' and date_today.time() < datetime.strptime(k, '%H:%M').time():
205 | return sheet_obj.title
206 | continue
207 |
208 | if (self.name_master is not None and dct[NAME_COL_MASTER].strip() == self.name_master and
209 | dct[NAME_COL_SERVICE].strip() == self.name_service) \
210 | or (self.name_master is None and dct[NAME_COL_SERVICE].strip() == self.name_service):
211 | for k, v in dct.items():
212 | if str(v).strip() == '':
213 | return sheet_obj.title
214 | return False
215 |
216 | worksheet_all = get_sheet_names()
217 |
218 | with ThreadPoolExecutor(2) as executor:
219 | res = executor.map(actual_date, worksheet_all)
220 | res = list(filter(lambda x: type(x) is str, res))
221 |
222 | # Кэшируем результат
223 | update_cache_days(self.name_service, self.name_master, res)
224 | return res
225 |
226 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000)
227 | def get_free_time(self) -> list:
228 | """Функция выгружает ВСЕ СВОБОДНОЕ ВРЕМЯ для определенной ДАТЫ"""
229 |
230 | try:
231 | with lock:
232 | all_val = sh.worksheet(self.date_record).get_all_records()
233 | except gspread.exceptions.WorksheetNotFound as not_found:
234 | print(not_found, self.date_record, '- Дата занята/не найдена')
235 | return []
236 |
237 | if self.date_record == datetime.now(tz=tz).strftime('%d.%m.%y'):
238 | lst = [k.strip() for i in all_val
239 | if (self.name_master is None and i[NAME_COL_SERVICE].strip() == self.name_service) or
240 | (self.name_master is not None and i[NAME_COL_SERVICE].strip() == self.name_service and
241 | i[NAME_COL_MASTER].strip() == self.name_master)
242 | for k, v in i.items() if str(v).strip() == '' and
243 | datetime.now(tz=tz).time() < datetime.strptime(k, '%H:%M').time()]
244 | else:
245 | lst = [k.strip() for i in all_val
246 | if (self.name_master is None and i[NAME_COL_SERVICE].strip() == self.name_service) or
247 | (self.name_master is not None and i[NAME_COL_SERVICE].strip() == self.name_service and
248 | i[NAME_COL_MASTER].strip() == self.name_master)
249 | for k, v in i.items() if str(v).strip() == '']
250 |
251 | if len(lst) > 0:
252 | lst = sorted(list(set(lst)))
253 | return lst
254 |
255 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000)
256 | def set_time(self, client_record='', search_criteria='') -> bool:
257 | """
258 | Производит в таблицу запись/отмену клиента
259 |
260 | :param client_record: Строка с данными клиента для записи (по умолчанию пустая строка)
261 | :param search_criteria: Критерий поиска на листе - "пустая" или "заполненная" (по умолчанию пустая строка)
262 |
263 | :return: True, если операция прошла успешно; False, если произошла ошибка при выполнении операции
264 | """
265 | try:
266 | with lock:
267 | all_val = sh.worksheet(self.date_record).get_all_records()
268 | except gspread.exceptions.WorksheetNotFound as not_found:
269 | print(not_found, self.date_record, '- Дата занята/не найдена')
270 | return False
271 |
272 | row_num = 1
273 | for i in all_val:
274 | row_num += 1
275 | col_num = 0
276 | if (self.name_master is None and i[NAME_COL_SERVICE].strip() == self.name_service) or \
277 | (self.name_master is not None and i[NAME_COL_SERVICE].strip() == self.name_service and
278 | i[NAME_COL_MASTER].strip() == self.name_master):
279 | for key_time, val_use in i.items():
280 | col_num += 1
281 | if key_time.strip() == self.time_record and val_use.strip() == search_criteria:
282 | if self.name_master is None:
283 | self.name_master = i[NAME_COL_MASTER].strip()
284 | sh.worksheet(self.date_record).update_cell(row_num, col_num, f'{client_record}')
285 | if (self.lst_records and search_criteria == '') or (self.lst_records and client_record == ''):
286 | record = [self.date_record, self.time_record, self.name_service, self.name_master]
287 | if search_criteria == '':
288 | self.lst_records.append(record)
289 | else:
290 | self.lst_records.remove(record)
291 | return True
292 | return False
293 |
294 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000)
295 | def get_record(self, client_record: str, count_days=7) -> list:
296 | """
297 | Находит все записи клиента на ближайшие дней
298 |
299 | :param client_record: строка записи клиента.
300 | :param count_days: кол-во ближайших дней для поиска.
301 |
302 | :return: Список - формата: [Дата, Время, Название услуги, Имя мастера]
303 | """
304 | if self.lst_records:
305 | return self.lst_records
306 |
307 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000)
308 | def check_record(sheet_obj) -> None:
309 | """Поиск брони клиента"""
310 | if sheet_obj.title in IGNOR_WORKSHEETS:
311 | return None
312 | try:
313 | date_sheet = datetime.strptime(sheet_obj.title, '%d.%m.%y')
314 | except Exception as ex:
315 | print(ex)
316 | print(sheet_obj.title, '- Добавьте лист в IGNOR_WORKSHEETS')
317 | return None
318 | date_today = datetime.now(tz=tz)
319 | if date_today.date() == date_sheet.date():
320 | with lock:
321 | all_val = sheet_obj.get_all_records()
322 | for dct in all_val:
323 | for k, v in dct.items():
324 | if v == client_record:
325 | try:
326 | record_time = datetime.strptime(k, '%H:%M').time()
327 | if date_today.time() < record_time:
328 | print("Запись найдена:")
329 | print([sheet_obj.title.strip(), k.strip(), dct[NAME_COL_SERVICE].strip(),
330 | dct[NAME_COL_MASTER].strip()])
331 | lst_records.append(
332 | [sheet_obj.title.strip(), k.strip(), dct[NAME_COL_SERVICE].strip(),
333 | dct[NAME_COL_MASTER].strip()]
334 | )
335 | else:
336 | # print("Пропущена (время уже прошло)")
337 | pass
338 |
339 | except Exception as e:
340 | print(f"Ошибка при разборе времени: {e}, значение ключа: {k}")
341 |
342 | elif date_today.date() < date_sheet.date() <= (date_today + timedelta(days=count_days)).date():
343 | with lock:
344 | all_val = sheet_obj.get_all_records()
345 | lst_records.extend(
346 | [sheet_obj.title.strip(), k.strip(), dct[NAME_COL_SERVICE].strip(), dct[NAME_COL_MASTER].strip()]
347 | for dct in all_val
348 | for k, v in dct.items()
349 | if v == client_record
350 | )
351 |
352 | lst_records = []
353 | with ThreadPoolExecutor(2) as executor:
354 | executor.map(check_record, sh.worksheets())
355 | self.lst_records = lst_records
356 | return lst_records
357 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | """
2 | Взаимодействие с Telegram
3 | """
4 | from datetime import datetime
5 | from telebot import types, TeleBot
6 | from telebot.types import CallbackQuery, ReplyKeyboardRemove, \
7 | ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
8 | from config import TOKEN
9 | import telebot_calendar
10 | from google_sheet import GoogleSheets, get_cache_services
11 | from keyboards import create_markup_menu, button_to_menu
12 | import clear_dict
13 |
14 | bot = TeleBot(TOKEN)
15 |
16 | CLIENT_PHONE = {467168798: '+79522600066', 288041146: '+79215528067'} # sql сделать
17 |
18 |
19 | def get_client_id(client_id, client_username) -> str:
20 | """Создаёт строку записи пользователя
21 |
22 | :param client_id: id чата/пользователя
23 | :param client_username: username пользователя
24 | :return: 'id: id @username tel: phone'"""
25 | id_client = f"id: {str(client_id)}\n@{str(client_username)}\n"
26 | if CLIENT_PHONE.get(client_id, None) is not None:
27 | if CLIENT_PHONE[client_id] != '':
28 | id_client += 'tel: ' + CLIENT_PHONE[client_id]
29 | else:
30 | id_client += 'tel: None'
31 | return id_client
32 |
33 |
34 | def create_client(chat_id) -> GoogleSheets:
35 | """
36 | Создаёт объект GoogleSheet по chat_id
37 |
38 | :chat_id: id чата/клиента
39 | """
40 | if clear_dict.CLIENT_DICT.get(chat_id):
41 | return clear_dict.CLIENT_DICT[chat_id]
42 | client = GoogleSheets(chat_id)
43 | clear_dict.CLIENT_DICT[chat_id] = client
44 | clear_dict.TIMER_DICT[chat_id] = datetime.now()
45 | return client
46 |
47 |
48 | @bot.message_handler(commands=['start'])
49 | def check_phone_number(message):
50 | """Запрашивает номер телефона у пользователя единожды"""
51 |
52 | if CLIENT_PHONE.get(message.chat.id, None) is None:
53 | markup = ReplyKeyboardMarkup(one_time_keyboard=True, resize_keyboard=True)
54 | button_phone = types.KeyboardButton(text="Отправить телефон 📞",
55 | request_contact=True)
56 | markup.add(button_phone)
57 | bot.send_message(message.chat.id, 'Для записи на услуги требуется номер телефона.',
58 | reply_markup=markup)
59 |
60 | @bot.message_handler(content_types=['contact'])
61 | def contact(message_contact):
62 | """Получает объект -> вызывает функцию стартового меню"""
63 | if message_contact.contact is not None:
64 | CLIENT_PHONE[message_contact.chat.id] = message_contact.contact.phone_number
65 | bot.send_message(message_contact.chat.id,
66 | text='Спасибо за доверие!',
67 | reply_markup=ReplyKeyboardRemove())
68 | menu(message_contact)
69 | else:
70 | menu(message)
71 |
72 |
73 | @bot.message_handler(content_types=['text'])
74 | def any_word_before_number(message_any):
75 | """Обработчик любых текстовых сообщений"""
76 | bot.send_message(message_any.chat.id,
77 | text='Пользоваться ботом возможно только при наличии номера телефона!\n'
78 | 'Взаимодействие с ботом происходит кнопками.')
79 |
80 |
81 | def menu(message):
82 | """Главное меню"""
83 | clear_dict.clear_unused_info(message.chat.id)
84 | bot.send_message(message.chat.id, "Выберите пункт меню:",
85 | reply_markup=create_markup_menu())
86 |
87 |
88 | @bot.callback_query_handler(lambda call: call.data == 'CANCEL_RECORD')
89 | def cancel_record(call):
90 | """
91 | InlineKeyboardMarkup - Выбор записи для отмены
92 | """
93 | client = create_client(call.message.chat.id)
94 | client_id = get_client_id(call.message.chat.id, call.from_user.username)
95 | records = client.get_record(client_id)
96 | if len(records) != 0:
97 | markup = InlineKeyboardMarkup(row_width=1)
98 | markup.add(
99 | *[InlineKeyboardButton(text=' - '.join(x[:3]),
100 | callback_data=f'CANCEL {ind}'
101 | ) for ind, x in enumerate(records)])
102 | markup.add(*button_to_menu(return_callback=None,
103 | menu_text='В главное меню'))
104 | bot.edit_message_text(chat_id=call.message.chat.id,
105 | message_id=call.message.message_id,
106 | text='Какую запись вы хотите отменить?🙈',
107 | reply_markup=markup
108 | )
109 | else:
110 | bot.edit_message_text(chat_id=call.message.chat.id,
111 | message_id=call.message.message_id,
112 | text='Отменять пока нечего 🤷'
113 | )
114 | check_phone_number(call.message)
115 |
116 |
117 | @bot.callback_query_handler(lambda call: call.data.startswith('CANCEL'))
118 | def approve_cancel(call):
119 | """
120 | Обработка inline callback запросов
121 | Подтверждение отмены записи
122 | """
123 | markup = InlineKeyboardMarkup(row_width=2)
124 | markup.add(*[InlineKeyboardButton(text='Подтверждаю',
125 | callback_data='APPROVE' + call.data),
126 | InlineKeyboardButton(text='В главное меню',
127 | callback_data='MENU')])
128 | bot.edit_message_text(chat_id=call.message.chat.id,
129 | message_id=call.message.message_id,
130 | text='Точно отменить?',
131 | reply_markup=markup)
132 |
133 |
134 | @bot.callback_query_handler(lambda call: call.data.startswith('APPROVE'))
135 | def set_cancel(call):
136 | """
137 | Обработка inline callback запросов
138 | Отмена записи
139 | """
140 | client = clear_dict.CLIENT_DICT.get(call.from_user.id)
141 | if client:
142 | client_info = client.lst_records[int(call.data.split()[1])]
143 | client.date_record, client.time_record, client.name_service, client.name_master = client_info
144 | client_id = get_client_id(call.message.chat.id, call.from_user.username)
145 | if client.set_time('', client_id):
146 | bot.edit_message_text(chat_id=call.message.chat.id,
147 | message_id=call.message.message_id,
148 | text='Запись отменена!')
149 | else:
150 | bot.edit_message_text(chat_id=call.message.chat.id,
151 | message_id=call.message.message_id,
152 | text='Не смог отменить запись.')
153 | check_phone_number(call.message)
154 | else:
155 | go_to_menu(call)
156 |
157 |
158 | @bot.callback_query_handler(lambda call: call.data == 'MY_RECORD')
159 | def show_record(call):
160 | """Показывает все записи клиента"""
161 | client = create_client(call.message.chat.id)
162 |
163 | client_id = get_client_id(call.message.chat.id, call.from_user.username)
164 | records = client.get_record(client_id)
165 | rec = ''
166 | if len(records) != 0:
167 | rec += 'Ближайшие записи:\n\n'
168 | for i in sorted(records, key=lambda x: (x[0], x[1], x[2])):
169 | rec += '🪷' + ' - '.join(i) + '\n'
170 | else:
171 | rec = 'Актуальных записей не найдено 🔍'
172 | bot.edit_message_text(chat_id=call.message.chat.id,
173 | message_id=call.message.message_id,
174 | text=rec
175 | )
176 | check_phone_number(call.message)
177 |
178 |
179 | @bot.callback_query_handler(lambda call: call.data == 'RECORD')
180 | def choice_service(call):
181 | """
182 | InlineKeyboardMarkup
183 | Выбор услуги для записи
184 | """
185 | create_client(call.message.chat.id)
186 |
187 | all_serv = get_cache_services()
188 | markup = InlineKeyboardMarkup(row_width=3)
189 | markup.add(*[InlineKeyboardButton(text=x,
190 | callback_data='SERVICE' + x
191 | ) for x in all_serv.keys()])
192 | markup.add(*button_to_menu(None))
193 | bot.edit_message_text(chat_id=call.message.chat.id,
194 | message_id=call.message.message_id,
195 | text="Выбери услугу:",
196 | reply_markup=markup)
197 |
198 |
199 | @bot.callback_query_handler(func=lambda call: call.data.startswith('SERVICE'))
200 | def choice_master(call):
201 | """
202 | Обработка inline callback запросов
203 | Выбор мастера
204 | """
205 | client = clear_dict.CLIENT_DICT.get(call.from_user.id)
206 | if client:
207 | client.name_service = call.data[len('SERVICE'):]
208 | dct = get_cache_services()
209 | markup = InlineKeyboardMarkup(row_width=2)
210 | markup.add(*[InlineKeyboardButton(text=x,
211 | callback_data='MASTER' + x
212 | ) for x in dct[client.name_service]])
213 | markup.add(InlineKeyboardButton(text='Любой мастер',
214 | callback_data='MASTER' + 'ЛЮБОЙ'))
215 | markup.add(*button_to_menu('RECORD'))
216 | bot.edit_message_text(chat_id=call.message.chat.id,
217 | message_id=call.message.message_id,
218 | text="Выбери Мастера:",
219 | reply_markup=markup)
220 | else:
221 | go_to_menu(call)
222 |
223 |
224 | @bot.callback_query_handler(func=lambda call: call.data.startswith('MASTER'))
225 | def choice_date(call):
226 | """
227 | Обработка inline callback запросов
228 | Выбор даты
229 | """
230 | client = clear_dict.CLIENT_DICT.get(call.from_user.id)
231 | if client:
232 | if call.data[len('MASTER'):] != 'ЛЮБОЙ':
233 | client.name_master = call.data[len('MASTER'):]
234 | else:
235 | client.name_master = None
236 | lst = client.get_all_days()
237 | lst = list(map(lambda x: datetime.strptime(x, '%d.%m.%y').date(), lst))
238 | if len(lst) == 0:
239 | service = client.name_service if client.name_service else 'ЛЮБОЙ'
240 | markup = InlineKeyboardMarkup(row_width=2)
241 | markup.add(*button_to_menu('SERVICE' + service))
242 | bot.edit_message_text(chat_id=call.message.chat.id,
243 | message_id=call.message.message_id,
244 | text="Для выбранного мастера нет доступных дат!\n"
245 | "Попробуй другого мастера😉",
246 | reply_markup=markup)
247 | else:
248 | client.lst_currant_date = lst
249 | clear_dict.CALENDAR_DICT[call.message.chat.id] = str(call.message.chat.id)
250 | bot.edit_message_text(chat_id=call.from_user.id,
251 | message_id=call.message.message_id,
252 | text='Выбери доступную дату:\n ✅ - есть свободное время',
253 | reply_markup=telebot_calendar.create_calendar(
254 | name='CALENDAR' + clear_dict.CALENDAR_DICT[call.message.chat.id],
255 | lst_current_date=lst)
256 | )
257 | else:
258 | go_to_menu(call)
259 |
260 |
261 | @bot.callback_query_handler(func=lambda call: call.data.startswith('CALENDAR'))
262 | def choice_time(call: CallbackQuery):
263 | """
264 | Обработка inline callback запросов
265 | Выбор времени
266 | """
267 | client = clear_dict.CLIENT_DICT.get(call.from_user.id)
268 | if client:
269 | lst = client.lst_currant_date
270 | # At this point, we are sure that this calendar is ours. So we cut the line by the separator of our calendar
271 | name, action, year, month, day = call.data.split(':')
272 | # Processing the calendar. Get either the date or None if the buttons are of a different type
273 | telebot_calendar.calendar_query_handler(
274 | bot=bot, call=call, name=name, action=action, year=year, month=month, day=day,
275 | lst_currant_date=lst
276 | )
277 |
278 | if action == "DAY":
279 | client.date_record = datetime(int(year), int(month), int(day)).strftime('%d.%m.%y')
280 | lst_times = client.get_free_time()
281 | client.dct_currant_time = lst_times
282 |
283 | markup = InlineKeyboardMarkup(row_width=3)
284 | markup.add(*[InlineKeyboardButton(text=x,
285 | callback_data='TIME' + x
286 | ) for x in lst_times])
287 | master = 'MASTER' + (client.name_master if client.name_master else 'ЛЮБОЙ')
288 | markup.add(*button_to_menu(master))
289 | text = "Выберите время:" if len(lst_times) != 0 else "Для выбранного даты нет доступного времени!\n" \
290 | "Попробуй другую дату😉"
291 | bot.delete_message(chat_id=call.message.chat.id,
292 | message_id=call.message.message_id)
293 | bot.send_message(
294 | chat_id=call.from_user.id,
295 | text=text,
296 | reply_markup=markup
297 | )
298 |
299 | elif action == "MENU":
300 | go_to_menu(call)
301 | elif action == "RETURN":
302 | call.data = 'SERVICE' + client.name_service
303 | choice_master(call)
304 | else:
305 | go_to_menu(call)
306 |
307 |
308 | @bot.callback_query_handler(lambda call: call.data.startswith('TIME'))
309 | def approve_record(call):
310 | """
311 | Обработка inline callback запросов
312 | Подтверждение отмены записи
313 | """
314 | client = clear_dict.CLIENT_DICT.get(call.from_user.id)
315 |
316 | if client:
317 | client.time_record = call.data[len('TIME'):]
318 | id_calendar = clear_dict.CALENDAR_DICT[call.from_user.id]
319 | date_string = client.date_record
320 | date_object = datetime.strptime(date_string, '%d.%m.%y')
321 | formatted_date = date_object.strftime('%Y:') + str(date_object.month) + ':' + str(date_object.day)
322 | name_calendar = 'CALENDAR' + id_calendar + ':DAY:' + formatted_date
323 |
324 | markup = InlineKeyboardMarkup(row_width=2)
325 | markup.add(InlineKeyboardButton(text='Подтверждаю',
326 | callback_data='APP_REC'))
327 | markup.add(*button_to_menu(name_calendar))
328 | bot.edit_message_text(chat_id=call.message.chat.id,
329 | message_id=call.message.message_id,
330 | text=f'Проверьте данные записи:\n\n'
331 | f'🛎️ Услуга: {client.name_service}\n'
332 | f'👤 Мастер: {client.name_master if client.name_master else "Любой"}\n'
333 | f'📅 Дата: {client.date_record}\n'
334 | f'🕓 Время: {client.time_record}',
335 | reply_markup=markup)
336 | else:
337 | go_to_menu(call)
338 |
339 |
340 | @bot.callback_query_handler(func=lambda call: call.data.startswith('APP_REC'))
341 | def set_time(call):
342 | """
343 | Обработка inline callback запросов
344 | Выбор времени
345 | """
346 | client = clear_dict.CLIENT_DICT.get(call.from_user.id)
347 | if client:
348 | id_client = get_client_id(call.message.chat.id, call.from_user.username)
349 | if client.set_time(id_client):
350 | bot.edit_message_text(chat_id=call.from_user.id,
351 | message_id=call.message.message_id,
352 | text=f'Успешно записал вас!\n\n'
353 | f'🛎️ Услуга: {client.name_service}\n'
354 | f'👤 Мастер: {client.name_master if client.name_master else "Любой"}\n'
355 | f'📅 Дата: {client.date_record}\n'
356 | f'🕓 Время: {client.time_record}',
357 | )
358 | check_phone_number(call.message)
359 | else:
360 | bot.send_message(call.message.chat.id, 'Время кто-то забронировал...\nПопробуй другое!')
361 | else:
362 | go_to_menu(call)
363 |
364 |
365 | @bot.callback_query_handler(func=lambda call: call.data == 'MENU')
366 | def go_to_menu(call):
367 | """Возвращает в главное меню"""
368 | bot.delete_message(chat_id=call.message.chat.id,
369 | message_id=call.message.message_id)
370 | check_phone_number(call.message)
371 |
372 |
373 | bot.infinity_polling()
374 |
--------------------------------------------------------------------------------