init commit docbot

This commit is contained in:
oleg.vodyanov91@gmail.com 2025-06-01 03:38:30 +04:00
commit c977d350f4
29 changed files with 2262 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.env
.env_db
.idea
.venv
alembic.ini
alembic/
db/
__pycache__

0
README.md Normal file
View File

11
docbot.iml Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
services:
db:
image: postgres:17
restart: unless-stopped
env_file:
- .env_db
volumes:
- db_data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
ports:
- 5432:5432
volumes:
db_data:

1429
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

40
pyproject.toml Normal file
View File

@ -0,0 +1,40 @@
[project]
name = "docbot"
version = "0.1.0"
description = ""
authors = [
{name = "oleg.vodyanov",email = "oleg.vodyanov91@gmail.com"}
]
readme = "README.md"
requires-python = ">=3.9"
[tool.poetry]
packages = [{include = "docbot", from = "src"}]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.dependencies]
python = "^3.12"
python-telegram-bot = "22.1" # или aiogram v3
fastapi = "0.115.12"
uvicorn = {extras = ["standard"], version = "0.34.2"}
sqlalchemy = "2.0.41"
asyncpg = "0.30.0" # асинхронный драйвер для Postgres
alembic = "1.16.1"
pydantic = "2.11.5"
pydantic-settings = "2.9.1"
python-dotenv = "1.1.0"
greenlet = "3.2.2"
psycopg2-binary = "2.9.10"
alembic_utils = "0.8.8"
alembic-postgresql-enum = "1.7.0"
[tool.poetry.group.dev.dependencies]
pytest = "8.2"
pytest-asyncio = "1.0.0"
flake8 = "7.2.0"
black = "25.1.0"

0
src/core/__init__.py Normal file
View File

12
src/core/config.py Normal file
View File

@ -0,0 +1,12 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='../../.env', env_file_encoding='utf-8')
BOT_TOKEN: str
DATABASE_URL: str
ADMIN_API_KEY: str
settings = Settings()

View File

@ -0,0 +1,9 @@
from enum import Enum
class Consultation(str, Enum):
ASYNC_TEXT = "Асинхронный текст (без переписки)"
ASYNC_TEXT_WITH_DIALOG = "Асинхронный текст с перепиской"
ONLINE_CHAT = "Онлайн-чат (анонимный, в реальном времени)"
AUDION_CALL = "Аудиозвонок через внешнюю платформу"
VIDEO_CALL = "Видеозвонок через внешнюю платформу"

56
src/core/logging.py Normal file
View File

@ -0,0 +1,56 @@
import logging
import sys
from logging.handlers import RotatingFileHandler
from core.config import settings
def setup_logging():
"""
Инициализирует логирование для всего приложения.
Конфигурирует консольный и файловый логгеры с ротацией.
"""
# Получаем уровень логирования из настроек, по умолчанию INFO
level_name = settings.log_level.upper() if hasattr(settings, 'log_level') else 'INFO'
log_level = getattr(logging, level_name, logging.INFO)
# Форматтер для всех хэндлеров
formatter = logging.Formatter(
fmt="%(asctime)s %(levelname)s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
# Консольный хэндлер
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(log_level)
console_handler.setFormatter(formatter)
# Собираем хэндлеры
handlers = [console_handler]
# Файловый хэндлер с ротацией (если указан путь в настройках)
if hasattr(settings, 'log_file_path') and settings.log_file_path:
file_handler = RotatingFileHandler(
filename=settings.log_file_path,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8"
)
file_handler.setLevel(log_level)
file_handler.setFormatter(formatter)
handlers.append(file_handler)
# Конфигурируем корневой логгер
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
# Отключаем стандартные хэндлеры, чтобы не дублировалось
root_logger.handlers.clear()
for handler in handlers:
root_logger.addHandler(handler)
# Автоматически запускаем настройку логирования при импорте модуля
setup_logging()
# Получаем logger для текущего модуля
logger = logging.getLogger(__name__)

0
src/docbot/__init__.py Normal file
View File

Binary file not shown.

View File

@ -0,0 +1,10 @@
from telegram import Update
from telegram.ext import (
ContextTypes,
ConversationHandler,
CommandHandler,
MessageHandler,
filters,
)

View File

@ -0,0 +1,16 @@
from telegram import Update
from telegram.ext import (
ContextTypes,
ConversationHandler,
CommandHandler,
)
async def __cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text("❌ Отменено.")
return ConversationHandler.END
def get_cancel_handler() -> CommandHandler:
"""Фабрика для регистрации в Application."""
return CommandHandler("cancel", __cancel)

View File

@ -0,0 +1,40 @@
from telegram import Update
from telegram.ext import (
ContextTypes,
ConversationHandler,
CommandHandler,
)
from docbot.services.doctors_service import get_doctors_names
from docbot.services.admins_service import get_admin_info
async def get_doctors(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
user_id = update.effective_user.id
# проверяем, нет ли уже незавершённой сессии
admin = await get_admin_info(user_id)
if not admin:
await update.message.reply_text(
"⚠️ Вы не можете смотреть информацию о врачах!",
parse_mode="Markdown"
)
return ConversationHandler.END
doctors = await get_doctors_names()
if doctors:
await update.message.reply_text(
f"📝 Список активных врачей. \n {doctors}",
parse_mode="Markdown"
)
return ConversationHandler.END
else:
await update.message.reply_text(
"📝 Список врачей пуст! Никто не зарегистрировался 🥺"
)
return ConversationHandler.END
def get_doctors_handler() -> CommandHandler:
"""Фабрика для регистрации в Application."""
return CommandHandler("doctors", get_doctors)

View File

@ -0,0 +1,34 @@
from telegram import Update
from telegram.ext import ContextTypes, CommandHandler
from core.logging import logger
from docbot.services.admins_service import get_admin_info
from docbot.services.referral_service import generate_referral_code
async def genref(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
Команда /genref Генерация нового реферального кода.
Доступна только администратору чата
"""
user_id = update.effective_user.id
if not await get_admin_info(user_id):
logger.warning(f"Пользователь {user_id} пытался сгенерировать код без прав.")
await update.message.reply_text("У вас нет прав для этой команды.")
return
# Генерируем новый код и сохраняем его
token = await generate_referral_code()
await update.message.reply_text(
f"✅ Новый реферальный код: `{token}`\n"
"Передайте этот код врачу. Он действует только один раз.",
parse_mode="Markdown"
)
logger.info(f"Admin {user_id} сгенерировал реферальный код {token}.")
def get_referral_handlers():
"""
Возвращает список готовых CommandHandler-ов для регистрации в Application.
"""
return CommandHandler("genref", genref)

View File

@ -0,0 +1,69 @@
from telegram import Update
from telegram.ext import (
ContextTypes,
ConversationHandler,
CommandHandler,
MessageHandler,
filters,
)
from docbot.services.session_service import create_session_code, get_pending_session
from docbot.handlers.cancel_handler import get_cancel_handler
ASK_LINK = 1
async def ask_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
user_id = update.effective_user.id
# проверяем, нет ли уже незавершённой сессии
pending = await get_pending_session(user_id)
if pending:
await update.message.reply_text(
"⚠️ У вас уже есть активный код для консультации: "
f"*{pending.code}*\n"
"Пожалуйста, дождитесь, пока врач отметит получение консультации, "
"и только потом создавайте новую сессию.",
parse_mode="Markdown"
)
return ConversationHandler.END
# если нет — продолжаем и запрашиваем ссылку
await update.message.reply_text(
"📝 Пожалуйста, пришлите ссылку на вашу анкету в формате URL."
)
return ASK_LINK
async def receive_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
link = update.message.text.strip()
user_id = update.effective_user.id
# вызываем сервис, который создаст код сессии и запишет в БД
code = await create_session_code(telegram_id=user_id, form_link=link)
await update.message.reply_text(
f"Ваш код для консультации: *{code}*\n"
"Сохраните его и передайте врачу.",
parse_mode="Markdown"
)
return ConversationHandler.END
def send_form_link_handler() -> CommandHandler:
"""Фабрика для регистрации в Application."""
return CommandHandler("get_form", ask_link)
def get_get_form_handler() -> ConversationHandler:
return ConversationHandler(
entry_points=[send_form_link_handler()],
states={
ASK_LINK: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_link)
],
},
fallbacks=[get_cancel_handler()],
name="form_conversation", # для тестов/логирования
persistent=True, # если используете хранение состояний
)

View File

@ -0,0 +1,62 @@
from telegram import Update, ReplyKeyboardMarkup
from telegram.ext import ContextTypes, CommandHandler
from core.config import settings
from docbot.services.referral_service import ReferralService
from docbot.services.admins_service import get_admin_info
from docbot.services.doctors_service import get_doctor_info
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
Отдаёт список команд, доступных текущему пользователю:
- админ
- зарегистрированный доктор
- пациент (все остальные)
"""
user_id = update.effective_user.id
# 1) Проверим, админ ли это
if await get_admin_info(user_id):
text = (
"🔐 *Админ-меню*:\n\n"
"/start Запустить бота\n"
"/genref Сгенерировать новый реферальный код для врача\n"
"/doctors Посмотреть список зарегистрированных врачей\n"
"/help Показать это меню\n"
)
# (При необходимости в будущем можно добавить другие admin-команды)
await update.message.reply_text(text, parse_mode="Markdown")
return
# 2) Проверим, зарегистрирован ли врач
if await get_doctor_info(user_id):
text = (
"👨‍⚕️ *Меню доктора*:\n\n"
"/start Запустить бота\n"
"/sessions Посмотреть список пациентов (или текущих сессий)\n"
"/markconsulted <CODE> Отметить, что пациент с кодом <CODE> получил консультацию\n"
"/help Показать это меню\n"
)
# Можно предлагать докторам также специальные кнопки:
keyboard = ReplyKeyboardMarkup(
[["/sessions"], ["/markconsulted <CODE>"], ["/help"]],
one_time_keyboard=True,
resize_keyboard=True
)
await update.message.reply_text(text, parse_mode="Markdown", reply_markup=keyboard)
return
# 3) Иначе — это пациент (незарегистрированный/обычный пользователь)
text = (
"👤 *Меню пациента*:\n\n"
"/start Запустить бота\n"
"/get_form Получить ссылку на форму анкеты для заполнения"
"/send_form Отправить ссылку на заполненную анкету\n"
"/help Показать это меню\n"
)
keyboard = ReplyKeyboardMarkup(
[["/form", "/consult"], ["/help"]],
one_time_keyboard=True,
resize_keyboard=True
)
await update.message.reply_text(text, parse_mode="Markdown", reply_markup=keyboard)

View File

@ -0,0 +1,103 @@
from telegram import Update, ReplyKeyboardMarkup, ReplyKeyboardRemove
from telegram.ext import (
ContextTypes,
ConversationHandler,
CommandHandler,
MessageHandler,
filters,
)
from docbot.handlers.cancel_handler import get_cancel_handler
from docbot.services.referral_service import validate_referral_code, mark_referral_code_as_used
from core.enums.consultation_types import Consultation
ASK_REFERRAL_CODE = 1
ASK_NAME = 2
ASK_CONSULTATION_TYPE = 3
async def ask_doctor_referral_code(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text(
"📝 Пожалуйста, пришлите реферальный код.",
parse_mode="Markdown"
)
return ASK_REFERRAL_CODE
async def receive_doctor_referral_code(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
doctor_referral_code = update.message.text.strip()
ref_obj = await validate_referral_code(doctor_referral_code)
if not ref_obj:
return ConversationHandler.END
doctor_telegram_id = update.effective_user.id
context.user_data["doctor_telegram_id"] = doctor_telegram_id
context.user_data["ref_obj"] = ref_obj
await update.message.reply_text(
"📝 Пожалуйста, пришлите своё имя или псевдоним.",
parse_mode="Markdown"
)
return ASK_NAME
async def receive_doctor_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
doctor_name = update.message.text.strip()
context.user_data["doctor_name"] = doctor_name
reply_keyboard = [
[
Consultation.ASYNC_TEXT.value,
Consultation.ASYNC_TEXT_WITH_DIALOG.value,
Consultation.ONLINE_CHAT.value,
Consultation.AUDION_CALL.value,
Consultation.VIDEO_CALL.value
]
]
await update.message.reply_text(
f"Пожалуйста, выберите тип консультации.",
parse_mode="Markdown",
reply_markup=ReplyKeyboardMarkup(
reply_keyboard, one_time_keyboard=True, input_field_placeholder="Тип консультации"
),
)
return ASK_CONSULTATION_TYPE
async def receive_doctor_consultation_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
doctor_consultation_type = update.message.text.strip()
await update.message.reply_text(
"📝 Спасибо, сохраняю информацию.",
parse_mode="Markdown",
reply_markup=ReplyKeyboardRemove()
)
await mark_referral_code_as_used(context.user_data["ref_obj"], context.user_data["doctor_telegram_id"],
context.user_data["doctor_name"], Consultation(doctor_consultation_type))
return ConversationHandler.END
def ask_doctor_info_handler() -> CommandHandler:
"""Фабрика для регистрации в Application."""
return CommandHandler("register", ask_doctor_referral_code)
def get_register_doctor_handler() -> ConversationHandler:
return ConversationHandler(
entry_points=[ask_doctor_info_handler()],
states={
ASK_REFERRAL_CODE: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_referral_code)
],
ASK_NAME: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_name)
],
ASK_CONSULTATION_TYPE: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_consultation_type)
],
},
fallbacks=[get_cancel_handler()],
name="register_doctor_conversation", # для тестов/логирования
persistent=True, # если используете хранение состояний
)

View File

@ -0,0 +1,69 @@
from telegram import Update
from telegram.ext import (
ContextTypes,
ConversationHandler,
CommandHandler,
MessageHandler,
filters,
)
from docbot.services.session_service import create_session_code, get_pending_session
from docbot.handlers.cancel_handler import get_cancel_handler
ASK_LINK = 1
async def ask_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
user_id = update.effective_user.id
# проверяем, нет ли уже незавершённой сессии
pending = await get_pending_session(user_id)
if pending:
await update.message.reply_text(
"⚠️ У вас уже есть активный код для консультации: "
f"*{pending.code}*\n"
"Пожалуйста, дождитесь, пока врач отметит получение консультации, "
"и только потом создавайте новую сессию.",
parse_mode="Markdown"
)
return ConversationHandler.END
# если нет — продолжаем и запрашиваем ссылку
await update.message.reply_text(
"📝 Пожалуйста, пришлите ссылку на вашу анкету в формате URL."
)
return ASK_LINK
async def receive_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
link = update.message.text.strip()
user_id = update.effective_user.id
# вызываем сервис, который создаст код сессии и запишет в БД
code = await create_session_code(telegram_id=user_id, form_link=link)
await update.message.reply_text(
f"Ваш код для консультации: *{code}*\n"
"Сохраните его и передайте врачу.",
parse_mode="Markdown"
)
return ConversationHandler.END
def ask_link_handler() -> CommandHandler:
"""Фабрика для регистрации в Application."""
return CommandHandler("send_form", ask_link)
def get_send_form_handler() -> ConversationHandler:
return ConversationHandler(
entry_points=[ask_link_handler()],
states={
ASK_LINK: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_link)
],
},
fallbacks=[get_cancel_handler()],
name="send_form_conversation", # для тестов/логирования
persistent=True, # если используете хранение состояний
)

View File

@ -0,0 +1,22 @@
from telegram import Update
from telegram.ext import ContextTypes, CommandHandler
from core.logging import logger
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
Отправляет приветствие и краткую инструкцию по работе с ботом.
"""
text = (
"👋 Добро пожаловать в DocBot!\n\n"
"Используйте команду /form чтобы прислать ссылку на анкету, если вы пациент.\n"
"После заполнения анкеты мы пришлем код для консультации врача.\n"
"Используйте команду /register, если у вас есть реферальный код."
)
await context.bot.send_message(chat_id=update.effective_chat.id, text=text)
def get_start_handler() -> CommandHandler:
"""Фабрика для регистрации в Application."""
return CommandHandler("start", start)

View File

@ -0,0 +1,18 @@
from telegram import Update
from telegram.ext import ContextTypes, CommandHandler, MessageHandler, filters
async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
Срабатывает на любую команду `/something`,
если нет соответствующего CommandHandler-а выше.
"""
await update.message.reply_text(
"❓ Извините, я не знаю такой команды. "
"Попробуйте /start."
)
def get_unknown_handler() -> MessageHandler:
"""Фабрика для регистрации в Application."""
return MessageHandler(filters.COMMAND, unknown_command)

36
src/docbot/main.py Normal file
View File

@ -0,0 +1,36 @@
from core.logging import logger
from telegram.ext import ApplicationBuilder, PicklePersistence, ExtBot
from core.config import settings
from docbot.handlers.start_handler import get_start_handler
from docbot.handlers.send_form_handler import get_send_form_handler
from docbot.handlers.doctors_handler import get_doctors_handler
from docbot.handlers.register_handler import get_register_doctor_handler
from docbot.handlers.generate_ref import get_referral_handlers
from docbot.handlers.unknown import get_unknown_handler
def main():
persistence = PicklePersistence(filepath="conversations.pkl")
logger.info("Запуск DocBot…")
bot_instance = ExtBot(token=settings.BOT_TOKEN)
app = (
ApplicationBuilder()
.bot(bot_instance) # явно передаём наш Bot
.persistence(persistence)
.concurrent_updates(concurrent_updates=False)
.build()
)
app.add_handler(get_start_handler())
app.add_handler(get_send_form_handler())
app.add_handler(get_doctors_handler())
app.add_handler(get_register_doctor_handler())
app.add_handler(get_referral_handlers())
app.add_handler(get_unknown_handler())
logger.debug("Все хэндлеры зарегистрированы, запускаем polling")
app.run_polling()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,14 @@
from __future__ import annotations
from sqlalchemy import select
from db.session import AsyncSessionLocal
from db.models import Admins
async def get_admin_info(telegram_id: int) -> Admins | None:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Admins.telegram_id)
.where(Admins.telegram_id.match(str(telegram_id)))
)
return result.scalar_one_or_none()

View File

@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from db.session import AsyncSessionLocal
from db.models import Doctors
from core.enums.consultation_types import Consultation
async def get_doctor_info(telegram_id: int) -> Doctors | None:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Doctors)
.where(Doctors.telegram_id.match(telegram_id))
)
return result.scalar_one_or_none()
async def get_doctors_names() -> Doctors | None:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Doctors.name)
.where(Doctors.is_active)
)
return result.all()
async def add_doctor(telegram_id: str, name: str, available_formats: Consultation, is_active: bool):
async with AsyncSessionLocal() as session:
async with session.begin():
session.add(Doctors(
telegram_id=str(telegram_id),
name=name,
available_formats=available_formats,
is_active=is_active,
created_at=datetime.utcnow()
))

View File

View File

@ -0,0 +1,73 @@
import secrets
import string
from datetime import datetime
from sqlalchemy import select
from db.session import AsyncSessionLocal
from db.models import ReferralCode, Doctors
from core.enums.consultation_types import Consultation
async def generate_referral_code(length: int = 12) -> str:
"""
Генерирует уникальный реферальный код длиной length.
Сохраняет его в БД и возвращает строку кода.
"""
alphabet = string.ascii_uppercase + string.digits
while True:
referral_code = "".join(secrets.choice(alphabet) for _ in range(length))
async with AsyncSessionLocal() as session:
result = await session.execute(
select(ReferralCode)
.where(ReferralCode.code.match(referral_code))
)
exists = result.scalar_one_or_none()
if not exists:
new_ref = ReferralCode(code=referral_code, created_at=datetime.utcnow())
session.add(new_ref)
await session.commit()
return referral_code
# если сгенерированный код уже был в БД, пробуем снова
async def validate_referral_code(referral_code: str) -> ReferralCode | None:
"""
Проверяет, есть ли в БД неиспользованный реферальный код referral_code.
Если да, возвращает объект ReferralCode, иначе None.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(ReferralCode)
.where(ReferralCode.code.match(referral_code),
ReferralCode.is_used.is_(False)
)
)
return result.scalar_one_or_none()
async def mark_referral_code_as_used(referral_obj: ReferralCode, telegram_id: str, name: str,
available_formats: Consultation) -> Doctors:
"""
Помечает referral_obj как использованный, создаёт запись в таблице Doctors
с привязкой к telegram_id и возвращает объект Doctor.
"""
async with AsyncSessionLocal() as session:
async with session.begin():
# помечаем код
referral_obj.is_used = True
referral_obj.used_at = datetime.utcnow()
# создаём врача
new_doc = Doctors(
telegram_id=str(telegram_id),
name=name,
available_formats=[Consultation(available_formats)],
is_active=True,
created_at=datetime.utcnow(),
referral=referral_obj
)
session.add(new_doc)
await session.commit()
return new_doc

View File

@ -0,0 +1,79 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import select
from db.session import AsyncSessionLocal
from db.models import SessionCode
async def create_session_code(telegram_id: int, form_link: str) -> str:
"""
Генерирует уникальный код, сохраняет его вместе с Telegram ID и ссылкой на анкету.
Возвращает этот код.
"""
code = uuid.uuid4().hex[:8]
async with AsyncSessionLocal() as session: # безопасно создаём сессию
async with session.begin():
session.add(SessionCode(
code=code,
telegram_id=str(telegram_id), # храним как строку
form_link=form_link,
sent_at=datetime.utcnow()
))
return code
async def mark_consulted(code: str) -> bool:
"""
Отмечает, что консультация получена (добавляет consulted_at).
Возвращает True, если запись найдена и обновлена.
"""
async with AsyncSessionLocal() as session:
async with session.begin():
result = await session.execute(
select(SessionCode).where(SessionCode.code.match(code))
)
sc: SessionCode | None = result.scalar_one_or_none()
if not sc:
return False
sc.consulted_at = datetime.utcnow()
return True
async def get_pending_session(telegram_id: int) -> SessionCode | None:
"""
Ищет самую «свежую» сессию по telegram_id, где consulted_at ещё не заполнен.
Вернёт объект SessionCode или None.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SessionCode)
.where(SessionCode.telegram_id.match(str(telegram_id)))
.where(SessionCode.consulted_at.is_(None))
.order_by(SessionCode.sent_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_session_info(code: str) -> dict | None:
"""
Возвращает словарь с информацией по коду:
telegram_id, form_link, sent_at, consulted_at.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SessionCode).where(SessionCode.code.match(code))
)
sc: SessionCode | None = result.scalar_one_or_none()
if not sc:
return None
return {
"telegram_id": sc.telegram_id,
"form_link": sc.form_link,
"sent_at": sc.sent_at,
"consulted_at": sc.consulted_at,
}

0
tests/__init__.py Normal file
View File