change bot logic

This commit is contained in:
o.vodianov 2025-07-27 14:33:36 +04:00
parent 9d6b772777
commit e9dc210786
17 changed files with 594 additions and 84 deletions

3
.gitignore vendored
View File

@ -9,3 +9,6 @@ db/
__pycache__
src/docbot/conversations.pkl
conversations.pkl
*.jpg
*.png
*.jpeg

4
ai_example.py Normal file
View File

@ -0,0 +1,4 @@
from core.ai.ai_bot import detect_eyes
detect_eyes("/Users/o.vodianov/IdeaProjects/docbot/image.png")

161
poetry.lock generated
View File

@ -20,6 +20,41 @@ typing-extensions = ">=4.12"
[package.extras]
tz = ["tzdata"]
[[package]]
name = "alembic-postgresql-enum"
version = "1.7.0"
description = "Alembic autogenerate support for creation, alteration and deletion of enums"
optional = false
python-versions = "<4.0,>=3.7"
groups = ["main"]
files = [
{file = "alembic_postgresql_enum-1.7.0-py3-none-any.whl", hash = "sha256:2a64260f3f1ed96f1bf984f55cb838e5d915693b8478b7b6ffea13cc09184ae0"},
{file = "alembic_postgresql_enum-1.7.0.tar.gz", hash = "sha256:1a12a2b25f3f49440f419821888530496511573875438b6e9a08cbcb8a6f006f"},
]
[package.dependencies]
alembic = ">=1.7"
SQLAlchemy = ">=1.4"
[[package]]
name = "alembic-utils"
version = "0.8.8"
description = "A sqlalchemy/alembic extension for migrating procedures and views"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "alembic_utils-0.8.8-py3-none-any.whl", hash = "sha256:2c2545dc545833c5deb63bce2c3cde01c1807bf99da5efab2497bc8d817cb86e"},
{file = "alembic_utils-0.8.8.tar.gz", hash = "sha256:99de5d13194f26536bc0322f0c1660020a305015700d8447ccfc20e7d1494e5b"},
]
[package.dependencies]
alembic = ">=1.9"
flupy = "*"
parse = ">=1.8.4"
sqlalchemy = ">=1.4"
typing_extensions = ">=0.1.0"
[[package]]
name = "annotated-types"
version = "0.7.0"
@ -241,6 +276,21 @@ mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.13.0,<2.14.0"
pyflakes = ">=3.3.0,<3.4.0"
[[package]]
name = "flupy"
version = "1.2.3"
description = "Fluent data processing in Python - a chainable stream processing library for expressive data manipulation using method chaining"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "flupy-1.2.3-py3-none-any.whl", hash = "sha256:be0f5a393bad2b3534697fbab17081993cd3f5817169dd3a61e8b2e0887612e6"},
{file = "flupy-1.2.3.tar.gz", hash = "sha256:220b6d40dea238cd2d66784c0d4d2a5483447a48acd343385768e0c740af9609"},
]
[package.dependencies]
typing_extensions = ">=4"
[[package]]
name = "greenlet"
version = "3.2.2"
@ -567,6 +617,91 @@ files = [
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "numpy"
version = "2.2.6"
description = "Fundamental package for array computing in Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"},
{file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"},
{file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"},
{file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"},
{file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"},
{file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"},
{file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"},
{file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"},
{file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"},
{file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"},
{file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"},
{file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"},
{file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"},
{file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"},
{file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"},
{file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"},
{file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"},
{file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"},
{file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"},
{file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"},
{file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"},
{file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"},
{file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"},
{file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"},
{file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"},
{file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"},
{file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"},
{file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"},
{file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"},
{file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"},
{file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"},
{file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"},
{file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"},
{file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"},
{file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"},
{file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"},
{file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"},
{file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"},
{file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"},
{file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"},
{file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"},
{file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"},
{file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"},
{file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"},
{file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"},
{file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"},
{file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"},
{file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"},
{file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"},
{file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"},
{file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"},
{file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"},
{file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"},
{file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"},
{file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"},
]
[[package]]
name = "opencv-python"
version = "4.12.0.88"
description = "Wrapper package for OpenCV python bindings."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "opencv-python-4.12.0.88.tar.gz", hash = "sha256:8b738389cede219405f6f3880b851efa3415ccd674752219377353f017d2994d"},
{file = "opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:f9a1f08883257b95a5764bf517a32d75aec325319c8ed0f89739a57fae9e92a5"},
{file = "opencv_python-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:812eb116ad2b4de43ee116fcd8991c3a687f099ada0b04e68f64899c09448e81"},
{file = "opencv_python-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:51fd981c7df6af3e8f70b1556696b05224c4e6b6777bdd2a46b3d4fb09de1a92"},
{file = "opencv_python-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:092c16da4c5a163a818f120c22c5e4a2f96e0db4f24e659c701f1fe629a690f9"},
{file = "opencv_python-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:ff554d3f725b39878ac6a2e1fa232ec509c36130927afc18a1719ebf4fbf4357"},
{file = "opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2"},
]
[package.dependencies]
numpy = {version = ">=2,<2.3.0", markers = "python_version >= \"3.9\""}
[[package]]
name = "packaging"
version = "25.0"
@ -579,6 +714,18 @@ files = [
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "parse"
version = "1.20.2"
description = "parse() is the opposite of format()"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558"},
{file = "parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
@ -965,6 +1112,18 @@ rate-limiter = ["aiolimiter (>=1.1,<1.3)"]
socks = ["httpx[socks]"]
webhooks = ["tornado (>=6.4,<7.0)"]
[[package]]
name = "pytz"
version = "2025.2"
description = "World timezone definitions, modern and historical"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"},
{file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"},
]
[[package]]
name = "pyyaml"
version = "6.0.2"
@ -1426,4 +1585,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = "^3.12"
content-hash = "004653e87a533d6d6e4471fd4a57d2ff49110aa6adaf4ba590394fcaf0aef46b"
content-hash = "c50f083c47c32cf0ca58d1ccfec01aec63db5dc07be39cac76bb1e792c255951"

View File

@ -31,6 +31,8 @@ greenlet = "3.2.2"
psycopg2-binary = "2.9.10"
alembic_utils = "0.8.8"
alembic-postgresql-enum = "1.7.0"
pytz = "2025.2"
opencv-python = "4.12.0.88"
[tool.poetry.group.dev.dependencies]

28
src/core/ai/ai_bot.py Normal file
View File

@ -0,0 +1,28 @@
import cv2
def detect_eyes(image: str) -> None:
# Load the image
image = cv2.imread(image)
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# Detect faces in the grayscale image
eye_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_eye.xml')
eyes = eye_cascade.detectMultiScale(gray, scaleFactor=1.3, minNeighbors=5)
for (ex, ey, ew, eh) in eyes:
# Extract the eye region
eye_roi = image[ey:ey+eh, ex:ex+ew]
# Apply a Gaussian blur to the eye region
# Kernel size must be odd, e.g., (25, 25)
blurred_eye_roi = cv2.GaussianBlur(eye_roi, (599, 599), 50)
# Replace the original eye region with the blurred one
image[ey:ey+eh, ex:ex+ew] = blurred_eye_roi
output_path = 'blurred_eyes_image.jpg' # Name for the output image
cv2.imwrite(output_path, image)
print(f"Image with blurred eyes saved to {output_path}")

View File

@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='../../.env', env_file_encoding='utf-8')
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8')
BOT_TOKEN: str
DATABASE_URL: str

View File

@ -1,4 +1,7 @@
import uuid
import hashlib
import base64
from datetime import datetime
def UUID_code_generator() -> str:
@ -7,3 +10,22 @@ def UUID_code_generator() -> str:
Возвращает строку с кодом.
"""
return str(uuid.uuid4().hex[:8])
def generate_session_code(telegram_id: int, phone: str, consultation_date_time: str) -> str:
"""Генерация 8-символьного уникального кода на основе 3 параметров."""
combined = f"{telegram_id}|{phone}|{consultation_date_time}"
hash_digest = hashlib.sha256(combined.encode()).digest()
b64 = base64.urlsafe_b64encode(hash_digest).decode()
return b64[:8].upper()
def date_time_formatter(date_time: str) -> datetime:
"""
Форматирует дату и время в строку формата ДД.ММ.ГГГГ ЧЧ:ММ.
:param date_time: Дата и время в формате ISO 8601.
:return: Строка с отформатированной датой и временем.
"""
dt = datetime.strptime(date_time, '%d.%m.%y %H:%M')
return dt

View File

@ -11,17 +11,50 @@ class Base(DeclarativeBase):
pass
class SessionCode(Base):
__tablename__ = "session_codes"
class Sessions(Base):
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code: Mapped[str] = mapped_column(String(8), unique=True, nullable=False)
patient_telegram_id: Mapped[int] = mapped_column(nullable=False)
patient_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("patients.id", ondelete="CASCADE"), nullable=False)
patient: Mapped["Patients"] = relationship(back_populates="sessions")
sent_at: Mapped[datetime] = mapped_column(nullable=False)
consulted_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
doctor_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("doctors.id"))
session_status_history: Mapped[List["SessionStatusHistory"]] = relationship(
back_populates="sessions", cascade="all, delete-orphan")
session_date_time_history: Mapped[List["SessionDateTimeHistory"]] = relationship(
back_populates="sessions", cascade="all, delete-orphan")
paid_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
class SessionDateTimeHistory(Base):
__tablename__ = "session_date_time_history"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
sessions_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False)
sessions: Mapped["Sessions"] = relationship(back_populates="session_date_time_history")
updated_at: Mapped[datetime] = mapped_column(nullable=False, default=datetime.utcnow)
consultation_date_time: Mapped[Optional[datetime]] = mapped_column(nullable=True)
who_updated: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
class SessionStatusHistory(Base):
__tablename__ = "session_status_history"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
sessions_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False)
sessions: Mapped["Sessions"] = relationship(back_populates="session_status_history")
updated_at: Mapped[datetime] = mapped_column(nullable=False, default=datetime.utcnow)
status: Mapped[str] = mapped_column(String(50), nullable=False, default="pending")
who_updated: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
class Admins(Base):
@ -35,6 +68,20 @@ class Admins(Base):
ARRAY(String), nullable=True)
class Patients(Base):
__tablename__ = "patients"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
telegram_id: Mapped[int] = mapped_column(unique=True, nullable=False)
phone: Mapped[Optional[str]] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(nullable=False)
updated_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
accepted_terms: Mapped[bool] = mapped_column(default=False, nullable=True)
sessions: Mapped[List["Sessions"]] = relationship(
back_populates="patient", cascade="all, delete-orphan")
class Doctors(Base):
__tablename__ = "doctors"
@ -54,7 +101,7 @@ class Doctors(Base):
referral: Mapped["ReferralCode"] = relationship(
back_populates="doctor", uselist=False)
session_codes: Mapped[List["SessionCode"]] = relationship()
sessions: Mapped[List["Sessions"]] = relationship()
form_links: Mapped[List["FormLink"]] = relationship(
back_populates="doctor", cascade="all, delete-orphan")
payment_methods: Mapped[List["PaymentMethod"]] = relationship(

View File

@ -4,14 +4,14 @@ from telegram.ext import (
MessageHandler, filters
)
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from core.logging import logger
from core.enums.dialog_helpers import Confirmation
from core.texts import NO_PERMISSIONS_TO_USE_COMMAND
from docbot.services.admins_service import (
get_admin_info, get_doctors_waiting_authorization, approve_doctor,
reject_doctor, get_payment_link
reject_doctor, get_payment_methods
)
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
SELECT_DOCTOR, VERIFY_DOCTOR, SEND_PAYMENT_LINK = range(3)
@ -20,7 +20,7 @@ SELECT_DOCTOR_TO_VERIFY = "👀 Выберите врача для автори
CHOOSE_DOCTORS_DESTINY = "🙄 Врач с ID {doctor_telegram_id}. Что сделать?"
VERIFIED = "Врач с ID {doctor_telegram_id} верифицирован ✅"
REJECTED = "Верификация врача с ID {doctor_telegram_id} отклонена ❌"
PAYMENT_LINK = "Ссылка на оплату для врача с ID {doctor_telegram_id}: {payment_link}"
PAYMENT_LINK = "Ссылка на оплату для врача с ID {doctor_telegram_id}: {payment_link} отправлена."
async def verify(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@ -31,13 +31,13 @@ async def verify(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
user_id = update.effective_user.id
if not await get_admin_info(user_id):
logger.warning(
f"Пользователь {user_id} пытался проверить верификацию без прав.")
"Пользователь %s пытался проверить верификацию без прав.", user_id)
await update.message.reply_text(NO_PERMISSIONS_TO_USE_COMMAND)
return
# Здесь должна быть логика проверки верификации врача
# Например, можно запросить информацию о врачах и их статусе верификации
logger.info(f"Admin {user_id} запрашивает список врачей для верификации.")
logger.info("Admin %s запрашивает список врачей для верификации.", user_id)
doctors = await get_doctors_waiting_authorization()
if not doctors:
await update.message.reply_text(NO_DOCTORS)
@ -93,6 +93,12 @@ async def doctor_selected(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def doctor_action(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Обрабатывает действия администратора по верификации врача.
Действия: approve (верифицировать) или reject (отклонить).
После успешной верификации отправляет ссылку на оплату.
"""
logger.info("Обработка действия администратора по верификации врача.")
query = update.callback_query
await query.answer()
data = query.data.split("_")
@ -103,7 +109,7 @@ async def doctor_action(update: Update, context: ContextTypes.DEFAULT_TYPE):
if action == "approve":
# Здесь твоя логика верификации врача (например, запись в базу)
approve_doctor(doctor_telegram_id)
logger.info(f"Врач с ID {doctor_telegram_id} верифицирован.")
logger.info("Врач с ID %s верифицирован.", doctor_telegram_id)
# Отправляем сообщение об успешной верификации
await query.edit_message_text(
VERIFIED.format(doctor_telegram_id=doctor_telegram_id)
@ -111,7 +117,7 @@ async def doctor_action(update: Update, context: ContextTypes.DEFAULT_TYPE):
elif action == "reject":
# Логика отклонения
reject_doctor(doctor_telegram_id)
logger.info(f"Врач с ID {doctor_telegram_id} отклонен.")
logger.info("Врач с ID %s отклонен.", doctor_telegram_id)
# Отправляем сообщение об отклонении
await query.edit_message_text(
REJECTED.format(doctor_telegram_id=doctor_telegram_id)
@ -120,7 +126,7 @@ async def doctor_action(update: Update, context: ContextTypes.DEFAULT_TYPE):
return SEND_PAYMENT_LINK
def send_payment_link(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def send_payment_link(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Отправляет ссылку на оплату после верификации врача.
"""
@ -130,7 +136,12 @@ def send_payment_link(update: Update, context: ContextTypes.DEFAULT_TYPE):
return
# Здесь должна быть логика получения ссылки на оплату
payment_link = get_payment_link(doctor_telegram_id)
payment_link = get_payment_methods(doctor_telegram_id)
await context.bot.send_message(
chat_id=doctor_telegram_id,
text=f"Ваша верификация подтверждена! Вот ваша ссылка: {payment_link}"
)
# Отправляем ссылку на оплату
update.message.reply_text(

View File

@ -41,6 +41,7 @@ WAIT_FOR_ACTIVATION_TEXT = (
VERIFICATION_IN_PROGRESS_TEXT = (
"📝 Пока идет верификация, вы можете ознакомиться с форматами работы и условиями сервиса\n"
"Формат: название форматов + пакеты."
# TODO добавить ссылку на формат работы и пакеты (сразу отправить в чате инфу)
)
ALL_INFORMATION_RECEIVED_TEXT = (
"✅ Спасибо за информацию, мы с вами свяжемся. До свидания. 👋"

View File

@ -13,13 +13,38 @@ from telegram.ext import (
from docbot.handlers.utils.cancel_handler import get_cancel_handler
from docbot.handlers.start_handler import get_start_handler
from core.enums.dialog_helpers import ConfirmationMessage
from docbot.services.patients_service import (
create_patient, update_patient_phone, get_patient_by_telegram_id,
is_user_has_phone
)
from docbot.services.session_service import create_session
from core.logging import logger
SEND_ACKNOWLEDGEMENT_INFO = 1
PROCEED_WITH_CONSULTATION = 2
CHOOSE_CONSULTATION_TYPE = 3
SELECT_CONSULTATION_TYPE = 3
ENTER_PATIENT_PHONE = 4
ENTER_DOCTOR_NUMBER = 5
ENTER_CONSULTATION_DATE = 6
PAY_CONSULTATION = 7
STOPPING = 99
keyboard = [
[
InlineKeyboardButton(
"Записаться на консультацию",
callback_data="proceed_with_consultation"
),
InlineKeyboardButton(
"Частые вопросы",
callback_data="frequent_questions"
),
]
]
ACCEPT_PERSONAL_DATA_AGREEMENT_TEXT = (
"📝 Пожалуйста, подтвердите, что вы согласны с обработкой ваших персональных данных.\n"
@ -28,8 +53,7 @@ ACCEPT_PERSONAL_DATA_AGREEMENT_TEXT = (
async def accept_personal_data_agreement(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
keyboard = [
keyboard_accept = [
[
InlineKeyboardButton(
ConfirmationMessage.PROCEED.value,
@ -42,32 +66,38 @@ async def accept_personal_data_agreement(update: Update, context: ContextTypes.D
]
]
user_id = update.effective_user.id
user_data = context.user_data
user_data['telegram_id'] = user_id
logger.info(f"User {user_id} initiated consultation process.")
logger.info(f" user exists? {await get_patient_by_telegram_id(user_id)}")
if await get_patient_by_telegram_id(user_id):
await update.message.reply_text(
"Вы уже зарегистрированы как пациент. Пожалуйста, продолжайте с записью на консультацию.",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard),
)
return PROCEED_WITH_CONSULTATION
await update.message.reply_text(
ACCEPT_PERSONAL_DATA_AGREEMENT_TEXT,
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(keyboard),
reply_markup=InlineKeyboardMarkup(keyboard_accept),
)
return SEND_ACKNOWLEDGEMENT_INFO
async def receive_patient_aceptance(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
# Здесь можно добавить логику обработки согласия пациента
keyboard = [
[
InlineKeyboardButton(
"Записаться на консультацию",
callback_data="proceed_with_consultation"
),
InlineKeyboardButton(
"Частые вопросы",
callback_data="frequent_questions"
),
]
]
user_data = context.user_data
await update.callback_query.answer()
if update.callback_query.data == "accepted":
user_data['accepted'] = True
user_id = user_data['telegram_id']
await create_patient(telegram_id=user_id, terms_acceptance=True) # Создаем пациента в БД
await update.callback_query.edit_message_text(
text="✅ Спасибо за подтверждение. Вы можете продолжить запись на консультацию.",
parse_mode="Markdown",
@ -98,12 +128,105 @@ async def choose_consultation_type(update: Update, context: ContextTypes.DEFAULT
]
await update.callback_query.answer()
if update.callback_query.data == "proceed_with_consultation":
await update.callback_query.edit_message_text(
text="Выберите тип консультации:",
reply_markup=InlineKeyboardMarkup(keyboard),
)
return CHOOSE_CONSULTATION_TYPE
return SELECT_CONSULTATION_TYPE
async def enter_patient_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.callback_query.answer()
user_data = context.user_data
user_id = user_data['telegram_id']
patient = await get_patient_by_telegram_id(user_id)
has_phone = False
if patient:
has_phone = await is_user_has_phone(user_id)
logger.info(f"User {user_id} has phone: {has_phone}")
if update.callback_query.data in ["initial_reception"] and not has_phone:
await update.callback_query.edit_message_text(
text="Пожалуйста, введите ваш номер телефона для записи на консультацию:"
)
return ENTER_PATIENT_PHONE
elif has_phone:
await update.callback_query.edit_message_text(
text="Введите серийный номер врача, к которому вы хотите записаться на консультацию:",
parse_mode="Markdown"
)
return ENTER_DOCTOR_NUMBER
async def receive_patient_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
phone = update.message.text.strip()
user_id = context.user_data['telegram_id']
# Здесь можно добавить логику для сохранения номера телефона пациента в БД
# Например, вызов сервиса для обновления информации о пациенте
# await update_patient_phone(telegram_id=user_id, phone=phone)
logger.info((f"receive_patient_phone User {user_id} provided phone: {phone}"))
await update_patient_phone(telegram_id=user_id, phone=phone)
context.user_data['phone'] = phone
logger.info((f"receive_patient_phone2 User {user_id} provided phone: {phone}"))
await update.message.reply_text(
f"Ваш номер телефона: {phone}\n"
"Введите серийный номер врача, к которому вы хотите записаться на консультацию:",
parse_mode="Markdown"
)
return ENTER_DOCTOR_NUMBER
async def receive_doctor_number(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
doctor_number = update.message.text.strip()
await update.message.reply_text(
f"Вы ввели серийный номер врача: {doctor_number}\n"
"Введите дату и время консультации в формате ДД.ММ.ГГ ЧЧ:ММ, например, 01.01.23 12:00.",
parse_mode="Markdown"
)
logger.info(f"phone is {context.user_data['phone']}")
return ENTER_CONSULTATION_DATE
async def receive_consultation_date(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
consultation_date_time = update.message.text.strip()
# Здесь можно добавить логику для сохранения даты консультации в БД
# Например, вызов сервиса для создания записи о консультации
patient = await get_patient_by_telegram_id(context.user_data['telegram_id'])
await create_session(
telegram_id=context.user_data['telegram_id'],
phone=context.user_data['phone'],
consultation_date_time=consultation_date_time,
patient=patient
)
link = f"https://example.com/consultation/{context.user_data['telegram_id']}"
await update.message.reply_text(
f"Чтобы оплатить консультацию перейдите по ссылке {link}.",
parse_mode="Markdown",
reply_markup=ReplyKeyboardRemove()
)
return PAY_CONSULTATION
async def pay_consultation(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.message.reply_text(
"💳 Пожалуйста, оплатите консультацию по следующей ссылке: [Оплата](https://example.com/pay)",
parse_mode="Markdown"
)
return ConversationHandler.END
def consultation_handler() -> CommandHandler:
@ -117,9 +240,13 @@ def get_consultation_handler() -> ConversationHandler:
states={
SEND_ACKNOWLEDGEMENT_INFO: [CallbackQueryHandler(receive_patient_aceptance)],
PROCEED_WITH_CONSULTATION: [CallbackQueryHandler(choose_consultation_type)],
SELECT_CONSULTATION_TYPE: [CallbackQueryHandler(enter_patient_phone)],
ENTER_PATIENT_PHONE: [MessageHandler(filters=None, callback=receive_patient_phone)],
ENTER_DOCTOR_NUMBER: [MessageHandler(filters.TEXT & ~filters.COMMAND, receive_doctor_number)],
ENTER_CONSULTATION_DATE: [MessageHandler(filters=None, callback=receive_consultation_date)],
STOPPING: [get_start_handler()],
},
fallbacks=[get_cancel_handler()],
name="consultation_dialog", # для тестов/логирования
persistent=True, # если используете хранение состояний
name="consultation_dialog",
persistent=True,
)

View File

@ -6,7 +6,7 @@ from telegram.ext import (
MessageHandler,
filters,
)
from docbot.services.session_service import create_session_code, get_pending_session
from docbot.services.session_service import create_session, get_pending_session
from docbot.handlers.utils.cancel_handler import get_cancel_handler
@ -40,7 +40,7 @@ async def receive_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> in
user_id = update.effective_user.id
# вызываем сервис, который создаст код сессии и запишет в БД
code = await create_session_code(telegram_id=user_id, form_link=link)
code = await create_session(telegram_id=user_id, form_link=link)
await update.message.reply_text(
f"Ваш код для консультации: *{code}*\n"

View File

@ -9,9 +9,8 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
text = (
"👋 Добро пожаловать в DocBot!\n\n"
"Используйте команду /form чтобы прислать ссылку на анкету, если вы пациент.\n"
"После заполнения анкеты мы пришлем код для консультации врача.\n"
"Используйте команду /register, если у вас есть реферальный код."
"Используйте команду /consultation, чтобы произвести запись на консультацию, если вы пациент.\n"
"Используйте команду /register, если вы врач и у вас есть реферальный код."
)
await context.bot.send_message(chat_id=update.effective_chat.id, text=text)

View File

@ -6,11 +6,11 @@ from telegram.ext import (
)
async def __cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
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)
return CommandHandler("cancel", cancel)

View File

@ -6,6 +6,9 @@ from db.models import Admins, Doctors, VerificationRequests
async def get_admin_info(telegram_id: int) -> Admins | None:
""" Получает информацию об администраторе по его Telegram ID.
Возвращает объект Admins или None, если администратор не найден.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Admins.telegram_id)
@ -15,15 +18,23 @@ async def get_admin_info(telegram_id: int) -> Admins | None:
async def mark_doctor_inactive(telegram_id: int) -> Admins | None:
# TODO: Добавить логику для пометки врача как неактивного.
""" Помечает врача как неактивного.
Устанавливает флаг is_active в False.
Возвращает информацию о враче или None, если не найден.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Admins.telegram_id)
.where(Admins.telegram_id == telegram_id)
select(Doctors.telegram_id)
.where(Doctors.telegram_id == telegram_id)
)
return result.scalar_one_or_none()
async def get_doctors_waiting_authorization() -> Sequence[Row[Tuple[VerificationRequests, Doctors]]] | None:
""" Получает список врачей, ожидающих верификации.
Возвращает список кортежей (VerificationRequests, Doctors) или None, если нет врачей.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(VerificationRequests, Doctors)
@ -36,6 +47,10 @@ async def get_doctors_waiting_authorization() -> Sequence[Row[Tuple[Verification
async def approve_doctor(doctor_id: int) -> bool:
""" Верифицирует врача.
Устанавливает флаги is_verified и is_active в True.
Возвращает True, если врач найден и обновлен, иначе False.
"""
async with AsyncSessionLocal() as session:
async with session.begin():
result = await session.execute(
@ -51,6 +66,10 @@ async def approve_doctor(doctor_id: int) -> bool:
async def reject_doctor(doctor_id: int) -> bool:
""" Отклоняет верификацию врача.
Устанавливает флаги is_verified и is_active в False.
Возвращает True, если врач найден и обновлен, иначе False.
"""
async with AsyncSessionLocal() as session:
async with session.begin():
result = await session.execute(

View File

@ -0,0 +1,69 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from db.session import AsyncSessionLocal
from db.models import Patients
async def update_patient_phone(telegram_id: int, phone: str) -> bool:
"""
Обновляет номер телефона пациента в базе данных.
:param telegram_id: ID Telegram пациента.
:param phone: Новый номер телефона для обновления.
:return: True, если обновление прошло успешно, иначе False.
"""
async with AsyncSessionLocal() as session:
async with session.begin():
result = await session.execute(
select(Patients).where(Patients.telegram_id == telegram_id)
)
patient = result.scalar_one_or_none()
if patient:
patient.phone = phone
patient.updated_at = datetime.utcnow()
return True
return False
async def create_patient(telegram_id: int, terms_acceptance: bool) -> Patients:
"""
Создает нового пациента в базе данных.
:param telegram_id: ID Telegram пациента.
:return: Объект Patients с информацией о новом пациенте.
"""
async with AsyncSessionLocal() as session:
async with session.begin():
new_patient = Patients(
telegram_id=telegram_id,
created_at=datetime.utcnow(),
accepted_terms=terms_acceptance
)
session.add(new_patient)
return new_patient
async def get_patient_by_telegram_id(telegram_id: int) -> Patients | None:
"""
Получает информацию о пациенте по его Telegram ID.
:param telegram_id: ID Telegram пациента.
:return: Объект Patients или None, если пациент не найден.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Patients).where(Patients.telegram_id == telegram_id)
)
return result.scalar_one_or_none()
async def is_user_has_phone(telegram_id: int) -> bool:
"""
Проверяет, есть ли у пользователя номер телефона.
:param telegram_id: ID Telegram пользователя.
:return: True, если номер телефона есть, иначе False.
"""
patient = await get_patient_by_telegram_id(telegram_id)
return patient is not None and patient.phone is not None

View File

@ -6,23 +6,42 @@ from datetime import datetime
from sqlalchemy import select
from db.session import AsyncSessionLocal
from db.models import SessionCode
from core.utils import UUID_code_generator
from db.models import Sessions, Patients, SessionStatusHistory, SessionDateTimeHistory
from core.utils import (
UUID_code_generator, generate_session_code, date_time_formatter
)
async def create_session_code(telegram_id: int, form_link: str) -> str:
async def create_session(telegram_id: int, phone: str, consultation_date_time: str, patient: Patients) -> str:
"""
Генерирует уникальный код, сохраняет его вместе с Telegram ID.
Возвращает этот код.
"""
code = UUID_code_generator()
code = generate_session_code(telegram_id=telegram_id, phone=phone, consultation_date_time=consultation_date_time)
async with AsyncSessionLocal() as session:
async with session.begin():
session.add(SessionCode(
sessions = Sessions(
code=code,
patient_telegram_id=telegram_id,
patient=patient,
sent_at=datetime.utcnow()
))
)
sessions_code_history = SessionStatusHistory(
sessions=sessions,
updated_at=datetime.utcnow(),
status="created",
who_updated="bot"
)
sessions_date_time_history = SessionDateTimeHistory(
sessions=sessions,
updated_at=datetime.utcnow(),
consultation_date_time=date_time_formatter(consultation_date_time),
who_updated="bot"
)
session.add_all([sessions_code_history, sessions_date_time_history])
return code
@ -34,26 +53,26 @@ async def mark_consulted(code: str) -> bool:
async with AsyncSessionLocal() as session:
async with session.begin():
result = await session.execute(
select(SessionCode).where(SessionCode.code.match(code))
select(Sessions).where(Sessions.code.match(code))
)
sc: SessionCode | None = result.scalar_one_or_none()
sc: Sessions | 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:
async def get_pending_session(telegram_id: int) -> Sessions | None:
"""
Ищет самую «свежую» сессию по telegram_id, где consulted_at ещё не заполнен.
Вернёт объект SessionCode или None.
Вернёт объект Sessions или None.
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SessionCode)
.where(SessionCode.patient_telegram_id.match(telegram_id))
.where(SessionCode.consulted_at.is_(None))
.order_by(SessionCode.sent_at.desc())
select(Sessions)
.where(Sessions.patient_telegram_id.match(telegram_id))
.where(Sessions.consulted_at.is_(None))
.order_by(Sessions.sent_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
@ -66,9 +85,9 @@ async def get_session_info(code: str) -> dict | None:
"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SessionCode).where(SessionCode.code.match(code))
select(Sessions).where(Sessions.code.match(code))
)
sc: SessionCode | None = result.scalar_one_or_none()
sc: Sessions | None = result.scalar_one_or_none()
if not sc:
return None
return {