commit 3e2570ae0b2d03cea2d75672c68aad4fff6843d7 Author: Ruslan Bakiev Date: Wed Jan 7 09:17:34 2026 +0700 Initial commit from monorepo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..09bc067 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + NIXPACKS_POETRY_VERSION=2.2.1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl \ + && rm -rf /var/lib/apt/lists/* + +RUN python -m venv --copies /opt/venv +ENV VIRTUAL_ENV=/opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY . . + +RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \ + && poetry install --no-interaction --no-ansi + +ENV PORT=8000 + +CMD ["sh", "-c", "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn teams.wsgi:application --bind 0.0.0.0:${PORT:-8000}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..039d672 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Teams Service + +Backend сервис для управления командами и участниками в системе Optovia. + +## Описание + +Сервис для управления командами с интеграцией Logto для аутентификации. Включает управление участниками, приглашениями и KYC статусами команд. + +## Основные функции + +- Создание и управление командами +- Управление участниками команд (OWNER, ADMIN, MANAGER, MEMBER) +- Система приглашений в команды +- Интеграция с Logto для аутентификации +- KYC статусы команд +- Управление активной командой пользователя + +## Модели данных + +- **Team** - модель команды с KYC статусами +- **TeamMember** - участники команды с ролями +- **TeamInvitation** - приглашения в команды +- **User** - пользователи с привязкой к Logto + +## KYC статусы команд + +- `PENDING_KYC` - Требуется KYC +- `KYC_IN_REVIEW` - KYC на рассмотрении +- `KYC_APPROVED` - KYC одобрен +- `KYC_REJECTED` - KYC отклонен +- `SUSPENDED` - Заблокировано + +## Технологии + +- Django 5.2.8 +- GraphQL (Graphene-Django) +- PostgreSQL +- Logto Integration +- Gunicorn + +## Развертывание + +Проект развертывается через Nixpacks на Dokploy с автоматическими миграциями. + +## Автор + +Ruslan Bakiev diff --git a/db.sqlite3 b/db.sqlite3 new file mode 100644 index 0000000..e69de29 diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..df2eb1b --- /dev/null +++ b/manage.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + +if __name__ == '__main__': + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'teams.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) \ No newline at end of file diff --git a/nixpacks.toml b/nixpacks.toml new file mode 100644 index 0000000..b3161ac --- /dev/null +++ b/nixpacks.toml @@ -0,0 +1,18 @@ +providers = ["python"] + +[build] + +[phases.install] +cmds = [ + "python -m venv --copies /opt/venv", + ". /opt/venv/bin/activate", + "pip install poetry==$NIXPACKS_POETRY_VERSION", + "poetry install --no-interaction --no-ansi" +] + +[start] +cmd = "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn teams.wsgi:application --bind 0.0.0.0:${PORT:-8000}" + +[variables] +# Set Poetry version to match local environment +NIXPACKS_POETRY_VERSION = "2.2.1" diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..edff392 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1005 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "aenum" +version = "3.1.16" +description = "Advanced Enumerations (compatible with Python's stdlib Enum), NamedTuples, and NamedConstants" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "aenum-3.1.16-py2-none-any.whl", hash = "sha256:7810cbb6b4054b7654e5a7bafbe16e9ee1d25ef8e397be699f63f2f3a5800433"}, + {file = "aenum-3.1.16-py3-none-any.whl", hash = "sha256:9035092855a98e41b66e3d0998bd7b96280e85ceb3a04cc035636138a1943eaf"}, +] + +[[package]] +name = "asgiref" +version = "3.11.0" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"}, + {file = "asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4"}, +] + +[package.extras] +tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] + +[[package]] +name = "boto3" +version = "1.41.4" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "boto3-1.41.4-py3-none-any.whl", hash = "sha256:77d84b7ce890a9b0c6a8993f8de106d8cf8138f332a4685e6de453965e60cb24"}, + {file = "boto3-1.41.4.tar.gz", hash = "sha256:2c6b8d13df6beb9255d0c8cb60a7a2164f5270580ea5b921a65658a0c28e3223"}, +] + +[package.dependencies] +botocore = ">=1.41.4,<1.42.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.15.0,<0.16.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.41.4" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "botocore-1.41.4-py3-none-any.whl", hash = "sha256:7143ef845f1d1400dbbf05d999f8c5e8cfaecd6bd84cbfbe5fa0a40e3a9f6353"}, + {file = "botocore-1.41.4.tar.gz", hash = "sha256:45c78f07b53a64cbe55e5d60297958f151bd4a2c6acb944a8bb65874bc2fd953"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.29.0)"] + +[[package]] +name = "certifi" +version = "2025.11.12" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, + {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, +] + +[[package]] +name = "cffi" +version = "2.0.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.9" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, + {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, + {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, + {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, + {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, + {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, + {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, + {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, + {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, + {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, + {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, + {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, + {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, + {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, + {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, + {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, + {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, + {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, + {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, + {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, + {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, + {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, + {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, + {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, + {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, + {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, + {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, + {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, + {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, + {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, + {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, + {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, + {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, + {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, + {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, + {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, + {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, + {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, + {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, + {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, + {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, + {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.8" +groups = ["main"] +files = [ + {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"}, + {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"}, + {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"}, + {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"}, + {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"}, + {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"}, + {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"}, + {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"}, + {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"}, + {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"}, + {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"}, + {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"}, + {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"}, + {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"}, + {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"}, + {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"}, + {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"}, + {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"}, + {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"}, + {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "django" +version = "5.2.8" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f"}, + {file = "django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f"}, +] + +[package.dependencies] +asgiref = ">=3.8.1" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-cors-headers" +version = "4.9.0" +description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449"}, + {file = "django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8"}, +] + +[package.dependencies] +asgiref = ">=3.6" +django = ">=4.2" + +[[package]] +name = "graphene" +version = "3.4.3" +description = "GraphQL Framework for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "graphene-3.4.3-py2.py3-none-any.whl", hash = "sha256:820db6289754c181007a150db1f7fff544b94142b556d12e3ebc777a7bf36c71"}, + {file = "graphene-3.4.3.tar.gz", hash = "sha256:2a3786948ce75fe7e078443d37f609cbe5bb36ad8d6b828740ad3b95ed1a0aaa"}, +] + +[package.dependencies] +graphql-core = ">=3.1,<3.3" +graphql-relay = ">=3.1,<3.3" +python-dateutil = ">=2.7.0,<3" +typing-extensions = ">=4.7.1,<5" + +[package.extras] +dev = ["coveralls (>=3.3,<5)", "mypy (>=1.10,<2)", "pytest (>=8,<9)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=4,<5)", "pytest-cov (>=5,<6)", "pytest-mock (>=3,<4)", "ruff (==0.5.0)", "types-python-dateutil (>=2.8.1,<3)"] +test = ["coveralls (>=3.3,<5)", "pytest (>=8,<9)", "pytest-asyncio (>=0.16,<2)", "pytest-benchmark (>=4,<5)", "pytest-cov (>=5,<6)", "pytest-mock (>=3,<4)"] + +[[package]] +name = "graphene-django" +version = "3.2.3" +description = "Graphene Django integration" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "graphene-django-3.2.3.tar.gz", hash = "sha256:d831bfe8e9a6e77e477b7854faef4addb318f386119a69ee4c57b74560f3e07d"}, + {file = "graphene_django-3.2.3-py2.py3-none-any.whl", hash = "sha256:0c673a4dad315b26b4d18eb379ad0c7027fd6a36d23a1848b7c7c09a14a9271e"}, +] + +[package.dependencies] +Django = ">=3.2" +graphene = ">=3.0,<4" +graphql-core = ">=3.1.0,<4" +graphql-relay = ">=3.1.1,<4" +promise = ">=2.1" +text-unidecode = "*" + +[package.extras] +dev = ["coveralls", "django-filter (>=22.1)", "djangorestframework (>=3.6.3)", "mock", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-django (>=4.5.2)", "pytest-random-order", "pytz", "ruff (==0.1.2)"] +rest-framework = ["djangorestframework (>=3.6.3)"] +test = ["coveralls", "django-filter (>=22.1)", "djangorestframework (>=3.6.3)", "mock", "pytest (>=7.3.1)", "pytest-cov", "pytest-django (>=4.5.2)", "pytest-random-order", "pytz"] + +[[package]] +name = "graphql-core" +version = "3.2.7" +description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL." +optional = false +python-versions = "<4,>=3.7" +groups = ["main"] +files = [ + {file = "graphql_core-3.2.7-py3-none-any.whl", hash = "sha256:17fc8f3ca4a42913d8e24d9ac9f08deddf0a0b2483076575757f6c412ead2ec0"}, + {file = "graphql_core-3.2.7.tar.gz", hash = "sha256:27b6904bdd3b43f2a0556dad5d579bdfdeab1f38e8e8788e555bdcb586a6f62c"}, +] + +[[package]] +name = "graphql-relay" +version = "3.2.0" +description = "Relay library for graphql-core" +optional = false +python-versions = ">=3.6,<4" +groups = ["main"] +files = [ + {file = "graphql-relay-3.2.0.tar.gz", hash = "sha256:1ff1c51298356e481a0be009ccdff249832ce53f30559c1338f22a0e0d17250c"}, + {file = "graphql_relay-3.2.0-py3-none-any.whl", hash = "sha256:c9b22bd28b170ba1fe674c74384a8ff30a76c8e26f88ac3aa1584dd3179953e5"}, +] + +[package.dependencies] +graphql-core = ">=3.2,<3.3" + +[[package]] +name = "gunicorn" +version = "23.0.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, + {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "infisicalsdk" +version = "1.0.12" +description = "Official Infisical SDK for Python (Latest)" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "infisicalsdk-1.0.12-py3-none-any.whl", hash = "sha256:348af8f665fd81beac643db5a81f858eaf473e5809bdb7dfdcca923fe3ab49ea"}, + {file = "infisicalsdk-1.0.12.tar.gz", hash = "sha256:d376e3a649ff4733d1586488d58bf9305c2a60e9360a7d6c315a4639799aedd4"}, +] + +[package.dependencies] +aenum = "*" +boto3 = ">=1.35,<2.0" +botocore = ">=1.35,<2.0" +python-dateutil = "*" +requests = ">=2.32,<3.0" + +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + +[[package]] +name = "nexus-rpc" +version = "1.2.0" +description = "Nexus Python SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "nexus_rpc-1.2.0-py3-none-any.whl", hash = "sha256:977876f3af811ad1a09b2961d3d1ac9233bda43ff0febbb0c9906483b9d9f8a3"}, + {file = "nexus_rpc-1.2.0.tar.gz", hash = "sha256:b4ddaffa4d3996aaeadf49b80dfcdfbca48fe4cb616defaf3b3c5c2c8fc61890"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.2" + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "promise" +version = "2.3" +description = "Promises/A+ implementation for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "promise-2.3.tar.gz", hash = "sha256:dfd18337c523ba4b6a58801c164c1904a9d4d1b1747c7d5dbf45b693a49d93d0"}, +] + +[package.dependencies] +six = "*" + +[package.extras] +test = ["coveralls", "futures", "mock", "pytest (>=2.7.3)", "pytest-benchmark", "pytest-cov"] + +[[package]] +name = "protobuf" +version = "6.33.1" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "protobuf-6.33.1-cp310-abi3-win32.whl", hash = "sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b"}, + {file = "protobuf-6.33.1-cp310-abi3-win_amd64.whl", hash = "sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed"}, + {file = "protobuf-6.33.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490"}, + {file = "protobuf-6.33.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178"}, + {file = "protobuf-6.33.1-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53"}, + {file = "protobuf-6.33.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1"}, + {file = "protobuf-6.33.1-cp39-cp39-win32.whl", hash = "sha256:023af8449482fa884d88b4563d85e83accab54138ae098924a985bcbb734a213"}, + {file = "protobuf-6.33.1-cp39-cp39-win_amd64.whl", hash = "sha256:df051de4fd7e5e4371334e234c62ba43763f15ab605579e04c7008c05735cd82"}, + {file = "protobuf-6.33.1-py3-none-any.whl", hash = "sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa"}, + {file = "protobuf-6.33.1.tar.gz", hash = "sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b"}, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c0e4262e089516603a09474ee13eabf09cb65c332277e39af68f6233911087"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e"}, + {file = "psycopg2_binary-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908"}, + {file = "psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d"}, + {file = "psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1"}, + {file = "psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d"}, + {file = "psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:20e7fb94e20b03dcc783f76c0865f9da39559dcc0c28dd1a3fce0d01902a6b9c"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4bdab48575b6f870f465b397c38f1b415520e9879fdf10a53ee4f49dcbdf8a21"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9d3a9edcfbe77a3ed4bc72836d466dfce4174beb79eda79ea155cc77237ed9e8"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:44fc5c2b8fa871ce7f0023f619f1349a0aa03a0857f2c96fbc01c657dcbbdb49"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c55460033867b4622cda1b6872edf445809535144152e5d14941ef591980edf"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d11098a83cca92deaeaed3d58cfd150d49b3b06ee0d0852be466bf87596899e"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:691c807d94aecfbc76a14e1408847d59ff5b5906a04a23e12a89007672b9e819"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8b81627b691f29c4c30a8f322546ad039c40c328373b11dff7490a3e1b517855"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:b637d6d941209e8d96a072d7977238eea128046effbf37d1d8b2c0764750017d"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:41360b01c140c2a03d346cec3280cf8a71aa07d94f3b1509fa0161c366af66b4"}, + {file = "psycopg2_binary-2.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02"}, +] + +[[package]] +name = "pycparser" +version = "2.23" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, + {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.2.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "s3transfer" +version = "0.15.0" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "s3transfer-0.15.0-py3-none-any.whl", hash = "sha256:6f8bf5caa31a0865c4081186689db1b2534cef721d104eb26101de4b9d6a5852"}, + {file = "s3transfer-0.15.0.tar.gz", hash = "sha256:d36fac8d0e3603eff9b5bfa4282c7ce6feb0301a633566153cbd0b93d11d8379"}, +] + +[package.dependencies] +botocore = ">=1.37.4,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] + +[[package]] +name = "sentry-sdk" +version = "2.47.0" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "sentry_sdk-2.47.0-py2.py3-none-any.whl", hash = "sha256:d72f8c61025b7d1d9e52510d03a6247b280094a327dd900d987717a4fce93412"}, + {file = "sentry_sdk-2.47.0.tar.gz", hash = "sha256:8218891d5e41b4ea8d61d2aed62ed10c80e39d9f2959d6f939efbf056857e050"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = ">=1.26.11" + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +anthropic = ["anthropic (>=0.16)"] +arq = ["arq (>=0.23)"] +asyncpg = ["asyncpg (>=0.23)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +celery-redbeat = ["celery-redbeat (>=2)"] +chalice = ["chalice (>=1.16.0)"] +clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +google-genai = ["google-genai (>=1.29.0)"] +grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] +http2 = ["httpcore[http2] (==1.*)"] +httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] +huggingface-hub = ["huggingface_hub (>=0.22)"] +langchain = ["langchain (>=0.0.210)"] +langgraph = ["langgraph (>=0.6.6)"] +launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] +litellm = ["litellm (>=1.77.5)"] +litestar = ["litestar (>=2.0.0)"] +loguru = ["loguru (>=0.5)"] +mcp = ["mcp (>=1.15.0)"] +openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] +openfeature = ["openfeature-sdk (>=0.7.1)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +opentelemetry-experimental = ["opentelemetry-distro"] +opentelemetry-otlp = ["opentelemetry-distro[otlp] (>=0.35b0)"] +pure-eval = ["asttokens", "executing", "pure_eval"] +pydantic-ai = ["pydantic-ai (>=1.0.0)"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +statsig = ["statsig (>=0.55.3)"] +tornado = ["tornado (>=6)"] +unleash = ["UnleashClient (>=6.0.1)"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sqlparse" +version = "0.5.3" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, + {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] + +[[package]] +name = "temporalio" +version = "1.20.0" +description = "Temporal.io Python SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "temporalio-1.20.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:fba70314b4068f8b1994bddfa0e2ad742483f0ae714d2ef52e63013ccfd7042e"}, + {file = "temporalio-1.20.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffc5bb6cabc6ae67f0bfba44de6a9c121603134ae18784a2ff3a7f230ad99080"}, + {file = "temporalio-1.20.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1e80c1e4cdf88fa8277177f563edc91466fe4dc13c0322f26e55c76b6a219e6"}, + {file = "temporalio-1.20.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba92d909188930860c9d89ca6d7a753bc5a67e4e9eac6cea351477c967355eed"}, + {file = "temporalio-1.20.0-cp310-abi3-win_amd64.whl", hash = "sha256:eacfd571b653e0a0f4aa6593f4d06fc628797898f0900d400e833a1f40cad03a"}, + {file = "temporalio-1.20.0.tar.gz", hash = "sha256:5a6a85b7d298b7359bffa30025f7deac83c74ac095a4c6952fbf06c249a2a67c"}, +] + +[package.dependencies] +nexus-rpc = "1.2.0" +protobuf = ">=3.20,<7.0.0" +types-protobuf = ">=3.20" +typing-extensions = ">=4.2.0,<5" + +[package.extras] +grpc = ["grpcio (>=1.48.2,<2)"] +openai-agents = ["mcp (>=1.9.4,<2)", "openai-agents (>=0.3,<0.5)"] +opentelemetry = ["opentelemetry-api (>=1.11.1,<2)", "opentelemetry-sdk (>=1.11.1,<2)"] +pydantic = ["pydantic (>=2.0.0,<3)"] + +[[package]] +name = "text-unidecode" +version = "1.3" +description = "The most basic Text::Unidecode port" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, + {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, +] + +[[package]] +name = "types-protobuf" +version = "6.32.1.20251105" +description = "Typing stubs for protobuf" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "types_protobuf-6.32.1.20251105-py3-none-any.whl", hash = "sha256:a15109d38f7cfefd2539ef86d3f93a6a41c7cad53924f8aa1a51eaddbb72a660"}, + {file = "types_protobuf-6.32.1.20251105.tar.gz", hash = "sha256:641002611ff87dd9fedc38a39a29cacb9907ae5ce61489b53e99ca2074bef764"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "tzdata" +version = "2025.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "whitenoise" +version = "6.11.0" +description = "Radically simplified static file serving for WSGI applications" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258"}, + {file = "whitenoise-6.11.0.tar.gz", hash = "sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f"}, +] + +[package.extras] +brotli = ["brotli"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.11" +content-hash = "33bcae3d5a20ac5a5cbabf6a11f0b74a9cfa86ef799f7df5ead9052178fefb07" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d812da7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "teams" +version = "0.1.0" +description = "" +authors = [ + {name = "Ruslan Bakiev",email = "572431+veikab@users.noreply.github.com"} +] +readme = "README.md" +requires-python = "^3.11" +dependencies = [ + "django (>=5.2.8,<6.0)", + "graphene-django (>=3.2.3,<4.0.0)", + "django-cors-headers (>=4.9.0,<5.0.0)", + "psycopg2-binary (>=2.9.11,<3.0.0)", + "requests (>=2.32.5,<3.0.0)", + "temporalio (>=1.4.0,<2.0.0)", + "python-dotenv (>=1.2.1,<2.0.0)", + "pyjwt (>=2.10.1,<3.0.0)", + "cryptography (>=46.0.3,<47.0.0)", + "infisicalsdk (>=1.0.12,<2.0.0)", + "gunicorn (>=23.0.0,<24.0.0)", + "whitenoise (>=6.7.0,<7.0.0)", + "sentry-sdk (>=2.47.0,<3.0.0)" +] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/teams/__init__.py b/teams/__init__.py new file mode 100644 index 0000000..baaa45d --- /dev/null +++ b/teams/__init__.py @@ -0,0 +1 @@ +# Django orders service \ No newline at end of file diff --git a/teams/__pycache__/__init__.cpython-313.pyc b/teams/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c5032b0 Binary files /dev/null and b/teams/__pycache__/__init__.cpython-313.pyc differ diff --git a/teams/__pycache__/settings.cpython-313.pyc b/teams/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000..215e7b9 Binary files /dev/null and b/teams/__pycache__/settings.cpython-313.pyc differ diff --git a/teams/__pycache__/settings_local.cpython-313.pyc b/teams/__pycache__/settings_local.cpython-313.pyc new file mode 100644 index 0000000..c8e56bc Binary files /dev/null and b/teams/__pycache__/settings_local.cpython-313.pyc differ diff --git a/teams/__pycache__/urls.cpython-313.pyc b/teams/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000..2800b3e Binary files /dev/null and b/teams/__pycache__/urls.cpython-313.pyc differ diff --git a/teams/settings.py b/teams/settings.py new file mode 100644 index 0000000..522c860 --- /dev/null +++ b/teams/settings.py @@ -0,0 +1,161 @@ +import os +from pathlib import Path +from urllib.parse import urlparse +from dotenv import load_dotenv +from infisical_sdk import InfisicalSDKClient +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration + +load_dotenv() + +INFISICAL_API_URL = os.environ["INFISICAL_API_URL"] +INFISICAL_CLIENT_ID = os.environ["INFISICAL_CLIENT_ID"] +INFISICAL_CLIENT_SECRET = os.environ["INFISICAL_CLIENT_SECRET"] +INFISICAL_PROJECT_ID = os.environ["INFISICAL_PROJECT_ID"] +INFISICAL_ENV = os.environ.get("INFISICAL_ENV", "prod") + +client = InfisicalSDKClient(host=INFISICAL_API_URL) +client.auth.universal_auth.login( + client_id=INFISICAL_CLIENT_ID, + client_secret=INFISICAL_CLIENT_SECRET, +) + +# Fetch secrets from /teams and /shared +for secret_path in ["/teams", "/shared"]: + secrets_response = client.secrets.list_secrets( + environment_slug=INFISICAL_ENV, + secret_path=secret_path, + project_id=INFISICAL_PROJECT_ID, + expand_secret_references=True, + view_secret_value=True, + ) + for secret in secrets_response.secrets: + os.environ[secret.secretKey] = secret.secretValue + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production') + +DEBUG = os.getenv('DEBUG', 'False') == 'True' + +# Sentry/GlitchTip configuration +SENTRY_DSN = os.getenv('SENTRY_DSN', '') +if SENTRY_DSN: + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=[DjangoIntegration()], + auto_session_tracking=False, + traces_sample_rate=0.01, + release=os.getenv('RELEASE_VERSION', '1.0.0'), + environment=os.getenv('ENVIRONMENT', 'production'), + send_default_pii=False, + debug=DEBUG, + ) + +ALLOWED_HOSTS = ['*'] + +CSRF_TRUSTED_ORIGINS = ['https://teams.optovia.ru'] + +INSTALLED_APPS = [ + 'whitenoise.runserver_nostatic', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'corsheaders', + 'graphene_django', + 'teams_app', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'teams.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'teams.wsgi.application' + +db_url = os.environ["TEAMS_DATABASE_URL"] +parsed = urlparse(db_url) +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': parsed.path.lstrip('/'), + 'USER': parsed.username, + 'PASSWORD': parsed.password, + 'HOST': parsed.hostname, + 'PORT': str(parsed.port) if parsed.port else '', + } +} + +# Internationalization +LANGUAGE_CODE = 'ru-ru' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +# Static files +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# CORS +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = ['https://optovia.ru'] +CORS_ALLOW_CREDENTIALS = True + +# Logging +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'django.request': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + }, + }, +} + +# Logto JWT settings +LOGTO_JWKS_URL = os.getenv('LOGTO_JWKS_URL', 'https://auth.optovia.ru/oidc/jwks') +LOGTO_ISSUER = os.getenv('LOGTO_ISSUER', 'https://auth.optovia.ru/oidc') +LOGTO_TEAMS_AUDIENCE = os.getenv('LOGTO_TEAMS_AUDIENCE', 'https://teams.optovia.ru') +# ID Token audience can be omitted when we only need signature + issuer validation. +LOGTO_ID_TOKEN_AUDIENCE = os.getenv('LOGTO_ID_TOKEN_AUDIENCE') + +# Odoo connection (internal M2M) +ODOO_INTERNAL_URL = os.getenv('ODOO_INTERNAL_URL', 'odoo:8069') diff --git a/teams/settings_local.py b/teams/settings_local.py new file mode 100644 index 0000000..c5e2b7c --- /dev/null +++ b/teams/settings_local.py @@ -0,0 +1,71 @@ +# Local settings for makemigrations (no Infisical, SQLite) +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = 'local-dev-key' +DEBUG = True +ALLOWED_HOSTS = ['*'] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'corsheaders', + 'graphene_django', + 'teams_app', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'teams.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +LANGUAGE_CODE = 'ru-ru' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# CORS +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = ['http://localhost:3000', 'https://optovia.ru'] +CORS_ALLOW_CREDENTIALS = True diff --git a/teams/urls.py b/teams/urls.py new file mode 100644 index 0000000..e019bb2 --- /dev/null +++ b/teams/urls.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from django.urls import path +from django.views.decorators.csrf import csrf_exempt +from teams_app.views import test_jwt, PublicGraphQLView, UserGraphQLView, TeamGraphQLView, M2MGraphQLView +from teams_app.schemas.public_schema import public_schema +from teams_app.schemas.user_schema import user_schema +from teams_app.schemas.team_schema import team_schema +from teams_app.schemas.m2m_schema import m2m_schema + +urlpatterns = [ + path('admin/', admin.site.urls), + path('graphql/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=public_schema))), + path('graphql/user/', csrf_exempt(UserGraphQLView.as_view(graphiql=True, schema=user_schema))), + path('graphql/team/', csrf_exempt(TeamGraphQLView.as_view(graphiql=True, schema=team_schema))), + path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=True, schema=m2m_schema))), + path('test-jwt/', test_jwt, name='test_jwt'), +] \ No newline at end of file diff --git a/teams/wsgi.py b/teams/wsgi.py new file mode 100644 index 0000000..66d0000 --- /dev/null +++ b/teams/wsgi.py @@ -0,0 +1,6 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'teams.settings') + +application = get_wsgi_application() \ No newline at end of file diff --git a/teams_app/__init__.py b/teams_app/__init__.py new file mode 100644 index 0000000..6c1ab79 --- /dev/null +++ b/teams_app/__init__.py @@ -0,0 +1 @@ +# Orders Django app \ No newline at end of file diff --git a/teams_app/__pycache__/__init__.cpython-313.pyc b/teams_app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c442b4f Binary files /dev/null and b/teams_app/__pycache__/__init__.cpython-313.pyc differ diff --git a/teams_app/__pycache__/admin.cpython-313.pyc b/teams_app/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000..a0cdb90 Binary files /dev/null and b/teams_app/__pycache__/admin.cpython-313.pyc differ diff --git a/teams_app/__pycache__/apps.cpython-313.pyc b/teams_app/__pycache__/apps.cpython-313.pyc new file mode 100644 index 0000000..7702890 Binary files /dev/null and b/teams_app/__pycache__/apps.cpython-313.pyc differ diff --git a/teams_app/__pycache__/auth.cpython-313.pyc b/teams_app/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..93d5c1b Binary files /dev/null and b/teams_app/__pycache__/auth.cpython-313.pyc differ diff --git a/teams_app/__pycache__/graphql_middleware.cpython-313.pyc b/teams_app/__pycache__/graphql_middleware.cpython-313.pyc new file mode 100644 index 0000000..b8fbc4a Binary files /dev/null and b/teams_app/__pycache__/graphql_middleware.cpython-313.pyc differ diff --git a/teams_app/__pycache__/models.cpython-313.pyc b/teams_app/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..793ceda Binary files /dev/null and b/teams_app/__pycache__/models.cpython-313.pyc differ diff --git a/teams_app/__pycache__/permissions.cpython-313.pyc b/teams_app/__pycache__/permissions.cpython-313.pyc new file mode 100644 index 0000000..1f75a9b Binary files /dev/null and b/teams_app/__pycache__/permissions.cpython-313.pyc differ diff --git a/teams_app/__pycache__/views.cpython-313.pyc b/teams_app/__pycache__/views.cpython-313.pyc new file mode 100644 index 0000000..5614d6e Binary files /dev/null and b/teams_app/__pycache__/views.cpython-313.pyc differ diff --git a/teams_app/admin.py b/teams_app/admin.py new file mode 100644 index 0000000..ddf7ce9 --- /dev/null +++ b/teams_app/admin.py @@ -0,0 +1,45 @@ +from django.contrib import admin +from .models import Team, TeamMember, TeamInvitation, TeamInvitationToken, UserProfile, TeamAddress + +@admin.register(Team) +class TeamAdmin(admin.ModelAdmin): + list_display = ('uuid', 'name', 'owner', 'logto_org_id', 'created_at') + list_filter = ('created_at',) + search_fields = ('name', 'uuid', 'owner__username', 'owner__profile__logto_id', 'logto_org_id') + readonly_fields = ('uuid', 'created_at', 'updated_at') + +@admin.register(TeamMember) +class TeamMemberAdmin(admin.ModelAdmin): + list_display = ('uuid', 'team', 'user', 'role', 'joined_at') + list_filter = ('role', 'joined_at') + search_fields = ('user__username', 'user__profile__logto_id', 'uuid', 'team__name') + readonly_fields = ('uuid', 'joined_at') + +@admin.register(TeamInvitation) +class TeamInvitationAdmin(admin.ModelAdmin): + list_display = ('uuid', 'team', 'email', 'role', 'status', 'invited_by', 'expires_at') + list_filter = ('role', 'status', 'expires_at') + search_fields = ('email', 'uuid', 'team__name', 'invited_by') + readonly_fields = ('uuid', 'created_at') + + +@admin.register(TeamInvitationToken) +class TeamInvitationTokenAdmin(admin.ModelAdmin): + list_display = ('uuid', 'invitation', 'workflow_status', 'expires_at', 'created_at') + list_filter = ('workflow_status', 'expires_at') + search_fields = ('uuid', 'invitation__email', 'invitation__team__name') + readonly_fields = ('uuid', 'created_at') + + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ('logto_id', 'user', 'active_team', 'created_at') + search_fields = ('logto_id', 'user__username', 'active_team__name') + + +@admin.register(TeamAddress) +class TeamAddressAdmin(admin.ModelAdmin): + list_display = ('uuid', 'team', 'name', 'address', 'status', 'country_code', 'created_at') + list_filter = ('status', 'country_code', 'created_at') + search_fields = ('name', 'address', 'uuid', 'team__name') + readonly_fields = ('uuid', 'created_at', 'updated_at', 'processed_at') diff --git a/teams_app/apps.py b/teams_app/apps.py new file mode 100644 index 0000000..9470db8 --- /dev/null +++ b/teams_app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class TeamsAppConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'teams_app' \ No newline at end of file diff --git a/teams_app/auth.py b/teams_app/auth.py new file mode 100644 index 0000000..986b60d --- /dev/null +++ b/teams_app/auth.py @@ -0,0 +1,70 @@ +import logging +from typing import Iterable, Optional + +import jwt +from django.conf import settings +from jwt import InvalidTokenError, PyJWKClient + + +logger = logging.getLogger(__name__) + + +class LogtoTokenValidator: + """Validate JWTs issued by Logto using the published JWKS.""" + + def __init__(self, jwks_url: str, issuer: str): + self._issuer = issuer + self._jwks_client = PyJWKClient(jwks_url) + + def decode( + self, + token: str, + audience: Optional[str] = None, + ) -> dict: + """Decode and verify a JWT, enforcing issuer and optional audience.""" + try: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + header_alg = jwt.get_unverified_header(token).get("alg") + + return jwt.decode( + token, + signing_key.key, + algorithms=[header_alg] if header_alg else None, + issuer=self._issuer, + audience=audience, + options={"verify_aud": audience is not None}, + ) + except InvalidTokenError as exc: + logger.warning("Failed to validate Logto token: %s", exc) + raise + + +def get_bearer_token(request) -> str: + """Extract Bearer token from Authorization header.""" + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + if not auth_header.startswith("Bearer "): + raise InvalidTokenError("Missing Bearer token") + + token = auth_header.split(" ", 1)[1] + if not token or token == "undefined": + raise InvalidTokenError("Empty Bearer token") + + return token + + +def scopes_from_payload(payload: dict) -> list[str]: + """Split scope string (if present) into a list.""" + scope_value = payload.get("scope") + if not scope_value: + return [] + if isinstance(scope_value, str): + return scope_value.split() + if isinstance(scope_value, Iterable): + return list(scope_value) + return [] + + +validator = LogtoTokenValidator( + getattr(settings, "LOGTO_JWKS_URL", "https://auth.optovia.ru/oidc/jwks"), + getattr(settings, "LOGTO_ISSUER", "https://auth.optovia.ru/oidc"), +) diff --git a/teams_app/graphql_middleware.py b/teams_app/graphql_middleware.py new file mode 100644 index 0000000..f1ce978 --- /dev/null +++ b/teams_app/graphql_middleware.py @@ -0,0 +1,81 @@ +""" +GraphQL middleware for JWT authentication. + +Each class is bound to a specific GraphQL endpoint (public/user/team/m2m). +""" +import logging +from django.conf import settings +from graphql import GraphQLError +from jwt import InvalidTokenError + +from .auth import get_bearer_token, scopes_from_payload, validator + +logger = logging.getLogger(__name__) + + +def _is_introspection(info) -> bool: + """Возвращает True для любых introspection резолвов.""" + field = getattr(info, "field_name", "") + parent = getattr(getattr(info, "parent_type", None), "name", "") + return field.startswith("__") or parent.startswith("__") + + +class PublicNoAuthMiddleware: + """Public endpoint - no authentication required.""" + + def resolve(self, next, root, info, **kwargs): + return next(root, info, **kwargs) + + +class UserJWTMiddleware: + """User endpoint - requires ID token.""" + + def resolve(self, next, root, info, **kwargs): + request = info.context + if _is_introspection(info): + return next(root, info, **kwargs) + + # Only process auth once (check if already processed) + if not hasattr(request, 'user_id'): + try: + token = get_bearer_token(request) + payload = validator.decode(token) + request.user_id = payload.get('sub') + logger.info(f"[UserJWTMiddleware] user_id set to: {request.user_id}") + except InvalidTokenError as exc: + logger.warning(f"[UserJWTMiddleware] Token error: {exc}") + raise GraphQLError("Unauthorized") from exc + + return next(root, info, **kwargs) + + +class TeamJWTMiddleware: + """Team endpoint - requires Access token for teams audience.""" + + def resolve(self, next, root, info, **kwargs): + request = info.context + if _is_introspection(info): + return next(root, info, **kwargs) + + try: + token = get_bearer_token(request) + payload = validator.decode( + token, + audience=getattr(settings, 'LOGTO_TEAMS_AUDIENCE', None), + ) + request.user_id = payload.get('sub') + request.team_uuid = payload.get('team_uuid') + request.scopes = scopes_from_payload(payload) + if not request.team_uuid or 'teams:member' not in request.scopes: + raise GraphQLError("Unauthorized") + except InvalidTokenError as exc: + raise GraphQLError("Unauthorized") from exc + + return next(root, info, **kwargs) + + +class M2MNoAuthMiddleware: + """M2M endpoint - internal services only, no auth for now.""" + + def resolve(self, next, root, info, **kwargs): + return next(root, info, **kwargs) diff --git a/teams_app/middleware.py b/teams_app/middleware.py new file mode 100644 index 0000000..62357f3 --- /dev/null +++ b/teams_app/middleware.py @@ -0,0 +1,56 @@ +import json +import logging + +from django.conf import settings +from django.utils.deprecation import MiddlewareMixin +from jwt import InvalidTokenError + +from .auth import get_bearer_token, scopes_from_payload, validator + +logger = logging.getLogger(__name__) + +class LogtoJWTMiddleware(MiddlewareMixin): + """ + JWT middleware для проверки токенов от Logto + """ + + def __init__(self, get_response=None): + super().__init__(get_response) + + # Audience validated only for non-introspection API calls + self.audience = getattr(settings, "LOGTO_TEAMS_AUDIENCE", None) + + def _is_introspection_query(self, request): + """Проверяет, является ли запрос introspection (для GraphQL codegen)""" + if request.method != 'POST': + return False + try: + body = json.loads(request.body.decode('utf-8')) + query = body.get('query', '') + return '__schema' in query or '__type' in query + except Exception: + return False + + def process_request(self, request): + """Обрабатывает JWT токен из заголовка Authorization""" + + # Пропускаем проверку для admin панели и статики + if request.path.startswith('/admin/') or request.path.startswith('/static/'): + return None + + # Пропускаем introspection запросы (для GraphQL codegen) + if self._is_introspection_query(request): + return None + + try: + token = get_bearer_token(request) + payload = validator.decode(token, audience=self.audience) + + request.user_id = payload.get('sub') + request.team_uuid = payload.get('team_uuid') + request.scopes = scopes_from_payload(payload) + + except InvalidTokenError: + return None + + return None diff --git a/teams_app/migrations/0001_initial.py b/teams_app/migrations/0001_initial.py new file mode 100644 index 0000000..c275c55 --- /dev/null +++ b/teams_app/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.8 on 2025-12-04 08:11 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)), + ('name', models.CharField(max_length=255)), + ('logto_org_id', models.CharField(blank=True, max_length=255, null=True)), + ('status', models.CharField(choices=[('PENDING_KYC', 'Требуется KYC'), ('KYC_IN_REVIEW', 'KYC на рассмотрении'), ('KYC_APPROVED', 'KYC одобрен'), ('KYC_REJECTED', 'KYC отклонен'), ('SUSPENDED', 'Заблокировано')], default='PENDING_KYC', max_length=50)), + ('prefect_flow_run_id', models.CharField(blank=True, max_length=255, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_teams', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'teams_team', + }, + ), + migrations.CreateModel( + name='TeamInvitation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)), + ('email', models.EmailField(max_length=254)), + ('role', models.CharField(choices=[('OWNER', 'Владелец'), ('ADMIN', 'Администратор'), ('MANAGER', 'Менеджер'), ('MEMBER', 'Участник')], default='MEMBER', max_length=50)), + ('status', models.CharField(choices=[('PENDING', 'Ожидает ответа'), ('ACCEPTED', 'Принято'), ('DECLINED', 'Отклонено'), ('EXPIRED', 'Истекло')], default='PENDING', max_length=50)), + ('invited_by', models.CharField(max_length=255)), + ('expires_at', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='teams_app.team')), + ], + options={ + 'db_table': 'teams_invitation', + 'unique_together': {('team', 'email')}, + }, + ), + migrations.CreateModel( + name='TeamMember', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)), + ('role', models.CharField(choices=[('OWNER', 'Владелец'), ('ADMIN', 'Администратор'), ('MANAGER', 'Менеджер'), ('MEMBER', 'Участник')], default='MEMBER', max_length=50)), + ('joined_at', models.DateTimeField(auto_now_add=True)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='teams_app.team')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'teams_member', + 'unique_together': {('team', 'user')}, + }, + ), + ] diff --git a/teams_app/migrations/0002_add_user_profile.py b/teams_app/migrations/0002_add_user_profile.py new file mode 100644 index 0000000..49e3cff --- /dev/null +++ b/teams_app/migrations/0002_add_user_profile.py @@ -0,0 +1,30 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('logto_id', models.CharField(max_length=255, unique=True)), + ('avatar_id', models.CharField(blank=True, max_length=100, null=True)), + ('phone', models.CharField(blank=True, max_length=20, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('active_team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_profiles', to='teams_app.team')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'teams_user_profile', + }, + ), + ] diff --git a/teams_app/migrations/0003_add_team_type.py b/teams_app/migrations/0003_add_team_type.py new file mode 100644 index 0000000..c21f6f1 --- /dev/null +++ b/teams_app/migrations/0003_add_team_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-08 09:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0002_add_user_profile'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='team_type', + field=models.CharField(choices=[('BUYER', 'Покупатель'), ('SELLER', 'Продавец')], default='BUYER', max_length=20), + ), + ] diff --git a/teams_app/migrations/0004_teamaddress.py b/teams_app/migrations/0004_teamaddress.py new file mode 100644 index 0000000..153d876 --- /dev/null +++ b/teams_app/migrations/0004_teamaddress.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.8 on 2025-12-09 03:18 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0003_add_team_type'), + ] + + operations = [ + migrations.CreateModel( + name='TeamAddress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)), + ('name', models.CharField(max_length=255)), + ('address', models.TextField()), + ('latitude', models.FloatField(blank=True, null=True)), + ('longitude', models.FloatField(blank=True, null=True)), + ('is_default', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='teams_app.team')), + ], + options={ + 'db_table': 'teams_address', + }, + ), + ] diff --git a/teams_app/migrations/0005_remove_team_prefect_flow_run_id.py b/teams_app/migrations/0005_remove_team_prefect_flow_run_id.py new file mode 100644 index 0000000..4989a8b --- /dev/null +++ b/teams_app/migrations/0005_remove_team_prefect_flow_run_id.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.8 on 2025-12-16 01:42 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0004_teamaddress'), + ] + + operations = [ + migrations.RemoveField( + model_name='team', + name='prefect_flow_run_id', + ), + ] diff --git a/teams_app/migrations/0006_add_address_status_and_selected_location.py b/teams_app/migrations/0006_add_address_status_and_selected_location.py new file mode 100644 index 0000000..9b83b1d --- /dev/null +++ b/teams_app/migrations/0006_add_address_status_and_selected_location.py @@ -0,0 +1,59 @@ +# Generated manually + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0005_remove_team_prefect_flow_run_id'), + ] + + operations = [ + # TeamAddress fields + migrations.AddField( + model_name='teamaddress', + name='status', + field=models.CharField( + choices=[ + ('pending', 'Ожидает обработки'), + ('processing', 'Обрабатывается'), + ('ready', 'Готов'), + ('error', 'Ошибка'), + ], + default='pending', + max_length=20, + ), + ), + migrations.AddField( + model_name='teamaddress', + name='graph_node_id', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='teamaddress', + name='processed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='teamaddress', + name='error_message', + field=models.TextField(blank=True, null=True), + ), + # Team selected location fields + migrations.AddField( + model_name='team', + name='selected_location_type', + field=models.CharField( + blank=True, + choices=[('address', 'Адрес'), ('hub', 'Хаб')], + max_length=20, + null=True, + ), + ), + migrations.AddField( + model_name='team', + name='selected_location_uuid', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/teams_app/migrations/0007_teamaddress_country_code.py b/teams_app/migrations/0007_teamaddress_country_code.py new file mode 100644 index 0000000..a015a37 --- /dev/null +++ b/teams_app/migrations/0007_teamaddress_country_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-16 12:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0006_add_address_status_and_selected_location'), + ] + + operations = [ + migrations.AddField( + model_name='teamaddress', + name='country_code', + field=models.CharField(blank=True, max_length=2, null=True), + ), + ] diff --git a/teams_app/migrations/0008_remove_graph_node_id_and_change_default_status.py b/teams_app/migrations/0008_remove_graph_node_id_and_change_default_status.py new file mode 100644 index 0000000..6b3a688 --- /dev/null +++ b/teams_app/migrations/0008_remove_graph_node_id_and_change_default_status.py @@ -0,0 +1,31 @@ +# Generated manually + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0007_teamaddress_country_code'), + ] + + operations = [ + migrations.RemoveField( + model_name='teamaddress', + name='graph_node_id', + ), + migrations.AlterField( + model_name='teamaddress', + name='status', + field=models.CharField( + choices=[ + ('pending', 'Ожидает обработки'), + ('processing', 'Обрабатывается'), + ('ready', 'Готов'), + ('error', 'Ошибка') + ], + default='processing', + max_length=20 + ), + ), + ] diff --git a/teams_app/migrations/0009_alter_teamaddress_status.py b/teams_app/migrations/0009_alter_teamaddress_status.py new file mode 100644 index 0000000..e78c897 --- /dev/null +++ b/teams_app/migrations/0009_alter_teamaddress_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-30 02:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0008_remove_graph_node_id_and_change_default_status'), + ] + + operations = [ + migrations.AlterField( + model_name='teamaddress', + name='status', + field=models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20), + ), + ] diff --git a/teams_app/migrations/0010_remove_team_status.py b/teams_app/migrations/0010_remove_team_status.py new file mode 100644 index 0000000..4c97a8e --- /dev/null +++ b/teams_app/migrations/0010_remove_team_status.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.8 on 2025-12-30 03:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0009_alter_teamaddress_status'), + ] + + operations = [ + migrations.RemoveField( + model_name='team', + name='status', + ), + ] diff --git a/teams_app/migrations/0011_teaminvitationtoken.py b/teams_app/migrations/0011_teaminvitationtoken.py new file mode 100644 index 0000000..81e9409 --- /dev/null +++ b/teams_app/migrations/0011_teaminvitationtoken.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.8 on 2025-12-30 07:43 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0010_remove_team_status'), + ] + + operations = [ + migrations.CreateModel( + name='TeamInvitationToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)), + ('token_hash', models.CharField(max_length=255, unique=True)), + ('workflow_status', models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20)), + ('expires_at', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('invitation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to='teams_app.teaminvitation')), + ], + options={ + 'db_table': 'teams_invitation_token', + }, + ), + ] diff --git a/teams_app/migrations/0012_add_selected_location_details.py b/teams_app/migrations/0012_add_selected_location_details.py new file mode 100644 index 0000000..cc2dc81 --- /dev/null +++ b/teams_app/migrations/0012_add_selected_location_details.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.8 on 2026-01-03 05:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams_app', '0011_teaminvitationtoken'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='selected_location_latitude', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='team', + name='selected_location_longitude', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='team', + name='selected_location_name', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/teams_app/migrations/__init__.py b/teams_app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/teams_app/migrations/__pycache__/0001_initial.cpython-313.pyc b/teams_app/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 0000000..7ccbe3a Binary files /dev/null and b/teams_app/migrations/__pycache__/0001_initial.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0002_add_user_profile.cpython-313.pyc b/teams_app/migrations/__pycache__/0002_add_user_profile.cpython-313.pyc new file mode 100644 index 0000000..248a191 Binary files /dev/null and b/teams_app/migrations/__pycache__/0002_add_user_profile.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0003_add_team_type.cpython-313.pyc b/teams_app/migrations/__pycache__/0003_add_team_type.cpython-313.pyc new file mode 100644 index 0000000..08143a7 Binary files /dev/null and b/teams_app/migrations/__pycache__/0003_add_team_type.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0004_teamaddress.cpython-313.pyc b/teams_app/migrations/__pycache__/0004_teamaddress.cpython-313.pyc new file mode 100644 index 0000000..3aa9c13 Binary files /dev/null and b/teams_app/migrations/__pycache__/0004_teamaddress.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0005_remove_team_prefect_flow_run_id.cpython-313.pyc b/teams_app/migrations/__pycache__/0005_remove_team_prefect_flow_run_id.cpython-313.pyc new file mode 100644 index 0000000..c618b35 Binary files /dev/null and b/teams_app/migrations/__pycache__/0005_remove_team_prefect_flow_run_id.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0006_add_address_status_and_selected_location.cpython-313.pyc b/teams_app/migrations/__pycache__/0006_add_address_status_and_selected_location.cpython-313.pyc new file mode 100644 index 0000000..769f700 Binary files /dev/null and b/teams_app/migrations/__pycache__/0006_add_address_status_and_selected_location.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0007_teamaddress_country_code.cpython-313.pyc b/teams_app/migrations/__pycache__/0007_teamaddress_country_code.cpython-313.pyc new file mode 100644 index 0000000..e14f9ce Binary files /dev/null and b/teams_app/migrations/__pycache__/0007_teamaddress_country_code.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0008_remove_graph_node_id_and_change_default_status.cpython-313.pyc b/teams_app/migrations/__pycache__/0008_remove_graph_node_id_and_change_default_status.cpython-313.pyc new file mode 100644 index 0000000..8dc1efd Binary files /dev/null and b/teams_app/migrations/__pycache__/0008_remove_graph_node_id_and_change_default_status.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0009_alter_teamaddress_status.cpython-313.pyc b/teams_app/migrations/__pycache__/0009_alter_teamaddress_status.cpython-313.pyc new file mode 100644 index 0000000..b091356 Binary files /dev/null and b/teams_app/migrations/__pycache__/0009_alter_teamaddress_status.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0010_remove_team_status.cpython-313.pyc b/teams_app/migrations/__pycache__/0010_remove_team_status.cpython-313.pyc new file mode 100644 index 0000000..d93351a Binary files /dev/null and b/teams_app/migrations/__pycache__/0010_remove_team_status.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/0011_teaminvitationtoken.cpython-313.pyc b/teams_app/migrations/__pycache__/0011_teaminvitationtoken.cpython-313.pyc new file mode 100644 index 0000000..f4f2c10 Binary files /dev/null and b/teams_app/migrations/__pycache__/0011_teaminvitationtoken.cpython-313.pyc differ diff --git a/teams_app/migrations/__pycache__/__init__.cpython-313.pyc b/teams_app/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..580b6d4 Binary files /dev/null and b/teams_app/migrations/__pycache__/__init__.cpython-313.pyc differ diff --git a/teams_app/models.py b/teams_app/models.py new file mode 100644 index 0000000..32bab42 --- /dev/null +++ b/teams_app/models.py @@ -0,0 +1,165 @@ +from django.conf import settings +from django.db import models +import uuid + +class Team(models.Model): + TEAM_TYPE_CHOICES = [ + ('BUYER', 'Покупатель'), + ('SELLER', 'Продавец'), + ] + + LOCATION_TYPE_CHOICES = [ + ('address', 'Адрес'), + ('hub', 'Хаб'), + ] + + uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4) + name = models.CharField(max_length=255) + team_type = models.CharField(max_length=20, choices=TEAM_TYPE_CHOICES, default='BUYER') + logto_org_id = models.CharField(max_length=255, null=True, blank=True) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='owned_teams', + null=True, + blank=True, + ) + selected_location_type = models.CharField(max_length=20, choices=LOCATION_TYPE_CHOICES, null=True, blank=True) + selected_location_uuid = models.CharField(max_length=100, null=True, blank=True) + selected_location_name = models.CharField(max_length=255, null=True, blank=True) + selected_location_latitude = models.FloatField(null=True, blank=True) + selected_location_longitude = models.FloatField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'teams_team' + + def __str__(self): + return f"Team {self.name}" + + +class UserProfile(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='profile' + ) + logto_id = models.CharField(max_length=255, unique=True) + avatar_id = models.CharField(max_length=100, blank=True, null=True) + phone = models.CharField(max_length=20, blank=True, null=True) + active_team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name='active_profiles') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'teams_user_profile' + + def __str__(self): + return f"Profile {self.logto_id}" + +class TeamMember(models.Model): + ROLE_CHOICES = [ + ('OWNER', 'Владелец'), + ('ADMIN', 'Администратор'), + ('MANAGER', 'Менеджер'), + ('MEMBER', 'Участник'), + ] + + uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4) + team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='members') + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='team_memberships', + null=True, + blank=True, + ) + role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='MEMBER') + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'teams_member' + unique_together = ['team', 'user'] + + def __str__(self): + return f"{self.team.name} - {self.user} ({self.role})" + +class TeamInvitation(models.Model): + INVITATION_STATUS_CHOICES = [ + ('PENDING', 'Ожидает ответа'), + ('ACCEPTED', 'Принято'), + ('DECLINED', 'Отклонено'), + ('EXPIRED', 'Истекло'), + ] + + ROLE_CHOICES = TeamMember.ROLE_CHOICES + + uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4) + team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='invitations') + email = models.EmailField() + role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='MEMBER') + status = models.CharField(max_length=50, choices=INVITATION_STATUS_CHOICES, default='PENDING') + invited_by = models.CharField(max_length=255) + expires_at = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'teams_invitation' + unique_together = ['team', 'email'] + + def __str__(self): + return f"Приглашение в {self.team.name} для {self.email}" + + +class TeamInvitationToken(models.Model): + WORKFLOW_STATUS_CHOICES = [ + ('pending', 'Ожидает обработки'), + ('active', 'Активен'), + ('error', 'Ошибка'), + ] + + uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4) + invitation = models.ForeignKey(TeamInvitation, on_delete=models.CASCADE, related_name='tokens') + token_hash = models.CharField(max_length=255, unique=True) + workflow_status = models.CharField( + max_length=20, + choices=WORKFLOW_STATUS_CHOICES, + default='pending', + ) + expires_at = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'teams_invitation_token' + + def __str__(self): + return f"Token {self.uuid} for invitation {self.invitation_id}" + + +class TeamAddress(models.Model): + ADDRESS_STATUS_CHOICES = [ + ('pending', 'Ожидает обработки'), + ('active', 'Активен'), + ('error', 'Ошибка'), + ] + + uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4) + team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='addresses') + name = models.CharField(max_length=255) # "Офис", "Склад", "Производство" + address = models.TextField() + latitude = models.FloatField(null=True, blank=True) + longitude = models.FloatField(null=True, blank=True) + country_code = models.CharField(max_length=2, null=True, blank=True) # ISO 3166-1 alpha-2 + is_default = models.BooleanField(default=False) + status = models.CharField(max_length=20, choices=ADDRESS_STATUS_CHOICES, default='pending') + processed_at = models.DateTimeField(null=True, blank=True) + error_message = models.TextField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'teams_address' + + def __str__(self): + return f"{self.team.name} - {self.name}" diff --git a/teams_app/permissions.py b/teams_app/permissions.py new file mode 100644 index 0000000..d44f02d --- /dev/null +++ b/teams_app/permissions.py @@ -0,0 +1,74 @@ +""" +Декоратор для проверки scopes в JWT токене. +Используется для защиты GraphQL резолверов. +""" +from functools import wraps +from graphql import GraphQLError + + +def require_scopes(*scopes: str): + """ + Декоратор для проверки наличия scopes в JWT токене. + + Использование: + @require_scopes("read:teams") + def resolve_team(self, info): + ... + + @require_scopes("read:teams", "write:teams") + def resolve_update_team(self, info): + ... + """ + def decorator(func): + # Сохраняем scopes в метаданных для возможности сбора всех scopes + if not hasattr(func, '_required_scopes'): + func._required_scopes = [] + func._required_scopes.extend(scopes) + + @wraps(func) + def wrapper(self, info, *args, **kwargs): + # Получаем scopes из контекста (должны быть добавлены в middleware) + user_scopes = set(getattr(info.context, 'scopes', []) or []) + + missing = set(scopes) - user_scopes + if missing: + raise GraphQLError(f"Missing required scopes: {', '.join(missing)}") + + return func(self, info, *args, **kwargs) + + # Переносим метаданные на wrapper + wrapper._required_scopes = func._required_scopes + return wrapper + return decorator + + +def collect_scopes_from_schema(schema) -> set: + """ + Собирает все scopes из схемы для синхронизации с Logto. + + Использование: + from .schema import schema + scopes = collect_scopes_from_schema(schema) + # {'read:team', 'invite:member', ...} + """ + scopes = set() + + # Query resolvers + if hasattr(schema, 'query') and schema.query: + query_type = schema.query + for field_name in dir(query_type): + if field_name.startswith('resolve_'): + resolver = getattr(query_type, field_name, None) + if resolver and hasattr(resolver, '_required_scopes'): + scopes.update(resolver._required_scopes) + + # Mutation resolvers + if hasattr(schema, 'mutation') and schema.mutation: + mutation_type = schema.mutation + for field_name, field in mutation_type._meta.fields.items(): + if hasattr(field, 'type') and hasattr(field.type, 'mutate'): + mutate = field.type.mutate + if hasattr(mutate, '_required_scopes'): + scopes.update(mutate._required_scopes) + + return scopes diff --git a/teams_app/schemas/__pycache__/m2m_schema.cpython-313.pyc b/teams_app/schemas/__pycache__/m2m_schema.cpython-313.pyc new file mode 100644 index 0000000..0e7a60f Binary files /dev/null and b/teams_app/schemas/__pycache__/m2m_schema.cpython-313.pyc differ diff --git a/teams_app/schemas/__pycache__/public_schema.cpython-313.pyc b/teams_app/schemas/__pycache__/public_schema.cpython-313.pyc new file mode 100644 index 0000000..aa3640d Binary files /dev/null and b/teams_app/schemas/__pycache__/public_schema.cpython-313.pyc differ diff --git a/teams_app/schemas/__pycache__/team_schema.cpython-313.pyc b/teams_app/schemas/__pycache__/team_schema.cpython-313.pyc new file mode 100644 index 0000000..8b2a442 Binary files /dev/null and b/teams_app/schemas/__pycache__/team_schema.cpython-313.pyc differ diff --git a/teams_app/schemas/__pycache__/user_schema.cpython-313.pyc b/teams_app/schemas/__pycache__/user_schema.cpython-313.pyc new file mode 100644 index 0000000..99ec439 Binary files /dev/null and b/teams_app/schemas/__pycache__/user_schema.cpython-313.pyc differ diff --git a/teams_app/schemas/m2m_schema.py b/teams_app/schemas/m2m_schema.py new file mode 100644 index 0000000..9615bf3 --- /dev/null +++ b/teams_app/schemas/m2m_schema.py @@ -0,0 +1,329 @@ +""" +M2M (Machine-to-Machine) GraphQL schema. +Used by internal services (Temporal workflows, etc.) without user authentication. +""" +import graphene +import logging +from django.utils import timezone +from graphene_django import DjangoObjectType +from ..models import Team as TeamModel, TeamMember as TeamMemberModel, TeamAddress as TeamAddressModel, TeamInvitation as TeamInvitationModel, TeamInvitationToken as TeamInvitationTokenModel +from .user_schema import _get_or_create_user_with_profile + +logger = logging.getLogger(__name__) + + +class Team(DjangoObjectType): + id = graphene.String() + + class Meta: + model = TeamModel + fields = ('uuid', 'name', 'logto_org_id', 'created_at', 'updated_at') + + def resolve_id(self, info): + return self.uuid + + +class M2MQuery(graphene.ObjectType): + team = graphene.Field(Team, teamId=graphene.String(required=True)) + invitation = graphene.Field(lambda: TeamInvitation, invitationUuid=graphene.String(required=True)) + + def resolve_team(self, info, teamId): + try: + return TeamModel.objects.get(uuid=teamId) + except TeamModel.DoesNotExist: + return None + + def resolve_invitation(self, info, invitationUuid): + try: + return TeamInvitationModel.objects.get(uuid=invitationUuid) + except TeamInvitationModel.DoesNotExist: + return None + + +class TeamInvitation(DjangoObjectType): + class Meta: + model = TeamInvitationModel + fields = ('uuid', 'team', 'email', 'role', 'status', 'invited_by', 'expires_at', 'created_at') + + +class TeamInvitationToken(DjangoObjectType): + class Meta: + model = TeamInvitationTokenModel + fields = ('uuid', 'invitation', 'workflow_status', 'expires_at', 'created_at') + + +class CreateInvitationFromWorkflowInput(graphene.InputObjectType): + teamUuid = graphene.String(required=True) + email = graphene.String(required=True) + role = graphene.String() + invitedBy = graphene.String(required=True) + expiresAt = graphene.DateTime(required=True) + + +class CreateInvitationFromWorkflow(graphene.Mutation): + class Arguments: + input = CreateInvitationFromWorkflowInput(required=True) + + success = graphene.Boolean() + message = graphene.String() + invitationUuid = graphene.String() + invitation = graphene.Field(TeamInvitation) + + def mutate(self, info, input): + try: + team = TeamModel.objects.get(uuid=input.teamUuid) + invitation = TeamInvitationModel.objects.create( + team=team, + email=input.email, + role=input.role or 'MEMBER', + status='PENDING', + invited_by=input.invitedBy, + expires_at=input.expiresAt, + ) + return CreateInvitationFromWorkflow( + success=True, + message="Invitation created", + invitationUuid=invitation.uuid, + invitation=invitation, + ) + except Exception as exc: + logger.exception("Failed to create invitation") + return CreateInvitationFromWorkflow(success=False, message=str(exc)) + + +class CreateInvitationTokenInput(graphene.InputObjectType): + invitationUuid = graphene.String(required=True) + tokenHash = graphene.String(required=True) + expiresAt = graphene.DateTime(required=True) + + +class CreateInvitationToken(graphene.Mutation): + class Arguments: + input = CreateInvitationTokenInput(required=True) + + success = graphene.Boolean() + message = graphene.String() + token = graphene.Field(TeamInvitationToken) + + def mutate(self, info, input): + try: + invitation = TeamInvitationModel.objects.get(uuid=input.invitationUuid) + token = TeamInvitationTokenModel.objects.create( + invitation=invitation, + token_hash=input.tokenHash, + expires_at=input.expiresAt, + workflow_status='pending', + ) + return CreateInvitationToken(success=True, message="Token created", token=token) + except Exception as exc: + logger.exception("Failed to create invitation token") + return CreateInvitationToken(success=False, message=str(exc)) + + +class UpdateInvitationTokenStatusInput(graphene.InputObjectType): + tokenUuid = graphene.String(required=True) + status = graphene.String(required=True) # pending | active | error + + +class UpdateInvitationTokenStatus(graphene.Mutation): + class Arguments: + input = UpdateInvitationTokenStatusInput(required=True) + + success = graphene.Boolean() + message = graphene.String() + token = graphene.Field(TeamInvitationToken) + + def mutate(self, info, input): + try: + token = TeamInvitationTokenModel.objects.get(uuid=input.tokenUuid) + token.workflow_status = input.status + token.save(update_fields=['workflow_status']) + return UpdateInvitationTokenStatus(success=True, message="Status updated", token=token) + except TeamInvitationTokenModel.DoesNotExist: + return UpdateInvitationTokenStatus(success=False, message="Token not found") + + +class SetLogtoOrgIdMutation(graphene.Mutation): + """Set Logto organization ID (used by Temporal workflows)""" + class Arguments: + teamId = graphene.String(required=True) + logtoOrgId = graphene.String(required=True) + + team = graphene.Field(Team) + success = graphene.Boolean() + + def mutate(self, info, teamId, logtoOrgId): + try: + team = TeamModel.objects.get(uuid=teamId) + team.logto_org_id = logtoOrgId + team.save(update_fields=['logto_org_id']) + logger.info("Team %s: logto_org_id set to %s", teamId, logtoOrgId) + return SetLogtoOrgIdMutation(team=team, success=True) + except TeamModel.DoesNotExist: + raise Exception(f"Team {teamId} not found") + + +class CreateAddressFromWorkflowMutation(graphene.Mutation): + """Create TeamAddress from Temporal workflow (workflow-first pattern)""" + class Arguments: + workflowId = graphene.String(required=True) + teamUuid = graphene.String(required=True) + name = graphene.String(required=True) + address = graphene.String(required=True) + latitude = graphene.Float() + longitude = graphene.Float() + countryCode = graphene.String() + isDefault = graphene.Boolean() + + success = graphene.Boolean() + addressUuid = graphene.String() + teamType = graphene.String() # "buyer" or "seller" + message = graphene.String() + + def mutate(self, info, workflowId, teamUuid, name, address, + latitude=None, longitude=None, countryCode=None, isDefault=False): + try: + team = TeamModel.objects.get(uuid=teamUuid) + + # Если новый адрес default - сбрасываем старые + if isDefault: + team.addresses.update(is_default=False) + + address_obj = TeamAddressModel.objects.create( + team=team, + name=name, + address=address, + latitude=latitude, + longitude=longitude, + country_code=countryCode, + is_default=isDefault, + status='pending', + ) + + logger.info( + "Created address %s for team %s (workflow=%s, team_type=%s)", + address_obj.uuid, teamUuid, workflowId, team.team_type + ) + + return CreateAddressFromWorkflowMutation( + success=True, + addressUuid=str(address_obj.uuid), + teamType=team.team_type.lower() if team.team_type else "buyer", + message="Address created" + ) + except TeamModel.DoesNotExist: + return CreateAddressFromWorkflowMutation( + success=False, + message=f"Team {teamUuid} not found" + ) + except Exception as e: + logger.error("Failed to create address: %s", e) + return CreateAddressFromWorkflowMutation( + success=False, + message=str(e) + ) + + +class CreateTeamFromWorkflowMutation(graphene.Mutation): + """Create Team from Temporal workflow (KYC approval flow)""" + class Arguments: + teamName = graphene.String(required=True) + ownerId = graphene.String(required=True) # Logto user ID + teamType = graphene.String() # BUYER | SELLER, default BUYER + countryCode = graphene.String() + + success = graphene.Boolean() + teamId = graphene.String() + teamUuid = graphene.String() + message = graphene.String() + + def mutate(self, info, teamName, ownerId, teamType=None, countryCode=None): + try: + # Получаем или создаём пользователя + owner = _get_or_create_user_with_profile(ownerId) + + # Создаём команду + team = TeamModel.objects.create( + name=teamName, + owner=owner, + team_type=teamType or 'BUYER' + ) + + # Добавляем owner как участника команды с ролью OWNER + TeamMemberModel.objects.create( + team=team, + user=owner, + role='OWNER' + ) + + # Устанавливаем как активную команду + if hasattr(owner, 'profile') and not owner.profile.active_team: + owner.profile.active_team = team + owner.profile.save(update_fields=['active_team']) + + logger.info( + "Created team %s (%s) for owner %s from workflow", + team.uuid, teamName, ownerId + ) + + return CreateTeamFromWorkflowMutation( + success=True, + teamId=str(team.id), + teamUuid=str(team.uuid), + message="Team created" + ) + except Exception as e: + logger.error("Failed to create team from workflow: %s", e) + return CreateTeamFromWorkflowMutation( + success=False, + message=str(e) + ) + + +class UpdateAddressStatusMutation(graphene.Mutation): + """Update address processing status (used by Temporal workflows)""" + class Arguments: + addressUuid = graphene.String(required=True) + status = graphene.String(required=True) # pending, active, error + errorMessage = graphene.String() + + success = graphene.Boolean() + message = graphene.String() + + def mutate(self, info, addressUuid, status, errorMessage=None): + try: + address = TeamAddressModel.objects.get(uuid=addressUuid) + address.status = status + update_fields = ['status', 'updated_at'] + + if errorMessage: + address.error_message = errorMessage + update_fields.append('error_message') + + if status == 'active': + address.processed_at = timezone.now() + update_fields.append('processed_at') + + address.save(update_fields=update_fields) + + logger.info("Address %s status updated to %s", addressUuid, status) + + return UpdateAddressStatusMutation(success=True, message="Status updated") + except TeamAddressModel.DoesNotExist: + return UpdateAddressStatusMutation( + success=False, + message=f"Address {addressUuid} not found" + ) + + +class M2MMutation(graphene.ObjectType): + setLogtoOrgId = SetLogtoOrgIdMutation.Field() + createTeamFromWorkflow = CreateTeamFromWorkflowMutation.Field() + createAddressFromWorkflow = CreateAddressFromWorkflowMutation.Field() + updateAddressStatus = UpdateAddressStatusMutation.Field() + createInvitationFromWorkflow = CreateInvitationFromWorkflow.Field() + createInvitationToken = CreateInvitationToken.Field() + updateInvitationTokenStatus = UpdateInvitationTokenStatus.Field() + + +m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation) diff --git a/teams_app/schemas/public_schema.py b/teams_app/schemas/public_schema.py new file mode 100644 index 0000000..e773dce --- /dev/null +++ b/teams_app/schemas/public_schema.py @@ -0,0 +1,12 @@ +import graphene + + +class PublicQuery(graphene.ObjectType): + """Public schema - no authentication required""" + _placeholder = graphene.String(description="Placeholder field") + + def resolve__placeholder(self, info): + return None + + +public_schema = graphene.Schema(query=PublicQuery) diff --git a/teams_app/schemas/team_schema.py b/teams_app/schemas/team_schema.py new file mode 100644 index 0000000..813e2ae --- /dev/null +++ b/teams_app/schemas/team_schema.py @@ -0,0 +1,367 @@ +import graphene +from django.utils import timezone +from datetime import timedelta +from graphene_django import DjangoObjectType +from django.contrib.auth import get_user_model +from ..models import Team as TeamModel, TeamMember as TeamMemberModel, TeamAddress as TeamAddressModel +from ..permissions import require_scopes + +UserModel = get_user_model() + +class User(DjangoObjectType): + id = graphene.String() + firstName = graphene.String() + lastName = graphene.String() + phone = graphene.String() + avatarId = graphene.String() + createdAt = graphene.String() + + class Meta: + model = UserModel + fields = ('username', 'first_name', 'last_name', 'email') + + def resolve_id(self, info): + if hasattr(self, 'profile') and self.profile: + return self.profile.logto_id + return self.username + + def resolve_firstName(self, info): + return self.first_name + + def resolve_lastName(self, info): + return self.last_name + + def resolve_phone(self, info): + return getattr(self.profile, 'phone', None) + + def resolve_avatarId(self, info): + return getattr(self.profile, 'avatar_id', None) + + def resolve_createdAt(self, info): + return self.date_joined.isoformat() if self.date_joined else None + +class TeamMember(DjangoObjectType): + user = graphene.Field(User) + joinedAt = graphene.String() + + class Meta: + model = TeamMemberModel + fields = ('role', 'joined_at') + + def resolve_user(self, info): + return self.user + + def resolve_joinedAt(self, info): + return self.joined_at.isoformat() if self.joined_at else None + +class TeamAddress(DjangoObjectType): + isDefault = graphene.Boolean() + createdAt = graphene.String() + graphNodeId = graphene.String() + processedAt = graphene.String() + countryCode = graphene.String() + + class Meta: + model = TeamAddressModel + fields = ('uuid', 'name', 'address', 'latitude', 'longitude', 'is_default', 'created_at', 'country_code') + + def resolve_isDefault(self, info): + return self.is_default + + def resolve_createdAt(self, info): + return self.created_at.isoformat() if self.created_at else None + + def resolve_graphNodeId(self, info): + return self.graph_node_id + + def resolve_processedAt(self, info): + return self.processed_at.isoformat() if self.processed_at else None + + def resolve_countryCode(self, info): + return self.country_code + + +class SelectedLocation(graphene.ObjectType): + type = graphene.String() + uuid = graphene.String() + name = graphene.String() + latitude = graphene.Float() + longitude = graphene.Float() + + +class Team(DjangoObjectType): + id = graphene.String() + ownerId = graphene.String() + members = graphene.List(TeamMember) + addresses = graphene.List(lambda: TeamAddress) + selectedLocation = graphene.Field(SelectedLocation) + + class Meta: + model = TeamModel + fields = ('uuid', 'name', 'logto_org_id', 'owner', 'created_at', 'updated_at') + + def resolve_id(self, info): + return self.uuid + + def resolve_ownerId(self, info): + if self.owner and hasattr(self.owner, 'profile'): + return self.owner.profile.logto_id + return self.owner.username if self.owner else None + + def resolve_members(self, info): + return self.members.all() + + def resolve_addresses(self, info): + return self.addresses.all() + + def resolve_selectedLocation(self, info): + loc_type = getattr(self, 'selected_location_type', None) + loc_uuid = getattr(self, 'selected_location_uuid', None) + if loc_type and loc_uuid: + return SelectedLocation( + type=loc_type, + uuid=loc_uuid, + name=getattr(self, 'selected_location_name', None), + latitude=getattr(self, 'selected_location_latitude', None), + longitude=getattr(self, 'selected_location_longitude', None) + ) + return None + + +class TeamQuery(graphene.ObjectType): + team = graphene.Field(Team) + getTeam = graphene.Field(Team, teamId=graphene.String(required=True)) + team_members = graphene.List(TeamMember) + team_addresses = graphene.List(TeamAddress) + + @require_scopes("teams:member") + def resolve_team(self, info): + team_uuid = getattr(info.context, 'team_uuid', None) + if not team_uuid: + return None + + try: + return TeamModel.objects.get(uuid=team_uuid) + except TeamModel.DoesNotExist: + return None + + @require_scopes("teams:member") + def resolve_getTeam(self, info, teamId): + # Получаем конкретную команду по ID + try: + return TeamModel.objects.get(uuid=teamId) + except TeamModel.DoesNotExist: + return None + + @require_scopes("teams:member") + def resolve_team_members(self, info): + # Получаем участников команды + team_uuid = getattr(info.context, 'team_uuid', None) + if not team_uuid: + return [] + + try: + team = TeamModel.objects.get(uuid=team_uuid) + return team.members.all() + except TeamModel.DoesNotExist: + return [] + + @require_scopes("teams:member") + def resolve_team_addresses(self, info): + team_uuid = getattr(info.context, 'team_uuid', None) + if not team_uuid: + return [] + + try: + team = TeamModel.objects.get(uuid=team_uuid) + return team.addresses.all() + except TeamModel.DoesNotExist: + return [] + + +class InviteMemberInput(graphene.InputObjectType): + email = graphene.String(required=True) + role = graphene.String() + +class InviteMemberMutation(graphene.Mutation): + class Arguments: + input = InviteMemberInput(required=True) + + success = graphene.Boolean() + message = graphene.String() + + @require_scopes("teams:member") + def mutate(self, info, input): + from ..temporal_client import start_invite_workflow + + # Проверяем права - только owner может приглашать + team_uuid = getattr(info.context, 'team_uuid', None) + user_id = getattr(info.context, 'user_id', None) + + if not team_uuid or not user_id: + return InviteMemberMutation(success=False, message="Недостаточно прав") + + try: + team = TeamModel.objects.get(uuid=team_uuid) + + # Проверяем что пользователь - owner команды + if not team.owner: + return InviteMemberMutation(success=False, message="Только owner может приглашать") + owner_identifier = team.owner.profile.logto_id if hasattr(team.owner, 'profile') and team.owner.profile else team.owner.username + if owner_identifier != user_id: + return InviteMemberMutation(success=False, message="Только owner может приглашать") + + expires_at = timezone.now() + timedelta(days=7) + start_invite_workflow( + team_uuid=str(team.uuid), + email=input.email, + role=input.role or 'MEMBER', + invited_by=owner_identifier, + expires_at=expires_at.isoformat(), + ) + + return InviteMemberMutation(success=True, message="Приглашение отправлено") + + except TeamModel.DoesNotExist: + return InviteMemberMutation(success=False, message="Команда не найдена") + +class CreateTeamAddressInput(graphene.InputObjectType): + name = graphene.String(required=True) + address = graphene.String(required=True) + latitude = graphene.Float() + longitude = graphene.Float() + countryCode = graphene.String() + isDefault = graphene.Boolean() + + +class CreateTeamAddressMutation(graphene.Mutation): + class Arguments: + input = CreateTeamAddressInput(required=True) + + success = graphene.Boolean() + message = graphene.String() + workflowId = graphene.String() + + @require_scopes("teams:member") + def mutate(self, info, input): + from ..temporal_client import start_address_workflow + + team_uuid = getattr(info.context, 'team_uuid', None) + if not team_uuid: + return CreateTeamAddressMutation(success=False, message="Не авторизован") + + try: + team = TeamModel.objects.get(uuid=team_uuid) + + # Запускаем workflow - он сам создаст адрес через M2M мутацию + workflow_id, _ = start_address_workflow( + team_uuid=str(team.uuid), + name=input.name, + address=input.address, + latitude=input.get('latitude'), + longitude=input.get('longitude'), + country_code=input.get('countryCode'), + is_default=input.get('isDefault', False), + ) + + return CreateTeamAddressMutation( + success=True, + message="Адрес создается", + workflowId=workflow_id, + ) + + except TeamModel.DoesNotExist: + return CreateTeamAddressMutation(success=False, message="Команда не найдена") + except Exception as e: + return CreateTeamAddressMutation(success=False, message=str(e)) + + +class DeleteTeamAddressMutation(graphene.Mutation): + class Arguments: + uuid = graphene.String(required=True) + + success = graphene.Boolean() + message = graphene.String() + + @require_scopes("teams:member") + def mutate(self, info, uuid): + team_uuid = getattr(info.context, 'team_uuid', None) + if not team_uuid: + return DeleteTeamAddressMutation(success=False, message="Не авторизован") + + try: + team = TeamModel.objects.get(uuid=team_uuid) + address = team.addresses.get(uuid=uuid) + address.delete() + return DeleteTeamAddressMutation(success=True, message="Адрес удален") + + except TeamModel.DoesNotExist: + return DeleteTeamAddressMutation(success=False, message="Команда не найдена") + except TeamAddressModel.DoesNotExist: + return DeleteTeamAddressMutation(success=False, message="Адрес не найден") + + +class SetSelectedLocationInput(graphene.InputObjectType): + type = graphene.String(required=True) # 'address' или 'hub' + uuid = graphene.String(required=True) + name = graphene.String(required=True) + latitude = graphene.Float(required=True) + longitude = graphene.Float(required=True) + + +class SetSelectedLocationMutation(graphene.Mutation): + class Arguments: + input = SetSelectedLocationInput(required=True) + + success = graphene.Boolean() + message = graphene.String() + selectedLocation = graphene.Field(SelectedLocation) + + @require_scopes("teams:member") + def mutate(self, info, input): + team_uuid = getattr(info.context, 'team_uuid', None) + if not team_uuid: + return SetSelectedLocationMutation(success=False, message="Не авторизован") + + location_type = input.type + if location_type not in ('address', 'hub'): + return SetSelectedLocationMutation(success=False, message="Неверный тип локации") + + try: + team = TeamModel.objects.get(uuid=team_uuid) + team.selected_location_type = location_type + team.selected_location_uuid = input.uuid + team.selected_location_name = input.name + team.selected_location_latitude = input.latitude + team.selected_location_longitude = input.longitude + team.save(update_fields=[ + 'selected_location_type', + 'selected_location_uuid', + 'selected_location_name', + 'selected_location_latitude', + 'selected_location_longitude' + ]) + + return SetSelectedLocationMutation( + success=True, + message="Локация выбрана", + selectedLocation=SelectedLocation( + type=location_type, + uuid=input.uuid, + name=input.name, + latitude=input.latitude, + longitude=input.longitude + ) + ) + + except TeamModel.DoesNotExist: + return SetSelectedLocationMutation(success=False, message="Команда не найдена") + + +class TeamMutation(graphene.ObjectType): + invite_member = InviteMemberMutation.Field() + create_team_address = CreateTeamAddressMutation.Field() + delete_team_address = DeleteTeamAddressMutation.Field() + set_selected_location = SetSelectedLocationMutation.Field() + +team_schema = graphene.Schema(query=TeamQuery, mutation=TeamMutation) diff --git a/teams_app/schemas/user_schema.py b/teams_app/schemas/user_schema.py new file mode 100644 index 0000000..41d5635 --- /dev/null +++ b/teams_app/schemas/user_schema.py @@ -0,0 +1,287 @@ +import graphene +from graphene_django import DjangoObjectType +from django.contrib.auth import get_user_model +from ..models import Team as TeamModel, TeamMember as TeamMemberModel, TeamInvitation as TeamInvitationModel, UserProfile +from .team_schema import SelectedLocation + +UserModel = get_user_model() + + +def _get_or_create_user_with_profile(logto_id: str): + user, _ = UserModel.objects.get_or_create( + username=logto_id, + defaults={'email': ''} + ) + profile, _ = UserProfile.objects.get_or_create( + logto_id=logto_id, + defaults={'user': user} + ) + if profile.user_id != user.id: + profile.user = user + profile.save(update_fields=['user']) + # Attach profile to user for resolvers + user.profile = profile + return user + +class Team(DjangoObjectType): + id = graphene.String() + logtoOrgId = graphene.String() + teamType = graphene.String() + selectedLocation = graphene.Field(SelectedLocation) + + class Meta: + model = TeamModel + fields = ('uuid', 'name', 'logto_org_id', 'team_type', 'created_at') + + def resolve_id(self, info): + return self.uuid + + def resolve_logtoOrgId(self, info): + return self.logto_org_id + + def resolve_teamType(self, info): + return self.team_type + + def resolve_selectedLocation(self, info): + loc_type = getattr(self, 'selected_location_type', None) + loc_uuid = getattr(self, 'selected_location_uuid', None) + if loc_type and loc_uuid: + return SelectedLocation( + type=loc_type, + uuid=loc_uuid, + name=getattr(self, 'selected_location_name', None), + latitude=getattr(self, 'selected_location_latitude', None), + longitude=getattr(self, 'selected_location_longitude', None) + ) + return None + +class User(DjangoObjectType): + id = graphene.String() + firstName = graphene.String() + lastName = graphene.String() + phone = graphene.String() + avatarId = graphene.String() + activeTeamId = graphene.String() + activeTeam = graphene.Field(Team) + teams = graphene.List(Team) + + class Meta: + model = UserModel + fields = ('username', 'first_name', 'last_name', 'email') + + def resolve_id(self, info): + if hasattr(self, 'profile') and self.profile: + return self.profile.logto_id + return self.username + + def resolve_firstName(self, info): + return self.first_name + + def resolve_lastName(self, info): + return self.last_name + + def resolve_phone(self, info): + return getattr(self.profile, 'phone', None) + + def resolve_avatarId(self, info): + return getattr(self.profile, 'avatar_id', None) + + def resolve_activeTeamId(self, info): + return self.profile.active_team.uuid if getattr(self, 'profile', None) and self.profile.active_team else None + + def resolve_activeTeam(self, info): + return self.profile.active_team if getattr(self, 'profile', None) else None + + def resolve_teams(self, info): + # Возвращаем Team объекты через TeamMember отношения + from ..models import TeamMember as TeamMemberModel + team_members = TeamMemberModel.objects.filter(user=self) + return [member.team for member in team_members] + +class TeamMember(DjangoObjectType): + user = graphene.Field(User) + role = graphene.String() + joinedAt = graphene.String() + + class Meta: + from ..models import TeamMember as TeamMemberModel + model = TeamMemberModel + fields = ('uuid', 'role') + + def resolve_joinedAt(self, info): + return self.joined_at.isoformat() if self.joined_at else None + + +class TeamInvitation(DjangoObjectType): + email = graphene.String() + role = graphene.String() + status = graphene.String() + invitedBy = graphene.String() + expiresAt = graphene.String() + createdAt = graphene.String() + + class Meta: + model = TeamInvitationModel + fields = ('uuid', 'email', 'role', 'status') + + def resolve_invitedBy(self, info): + return self.invited_by + + def resolve_expiresAt(self, info): + return self.expires_at.isoformat() if self.expires_at else None + + def resolve_createdAt(self, info): + return self.created_at.isoformat() if self.created_at else None + + +class TeamWithMembers(DjangoObjectType): + id = graphene.String() + members = graphene.List(TeamMember) + invitations = graphene.List(TeamInvitation) + + class Meta: + model = TeamModel + fields = ('uuid', 'name', 'created_at') + + def resolve_id(self, info): + return self.uuid + + def resolve_members(self, info): + return self.members.all() + + def resolve_invitations(self, info): + return self.invitations.filter(status='PENDING') + + +class UserQuery(graphene.ObjectType): + me = graphene.Field(User) + get_team = graphene.Field(TeamWithMembers, team_id=graphene.String(required=True)) + + def resolve_me(self, info): + # Получаем user_id из ID Token + user_id = getattr(info.context, 'user_id', None) + if not user_id: + return None + + try: + return _get_or_create_user_with_profile(user_id) + except Exception: + return None + + def resolve_get_team(self, info, team_id): + try: + return TeamModel.objects.get(uuid=team_id) + except TeamModel.DoesNotExist: + return None + +class CreateTeamInput(graphene.InputObjectType): + name = graphene.String(required=True) + teamType = graphene.String() # BUYER или SELLER + + +class UpdateUserInput(graphene.InputObjectType): + firstName = graphene.String() + lastName = graphene.String() + phone = graphene.String() + avatarId = graphene.String() + +class CreateTeamMutation(graphene.Mutation): + class Arguments: + input = CreateTeamInput(required=True) + + team = graphene.Field(Team) + + def mutate(self, info, input): + # Получаем user_id из контекста (ID Token) + user_id = getattr(info.context, 'user_id', None) + if not user_id: + raise Exception("User not authenticated") + + try: + owner = _get_or_create_user_with_profile(user_id) + + team = TeamModel.objects.create( + name=input.name, + owner=owner, + team_type=input.teamType or 'BUYER' + ) + + # Добавляем owner как участника команды с ролью OWNER + TeamMemberModel.objects.create( + team=team, + user=owner, + role='OWNER' + ) + + # Устанавливаем как активную команду, если у пользователя её нет + if hasattr(owner, 'profile') and not owner.profile.active_team: + owner.profile.active_team = team + owner.profile.save(update_fields=['active_team']) + + return CreateTeamMutation(team=team) + + except Exception as e: + raise Exception(f"Failed to create team: {str(e)}") + + +class UpdateUserMutation(graphene.Mutation): + class Arguments: + userId = graphene.String(required=True) + input = UpdateUserInput(required=True) + + user = graphene.Field(User) + + def mutate(self, info, userId, input): + # Проверяем права - пользователь может редактировать только себя + context_user_id = getattr(info.context, 'user_id', None) + if context_user_id != userId: + return UpdateUserMutation(user=None) + + try: + user = _get_or_create_user_with_profile(userId) + + if input.firstName is not None: + user.first_name = input.firstName + if input.lastName is not None: + user.last_name = input.lastName + user.save() + if hasattr(user, 'profile'): + if input.phone is not None: + user.profile.phone = input.phone + if input.avatarId is not None: + user.profile.avatar_id = input.avatarId + user.profile.save() + return UpdateUserMutation(user=user) + + except Exception: + return UpdateUserMutation(user=None) + + +class SwitchTeamMutation(graphene.Mutation): + class Arguments: + teamId = graphene.String(required=True) + + user = graphene.Field(User) + + def mutate(self, info, teamId): + user_id = getattr(info.context, 'user_id', None) + if not user_id: + raise Exception("User not authenticated") + + try: + team = TeamModel.objects.get(uuid=teamId) + user = _get_or_create_user_with_profile(user_id) + if hasattr(user, 'profile'): + user.profile.active_team = team + user.profile.save(update_fields=['active_team']) + return SwitchTeamMutation(user=user) + except TeamModel.DoesNotExist: + raise Exception("Team not found") + + +class UserMutation(graphene.ObjectType): + create_team = CreateTeamMutation.Field() + update_user = UpdateUserMutation.Field() + switch_team = SwitchTeamMutation.Field() + +user_schema = graphene.Schema(query=UserQuery, mutation=UserMutation) diff --git a/teams_app/services.py b/teams_app/services.py new file mode 100644 index 0000000..7db5d5d --- /dev/null +++ b/teams_app/services.py @@ -0,0 +1,160 @@ +import requests +from django.conf import settings +from .models import Order, OrderLine, Stage, Trip + +class OdooService: + def __init__(self): + self.base_url = f"http://{settings.ODOO_INTERNAL_URL}" + + def get_odoo_orders(self, team_uuid): + """Получить заказы из Odoo API""" + try: + url = f"{self.base_url}/fastapi/orders/api/v1/orders/team/{team_uuid}" + response = requests.get(url, timeout=10) + if response.status_code == 200: + return response.json() + return [] + except Exception as e: + print(f"Error fetching from Odoo: {e}") + return [] + + def get_odoo_order(self, order_uuid): + """Получить заказ из Odoo API""" + try: + url = f"{self.base_url}/fastapi/orders/api/v1/orders/{order_uuid}" + response = requests.get(url, timeout=10) + if response.status_code == 200: + return response.json() + return None + except Exception as e: + print(f"Error fetching order from Odoo: {e}") + return None + + def sync_team_orders(self, team_uuid): + """Синхронизировать заказы команды с Odoo""" + odoo_orders = self.get_odoo_orders(team_uuid) + django_orders = [] + + for odoo_order in odoo_orders: + # Создаем или обновляем заказ в Django + order, created = Order.objects.get_or_create( + uuid=odoo_order['uuid'], + defaults={ + 'name': odoo_order['name'], + 'team_uuid': odoo_order['teamUuid'], + 'user_id': odoo_order['userId'], + 'source_location_uuid': odoo_order['sourceLocationUuid'], + 'source_location_name': odoo_order['sourceLocationName'], + 'destination_location_uuid': odoo_order['destinationLocationUuid'], + 'destination_location_name': odoo_order['destinationLocationName'], + 'status': odoo_order['status'], + 'total_amount': odoo_order['totalAmount'], + 'currency': odoo_order['currency'], + 'notes': odoo_order.get('notes', ''), + } + ) + + # Синхронизируем order lines + self.sync_order_lines(order, odoo_order.get('orderLines', [])) + + # Синхронизируем stages + self.sync_stages(order, odoo_order.get('stages', [])) + + django_orders.append(order) + + return django_orders + + def sync_order(self, order_uuid): + """Синхронизировать один заказ с Odoo""" + odoo_order = self.get_odoo_order(order_uuid) + if not odoo_order: + return None + + # Создаем или обновляем заказ + order, created = Order.objects.get_or_create( + uuid=odoo_order['uuid'], + defaults={ + 'name': odoo_order['name'], + 'team_uuid': odoo_order['teamUuid'], + 'user_id': odoo_order['userId'], + 'source_location_uuid': odoo_order['sourceLocationUuid'], + 'source_location_name': odoo_order['sourceLocationName'], + 'destination_location_uuid': odoo_order['destinationLocationUuid'], + 'destination_location_name': odoo_order['destinationLocationName'], + 'status': odoo_order['status'], + 'total_amount': odoo_order['totalAmount'], + 'currency': odoo_order['currency'], + 'notes': odoo_order.get('notes', ''), + } + ) + + # Синхронизируем связанные данные + self.sync_order_lines(order, odoo_order.get('orderLines', [])) + self.sync_stages(order, odoo_order.get('stages', [])) + + return order + + def sync_order_lines(self, order, odoo_lines): + """Синхронизировать строки заказа""" + # Удаляем старые + order.order_lines.all().delete() + + # Создаем новые + for line_data in odoo_lines: + OrderLine.objects.create( + uuid=line_data['uuid'], + order=order, + product_uuid=line_data['productUuid'], + product_name=line_data['productName'], + quantity=line_data['quantity'], + unit=line_data['unit'], + price_unit=line_data['priceUnit'], + subtotal=line_data['subtotal'], + currency=line_data.get('currency', 'RUB'), + notes=line_data.get('notes', ''), + ) + + def sync_stages(self, order, odoo_stages): + """Синхронизировать этапы заказа""" + # Удаляем старые + order.stages.all().delete() + + # Создаем новые + for stage_data in odoo_stages: + stage = Stage.objects.create( + uuid=stage_data['uuid'], + order=order, + name=stage_data['name'], + sequence=stage_data['sequence'], + stage_type=stage_data['stageType'], + transport_type=stage_data.get('transportType', ''), + source_location_name=stage_data.get('sourceLocationName', ''), + destination_location_name=stage_data.get('destinationLocationName', ''), + location_name=stage_data.get('locationName', ''), + selected_company_uuid=stage_data.get('selectedCompany', {}).get('uuid', '') if stage_data.get('selectedCompany') else '', + selected_company_name=stage_data.get('selectedCompany', {}).get('name', '') if stage_data.get('selectedCompany') else '', + ) + + # Синхронизируем trips + self.sync_trips(stage, stage_data.get('trips', [])) + + def sync_trips(self, stage, odoo_trips): + """Синхронизировать рейсы этапа""" + for trip_data in odoo_trips: + Trip.objects.create( + uuid=trip_data['uuid'], + stage=stage, + name=trip_data['name'], + sequence=trip_data['sequence'], + company_uuid=trip_data.get('company', {}).get('uuid', '') if trip_data.get('company') else '', + company_name=trip_data.get('company', {}).get('name', '') if trip_data.get('company') else '', + planned_weight=trip_data.get('plannedWeight'), + weight_at_loading=trip_data.get('weightAtLoading'), + weight_at_unloading=trip_data.get('weightAtUnloading'), + planned_loading_date=trip_data.get('plannedLoadingDate'), + actual_loading_date=trip_data.get('actualLoadingDate'), + real_loading_date=trip_data.get('realLoadingDate'), + planned_unloading_date=trip_data.get('plannedUnloadingDate'), + actual_unloading_date=trip_data.get('actualUnloadingDate'), + notes=trip_data.get('notes', ''), + ) \ No newline at end of file diff --git a/teams_app/temporal_client.py b/teams_app/temporal_client.py new file mode 100644 index 0000000..7798579 --- /dev/null +++ b/teams_app/temporal_client.py @@ -0,0 +1,168 @@ +import asyncio +import logging +import os +import uuid +from dataclasses import asdict +from typing import Tuple + +from temporalio.client import Client + +from .models import Team + +logger = logging.getLogger(__name__) + +# Default Temporal connection settings; override via env. +TEMPORAL_ADDRESS = os.getenv("TEMPORAL_ADDRESS", "temporal:7233") +TEMPORAL_NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "default") +TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "platform-worker") + + +async def _start_team_created_async(team: Team) -> Tuple[str, str]: + """ + Start the team_created workflow in Temporal and return (workflow_id, run_id). + """ + client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE) + + # We re-use team.uuid as workflow_id to keep idempotency. + handle = await client.start_workflow( + "team_created_workflow", # workflow name registered in worker + { + "team_id": team.uuid, + "team_name": team.name, + "owner_id": getattr(getattr(team.owner, "profile", None), "logto_id", "") or getattr(team.owner, "username", ""), + "logto_org_id": team.logto_org_id or "", + }, + id=team.uuid, + task_queue=TEMPORAL_TASK_QUEUE, + ) + return handle.id, handle.run_id + + +def start_team_created(team: Team) -> Tuple[str, str]: + """ + Sync wrapper for Django mutation handlers. + """ + try: + return asyncio.run(_start_team_created_async(team)) + except Exception: + logger.exception("Failed to start Temporal workflow for team %s", team.uuid) + raise + + +async def _start_address_workflow_async( + team_uuid: str, + name: str, + address: str, + latitude: float | None = None, + longitude: float | None = None, + country_code: str | None = None, + is_default: bool = False, +) -> Tuple[str, str]: + """ + Start the create_address workflow in Temporal. + Returns (workflow_id, run_id). + """ + client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE) + + workflow_id = f"address-{uuid.uuid4()}" + + handle = await client.start_workflow( + "create_address", + { + "workflow_id": workflow_id, + "team_uuid": team_uuid, + "name": name, + "address": address, + "latitude": latitude, + "longitude": longitude, + "country_code": country_code, + "is_default": is_default, + }, + id=workflow_id, + task_queue=TEMPORAL_TASK_QUEUE, + ) + + logger.info("Started address workflow %s for team %s", workflow_id, team_uuid) + return handle.id, handle.result_run_id + + +def start_address_workflow( + team_uuid: str, + name: str, + address: str, + latitude: float | None = None, + longitude: float | None = None, + country_code: str | None = None, + is_default: bool = False, +) -> Tuple[str, str]: + """ + Sync wrapper for starting address workflow. + """ + try: + return asyncio.run(_start_address_workflow_async( + team_uuid=team_uuid, + name=name, + address=address, + latitude=latitude, + longitude=longitude, + country_code=country_code, + is_default=is_default, + )) + except Exception: + logger.exception("Failed to start address workflow for team %s", team_uuid) + raise + + +async def _start_invite_workflow_async( + team_uuid: str, + email: str, + role: str, + invited_by: str, + expires_at: str, +) -> Tuple[str, str]: + """ + Start the invite_user workflow in Temporal. + Returns (workflow_id, run_id). + """ + client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE) + + workflow_id = f"invite-{uuid.uuid4()}" + + handle = await client.start_workflow( + "invite_user", + { + "team_uuid": team_uuid, + "email": email, + "role": role, + "invited_by": invited_by, + "expires_at": expires_at, + }, + id=workflow_id, + task_queue=TEMPORAL_TASK_QUEUE, + ) + + logger.info("Started invite workflow %s for %s", workflow_id, email) + return handle.id, handle.result_run_id + + +def start_invite_workflow( + team_uuid: str, + email: str, + role: str, + invited_by: str, + expires_at: str, +) -> Tuple[str, str]: + """ + Sync wrapper for starting invite workflow. + """ + try: + return asyncio.run(_start_invite_workflow_async( + team_uuid=team_uuid, + email=email, + role=role, + invited_by=invited_by, + expires_at=expires_at, + )) + except Exception: + logger.exception("Failed to start invite workflow for %s", email) + raise diff --git a/teams_app/tests.py b/teams_app/tests.py new file mode 100644 index 0000000..acf7551 --- /dev/null +++ b/teams_app/tests.py @@ -0,0 +1,98 @@ +from django.test import TestCase +from graphene.test import Client +from teams_app.schema import schema + +class TeamsGraphQLTestCase(TestCase): + def setUp(self): + self.client = Client(schema) + + def test_get_user_teams_with_params(self): + """Тест getUserTeams с userId""" + query = ''' + { + getUserTeams(userId: "demo-user") { + id + name + ownerId + logtoOrgId + createdAt + updatedAt + } + } + ''' + result = self.client.execute(query) + print(f"\n=== getUserTeams WITH PARAMS ===") + print(f"Result: {result}") + + if result.get('errors'): + print(f"ERRORS: {result['errors']}") + + if result.get('data'): + teams = result['data']['getUserTeams'] + print(f"Found {len(teams)} teams") + for team in teams: + print(f"Team: {team.get('name')} - {team.get('id')}") + + # Проверки + self.assertIsNone(result.get('errors')) + self.assertIn('getUserTeams', result['data']) + + def test_get_user_teams_no_params(self): + """Тест getUserTeams без параметров""" + query = ''' + { + getUserTeams { + id + name + } + } + ''' + result = self.client.execute(query) + print(f"\n=== getUserTeams NO PARAMS ===") + print(f"Result: {result}") + + if result.get('errors'): + print(f"ERRORS: {result['errors']}") + + if result.get('data'): + teams = result['data']['getUserTeams'] + print(f"Found {len(teams)} teams") + + def test_schema_fields(self): + """Тест что схема содержит нужные поля""" + query = ''' + { + __type(name: "Team") { + fields { + name + type { + name + } + } + } + } + ''' + result = self.client.execute(query) + print(f"\n=== TEAM SCHEMA FIELDS ===") + + if result.get('data') and result['data']['__type']: + fields = result['data']['__type']['fields'] + field_names = [f['name'] for f in fields] + print(f"Team fields: {field_names}") + + required_fields = ['id', 'name', 'ownerId'] + for field in required_fields: + if field in field_names: + print(f"✅ {field} - OK") + else: + print(f"❌ {field} - MISSING") + + def test_invalid_query(self): + """Тест неправильного запроса""" + query = '{ nonExistentField }' + result = self.client.execute(query) + print(f"\n=== INVALID QUERY TEST ===") + print(f"Result: {result}") + + # Должна быть ошибка + self.assertIsNotNone(result.get('errors')) diff --git a/teams_app/views.py b/teams_app/views.py new file mode 100644 index 0000000..3effa15 --- /dev/null +++ b/teams_app/views.py @@ -0,0 +1,97 @@ +import json +import jwt +from django.conf import settings +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from jwt import InvalidTokenError + +from .auth import get_bearer_token, scopes_from_payload, validator + + +@csrf_exempt +def test_jwt(request): + """Тестовый endpoint для проверки JWT токена с подписью.""" + + try: + token = get_bearer_token(request) + except InvalidTokenError as exc: + return JsonResponse({"status": "error", "error": str(exc)}, status=403) + + response = {"token_length": len(token), "token_preview": f"{token[:32]}...{token[-32:]}"} + + try: + audience = getattr(settings, "LOGTO_TEAMS_AUDIENCE", None) + payload = validator.decode(token, audience=audience) + response.update( + { + "status": "ok", + "header": jwt.get_unverified_header(token), + "payload": payload, + "user_id": payload.get("sub"), + "team_uuid": payload.get("team_uuid"), + "scopes": scopes_from_payload(payload), + } + ) + return JsonResponse(response, json_dumps_params={"indent": 2}) + except InvalidTokenError as exc: + response["status"] = "invalid" + response["error"] = str(exc) + return JsonResponse(response, status=403, json_dumps_params={"indent": 2}) + + +# GraphQL Views - authentication handled by GRAPHENE MIDDLEWARE + +from graphene_django.views import GraphQLView + +from .graphql_middleware import ( + M2MNoAuthMiddleware, + PublicNoAuthMiddleware, + TeamJWTMiddleware, + UserJWTMiddleware, +) + + +def _is_introspection_query(request): + """Проверяет, является ли запрос introspection (для GraphQL codegen)""" + if request.method != 'POST': + return False + try: + body = json.loads(request.body.decode('utf-8')) + query = body.get('query', '') + return '__schema' in query or '__type' in query + except Exception: + return False + + +class PublicGraphQLView(GraphQLView): + """GraphQL view for public operations (no authentication).""" + + def __init__(self, *args, **kwargs): + kwargs['middleware'] = [PublicNoAuthMiddleware()] + super().__init__(*args, **kwargs) + + +class UserGraphQLView(GraphQLView): + """GraphQL view for user-level operations (ID Token).""" + + def __init__(self, *args, **kwargs): + kwargs['middleware'] = [UserJWTMiddleware()] + super().__init__(*args, **kwargs) + + +class TeamGraphQLView(GraphQLView): + """GraphQL view for team-level operations (Access Token).""" + + def __init__(self, *args, **kwargs): + kwargs['middleware'] = [TeamJWTMiddleware()] + super().__init__(*args, **kwargs) + + +class M2MGraphQLView(GraphQLView): + """GraphQL view for M2M (machine-to-machine) operations. + No authentication required - used by internal services (Temporal, etc.) + """ + + def __init__(self, *args, **kwargs): + kwargs['middleware'] = [M2MNoAuthMiddleware()] + super().__init__(*args, **kwargs)