diff --git a/.gitignore b/.gitignore index fb520dc..32c3d58 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,7 @@ db/ !src/db __pycache__ src/docbot/conversations.pkl -conversations.pkl \ No newline at end of file +conversations.pkl +*.jpg +*.png +*.jpeg \ No newline at end of file diff --git a/ai_example.py b/ai_example.py new file mode 100644 index 0000000..39a6f31 --- /dev/null +++ b/ai_example.py @@ -0,0 +1,4 @@ +from core.ai.ai_bot import detect_eyes + + +detect_eyes("/Users/o.vodianov/IdeaProjects/docbot/image.png") diff --git a/poetry.lock b/poetry.lock index 04ea06a..003aca7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index cddeca0..809779f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/core/ai/ai_bot.py b/src/core/ai/ai_bot.py new file mode 100644 index 0000000..e65e9ba --- /dev/null +++ b/src/core/ai/ai_bot.py @@ -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}") diff --git a/src/core/config.py b/src/core/config.py index edd255d..f5f78fd 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -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 diff --git a/src/core/utils.py b/src/core/utils.py index c300c1e..0dc6bb1 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -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 diff --git a/src/db/models.py b/src/db/models.py index 74fbd79..c4ddb80 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -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( diff --git a/src/docbot/handlers/admins/verify_handler.py b/src/docbot/handlers/admins/verify_handler.py index 03a03e9..56ba689 100644 --- a/src/docbot/handlers/admins/verify_handler.py +++ b/src/docbot/handlers/admins/verify_handler.py @@ -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( diff --git a/src/docbot/handlers/doctors/register_handler.py b/src/docbot/handlers/doctors/register_handler.py index bd38f72..b0db516 100644 --- a/src/docbot/handlers/doctors/register_handler.py +++ b/src/docbot/handlers/doctors/register_handler.py @@ -41,6 +41,7 @@ WAIT_FOR_ACTIVATION_TEXT = ( VERIFICATION_IN_PROGRESS_TEXT = ( "📝 Пока идет верификация, вы можете ознакомиться с форматами работы и условиями сервиса\n" "Формат: название форматов + пакеты." + # TODO добавить ссылку на формат работы и пакеты (сразу отправить в чате инфу) ) ALL_INFORMATION_RECEIVED_TEXT = ( "✅ Спасибо за информацию, мы с вами свяжемся. До свидания. 👋" diff --git a/src/docbot/handlers/patients/consultation_handler.py b/src/docbot/handlers/patients/consultation_handler.py index e9964ff..79ce508 100644 --- a/src/docbot/handlers/patients/consultation_handler.py +++ b/src/docbot/handlers/patients/consultation_handler.py @@ -13,46 +13,26 @@ 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 - -ACCEPT_PERSONAL_DATA_AGREEMENT_TEXT = ( - "📝 Пожалуйста, подтвердите, что вы согласны с обработкой ваших персональных данных.\n" - "Для этого нажмите кнопку ниже." -) - - -async def accept_personal_data_agreement(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - - keyboard = [ - [ - InlineKeyboardButton( - ConfirmationMessage.PROCEED.value, - callback_data="accepted" - ), - InlineKeyboardButton( - ConfirmationMessage.DECLINE.value, - callback_data="declined" - ), - ] - ] - - await update.message.reply_text( - ACCEPT_PERSONAL_DATA_AGREEMENT_TEXT, - parse_mode="Markdown", - reply_markup=InlineKeyboardMarkup(keyboard), - ) - return SEND_ACKNOWLEDGEMENT_INFO - - -async def receive_patient_aceptance(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - # Здесь можно добавить логику обработки согласия пациента - keyboard = [ +keyboard = [ [ InlineKeyboardButton( "Записаться на консультацию", @@ -64,10 +44,60 @@ async def receive_patient_aceptance(update: Update, context: ContextTypes.DEFAUL ), ] ] + + +ACCEPT_PERSONAL_DATA_AGREEMENT_TEXT = ( + "📝 Пожалуйста, подтвердите, что вы согласны с обработкой ваших персональных данных.\n" + "Для этого нажмите кнопку ниже." +) + + +async def accept_personal_data_agreement(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + keyboard_accept = [ + [ + InlineKeyboardButton( + ConfirmationMessage.PROCEED.value, + callback_data="accepted" + ), + InlineKeyboardButton( + ConfirmationMessage.DECLINE.value, + callback_data="declined" + ), + ] + ] + + 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_accept), + ) + return SEND_ACKNOWLEDGEMENT_INFO + + +async def receive_patient_aceptance(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + # Здесь можно добавить логику обработки согласия пациента + 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", @@ -81,7 +111,7 @@ async def receive_patient_aceptance(update: Update, context: ContextTypes.DEFAUL parse_mode="Markdown" ) return STOPPING - + async def choose_consultation_type(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: keyboard = [ @@ -96,14 +126,107 @@ 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 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="Выберите тип консультации:", - reply_markup=InlineKeyboardMarkup(keyboard), + text="Пожалуйста, введите ваш номер телефона для записи на консультацию:" ) - return CHOOSE_CONSULTATION_TYPE + 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, ) diff --git a/src/docbot/handlers/patients/send_form_handler.py b/src/docbot/handlers/patients/send_form_handler.py index dacda34..f2c6599 100644 --- a/src/docbot/handlers/patients/send_form_handler.py +++ b/src/docbot/handlers/patients/send_form_handler.py @@ -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" diff --git a/src/docbot/handlers/start_handler.py b/src/docbot/handlers/start_handler.py index 52685bb..4a0b2d7 100644 --- a/src/docbot/handlers/start_handler.py +++ b/src/docbot/handlers/start_handler.py @@ -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) diff --git a/src/docbot/handlers/utils/cancel_handler.py b/src/docbot/handlers/utils/cancel_handler.py index 23cdfbd..d733d3f 100644 --- a/src/docbot/handlers/utils/cancel_handler.py +++ b/src/docbot/handlers/utils/cancel_handler.py @@ -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) \ No newline at end of file + return CommandHandler("cancel", cancel) diff --git a/src/docbot/services/admins_service.py b/src/docbot/services/admins_service.py index f6274fc..6b22d64 100644 --- a/src/docbot/services/admins_service.py +++ b/src/docbot/services/admins_service.py @@ -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( diff --git a/src/docbot/services/patients_service.py b/src/docbot/services/patients_service.py new file mode 100644 index 0000000..50794b9 --- /dev/null +++ b/src/docbot/services/patients_service.py @@ -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 diff --git a/src/docbot/services/session_service.py b/src/docbot/services/session_service.py index bd2fd4a..cbbda6a 100644 --- a/src/docbot/services/session_service.py +++ b/src/docbot/services/session_service.py @@ -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 {