commit 5a5186732bc33258f124884314c1a00fe1521c3d Author: Ruslan Bakiev Date: Wed Jan 7 09:12:35 2026 +0700 Initial commit from monorepo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1cb1d57 --- /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 exchange.wsgi:application --bind 0.0.0.0:${PORT:-8000}"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cdc3bc0 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Exchange Service + +Backend сервис для биржи товаров в системе Optovia. + +## Описание + +Сервис для управления офферами (предложениями) и заявками (RFQ) на товары. Включает интеграцию с Odoo для получения справочников товаров и логистических узлов. + +## Основные функции + +- Создание и управление офферами (каталог товаров) +- Позиции офферов с ценами и количествами +- Создание заявок на товары (RFQ) +- Проксирование справочников из Odoo (товары, локации) + +## Модели данных + +- **Offer** - предложение товаров от команды +- **OfferLine** - позиции оффера (товар, количество, цена) +- **Request** - заявка на товар (RFQ) + +## Статусы офферов + +- `draft` - Черновик +- `active` - Активно +- `closed` - Закрыто +- `cancelled` - Отменено + +## Технологии + +- Django 5.2.8 +- GraphQL (Graphene-Django) +- PostgreSQL +- Odoo Integration +- Gunicorn + +## Развертывание + +Проект развертывается через Nixpacks на Dokploy с автоматическими миграциями. + +## Автор + +Ruslan Bakiev diff --git a/exchange/__init__.py b/exchange/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/exchange/asgi.py b/exchange/asgi.py new file mode 100644 index 0000000..c0773fc --- /dev/null +++ b/exchange/asgi.py @@ -0,0 +1,11 @@ +""" +ASGI config for exchange project. +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'exchange.settings') + +application = get_asgi_application() diff --git a/exchange/auth.py b/exchange/auth.py new file mode 100644 index 0000000..836edad --- /dev/null +++ b/exchange/auth.py @@ -0,0 +1,66 @@ +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/exchange/graphql_middleware.py b/exchange/graphql_middleware.py new file mode 100644 index 0000000..fbba940 --- /dev/null +++ b/exchange/graphql_middleware.py @@ -0,0 +1,74 @@ +""" +GraphQL middleware for JWT authentication. + +Each class is bound to a specific GraphQL endpoint (public/user/team/m2m). +""" +from django.conf import settings +from graphql import GraphQLError +from jwt import InvalidTokenError + +from .auth import get_bearer_token, scopes_from_payload, validator + + +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) + + try: + token = get_bearer_token(request) + payload = validator.decode(token) + request.user_id = payload.get('sub') + except InvalidTokenError as exc: + raise GraphQLError("Unauthorized") from exc + + return next(root, info, **kwargs) + + +class TeamJWTMiddleware: + """Team endpoint - requires Access token for exchange 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_EXCHANGE_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/exchange/permissions.py b/exchange/permissions.py new file mode 100644 index 0000000..9a53ea6 --- /dev/null +++ b/exchange/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:requests") + def resolve_get_requests(self, info): + ... + + @require_scopes("create:offers") + def mutate(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:requests', 'create:offers', ...} + """ + 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/exchange/schemas/__init__.py b/exchange/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/exchange/schemas/m2m_schema.py b/exchange/schemas/m2m_schema.py new file mode 100644 index 0000000..1822f8c --- /dev/null +++ b/exchange/schemas/m2m_schema.py @@ -0,0 +1,127 @@ +""" +M2M (Machine-to-Machine) GraphQL schema for Exchange. +Used by internal services (Temporal workflows, etc.) without user authentication. +""" +import graphene +import logging +from graphene_django import DjangoObjectType +from offers.models import Offer + +logger = logging.getLogger(__name__) + + +class OfferType(DjangoObjectType): + class Meta: + model = Offer + fields = "__all__" + + +class M2MQuery(graphene.ObjectType): + offer = graphene.Field(OfferType, offerUuid=graphene.String(required=True)) + + def resolve_offer(self, info, offerUuid): + try: + return Offer.objects.get(uuid=offerUuid) + except Offer.DoesNotExist: + return None + + +class CreateOfferFromWorkflowInput(graphene.InputObjectType): + offerUuid = graphene.String(required=True) + teamUuid = graphene.String(required=True) + productUuid = graphene.String(required=True) + productName = graphene.String(required=True) + categoryName = graphene.String() + locationUuid = graphene.String() + locationName = graphene.String() + locationCountry = graphene.String() + locationCountryCode = graphene.String() + locationLatitude = graphene.Float() + locationLongitude = graphene.Float() + quantity = graphene.Decimal(required=True) + unit = graphene.String() + pricePerUnit = graphene.Decimal() + currency = graphene.String() + description = graphene.String() + validUntil = graphene.Date() + terminusSchemaId = graphene.String() + terminusDocumentId = graphene.String() + + +class CreateOfferFromWorkflow(graphene.Mutation): + class Arguments: + input = CreateOfferFromWorkflowInput(required=True) + + success = graphene.Boolean() + message = graphene.String() + offer = graphene.Field(OfferType) + + def mutate(self, info, input): + try: + offer = Offer.objects.filter(uuid=input.offerUuid).first() + if offer: + logger.info("Offer %s already exists, returning existing", input.offerUuid) + return CreateOfferFromWorkflow(success=True, message="Offer exists", offer=offer) + + offer = Offer.objects.create( + uuid=input.offerUuid, + team_uuid=input.teamUuid, + product_uuid=input.productUuid, + product_name=input.productName, + category_name=input.categoryName or '', + location_uuid=input.locationUuid or '', + location_name=input.locationName or '', + location_country=input.locationCountry or '', + location_country_code=input.locationCountryCode or '', + location_latitude=input.locationLatitude, + location_longitude=input.locationLongitude, + quantity=input.quantity, + unit=input.unit or 'ton', + price_per_unit=input.pricePerUnit, + currency=input.currency or 'USD', + description=input.description or '', + valid_until=input.validUntil, + terminus_schema_id=input.terminusSchemaId or '', + terminus_document_id=input.terminusDocumentId or '', + workflow_status='pending', + ) + logger.info("Created offer %s via workflow", offer.uuid) + return CreateOfferFromWorkflow(success=True, message="Offer created", offer=offer) + except Exception as exc: + logger.exception("Failed to create offer %s", input.offerUuid) + return CreateOfferFromWorkflow(success=False, message=str(exc), offer=None) + + +class UpdateOfferWorkflowStatusInput(graphene.InputObjectType): + offerUuid = graphene.String(required=True) + status = graphene.String(required=True) # pending | active | error + errorMessage = graphene.String() + + +class UpdateOfferWorkflowStatus(graphene.Mutation): + class Arguments: + input = UpdateOfferWorkflowStatusInput(required=True) + + success = graphene.Boolean() + message = graphene.String() + offer = graphene.Field(OfferType) + + def mutate(self, info, input): + try: + offer = Offer.objects.get(uuid=input.offerUuid) + offer.workflow_status = input.status + if input.errorMessage is not None: + offer.workflow_error = input.errorMessage + offer.save(update_fields=["workflow_status", "workflow_error", "updated_at"]) + logger.info("Offer %s workflow_status updated to %s", input.offerUuid, input.status) + return UpdateOfferWorkflowStatus(success=True, message="Status updated", offer=offer) + except Offer.DoesNotExist: + return UpdateOfferWorkflowStatus(success=False, message="Offer not found", offer=None) + + +class M2MMutation(graphene.ObjectType): + createOfferFromWorkflow = CreateOfferFromWorkflow.Field() + updateOfferWorkflowStatus = UpdateOfferWorkflowStatus.Field() + + +m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation) diff --git a/exchange/schemas/public_schema.py b/exchange/schemas/public_schema.py new file mode 100644 index 0000000..fa59920 --- /dev/null +++ b/exchange/schemas/public_schema.py @@ -0,0 +1,150 @@ +import graphene +from graphene_django import DjangoObjectType +from offers.models import Offer +from suppliers.models import SupplierProfile +from ..services import OdooService + + +class Product(graphene.ObjectType): + uuid = graphene.String() + name = graphene.String() + category_id = graphene.Int() + category_name = graphene.String() + terminus_schema_id = graphene.String() + + +class SupplierProfileType(DjangoObjectType): + """Профиль поставщика на бирже""" + offers_count = graphene.Int() + country_code = graphene.String() + + class Meta: + model = SupplierProfile + fields = "__all__" + + def resolve_offers_count(self, info): + return Offer.objects.filter(team_uuid=self.team_uuid, status='active').count() + + def resolve_country_code(self, info): + return getattr(self, 'country_code', '') + + +class OfferType(DjangoObjectType): + class Meta: + model = Offer + fields = "__all__" + + +class PublicQuery(graphene.ObjectType): + """Public schema - no authentication required""" + get_products = graphene.List(Product) + get_supplier_profiles = graphene.List( + SupplierProfileType, + country=graphene.String(), + is_verified=graphene.Boolean(), + limit=graphene.Int(), + offset=graphene.Int(), + ) + get_supplier_profiles_count = graphene.Int( + country=graphene.String(), + is_verified=graphene.Boolean(), + ) + get_supplier_profile = graphene.Field(SupplierProfileType, uuid=graphene.String(required=True)) + get_offers = graphene.List( + OfferType, + status=graphene.String(), + product_uuid=graphene.String(), + location_uuid=graphene.String(), + category_name=graphene.String(), + team_uuid=graphene.String(), + limit=graphene.Int(), + offset=graphene.Int(), + ) + get_offers_count = graphene.Int( + status=graphene.String(), + product_uuid=graphene.String(), + location_uuid=graphene.String(), + category_name=graphene.String(), + team_uuid=graphene.String(), + ) + get_offer = graphene.Field(OfferType, uuid=graphene.String(required=True)) + + def resolve_get_products(self, info): + odoo_service = OdooService() + products_data = odoo_service.get_products() + return [Product(**product) for product in products_data] + + @staticmethod + def _get_supplier_profiles_queryset(country=None, is_verified=None): + queryset = SupplierProfile.objects.filter(is_active=True) + if country: + queryset = queryset.filter(country__icontains=country) + if is_verified is not None: + queryset = queryset.filter(is_verified=is_verified) + return queryset + + def resolve_get_supplier_profiles(self, info, country=None, is_verified=None, limit=None, offset=None): + queryset = PublicQuery._get_supplier_profiles_queryset(country=country, is_verified=is_verified) + if offset is not None: + queryset = queryset[offset:] + if limit is not None: + queryset = queryset[:limit] + return queryset + + def resolve_get_supplier_profiles_count(self, info, country=None, is_verified=None): + return PublicQuery._get_supplier_profiles_queryset(country=country, is_verified=is_verified).count() + + def resolve_get_supplier_profile(self, info, uuid): + try: + return SupplierProfile.objects.get(uuid=uuid) + except SupplierProfile.DoesNotExist: + return None + + @staticmethod + def _get_offers_queryset(status=None, product_uuid=None, location_uuid=None, category_name=None, team_uuid=None): + queryset = Offer.objects.all() + if status: + queryset = queryset.filter(status=status) + else: + queryset = queryset.filter(status='active') + if team_uuid: + queryset = queryset.filter(team_uuid=team_uuid) + if location_uuid: + queryset = queryset.filter(location_uuid=location_uuid) + if product_uuid: + queryset = queryset.filter(product_uuid=product_uuid) + if category_name: + queryset = queryset.filter(category_name__icontains=category_name) + return queryset + + def resolve_get_offers(self, info, status=None, product_uuid=None, location_uuid=None, category_name=None, team_uuid=None, limit=None, offset=None): + queryset = PublicQuery._get_offers_queryset( + status=status, + product_uuid=product_uuid, + location_uuid=location_uuid, + category_name=category_name, + team_uuid=team_uuid, + ) + if offset is not None: + queryset = queryset[offset:] + if limit is not None: + queryset = queryset[:limit] + return queryset + + def resolve_get_offers_count(self, info, status=None, product_uuid=None, location_uuid=None, category_name=None, team_uuid=None): + return PublicQuery._get_offers_queryset( + status=status, + product_uuid=product_uuid, + location_uuid=location_uuid, + category_name=category_name, + team_uuid=team_uuid, + ).count() + + def resolve_get_offer(self, info, uuid): + try: + return Offer.objects.get(uuid=uuid) + except Offer.DoesNotExist: + return None + + +public_schema = graphene.Schema(query=PublicQuery) diff --git a/exchange/schemas/team_schema.py b/exchange/schemas/team_schema.py new file mode 100644 index 0000000..16822ae --- /dev/null +++ b/exchange/schemas/team_schema.py @@ -0,0 +1,198 @@ +import graphene +from graphene_django import DjangoObjectType +from offers.models import Offer +from purchase_requests.models import Request +from ..permissions import require_scopes +import uuid as uuid_lib + + +class RequestType(DjangoObjectType): + class Meta: + model = Request + fields = "__all__" + + +class OfferType(DjangoObjectType): + class Meta: + model = Offer + fields = "__all__" + + +class RequestInput(graphene.InputObjectType): + product_uuid = graphene.String(required=True) + quantity = graphene.Decimal(required=True) + source_location_uuid = graphene.String(required=True) + user_id = graphene.String(required=True) + + +class OfferInput(graphene.InputObjectType): + team_uuid = graphene.String(required=True) + # Товар + product_uuid = graphene.String(required=True) + product_name = graphene.String(required=True) + category_name = graphene.String() + # Локация + location_uuid = graphene.String() + location_name = graphene.String() + location_country = graphene.String() + location_country_code = graphene.String() + location_latitude = graphene.Float() + location_longitude = graphene.Float() + # Цена и количество + quantity = graphene.Decimal(required=True) + unit = graphene.String() + price_per_unit = graphene.Decimal() + currency = graphene.String() + # Прочее + description = graphene.String() + valid_until = graphene.Date() + terminus_schema_id = graphene.String() + terminus_payload = graphene.JSONString() + + +class TeamQuery(graphene.ObjectType): + """Team schema - Team Access Token authentication""" + get_requests = graphene.List(RequestType, user_id=graphene.String(required=True)) + get_request = graphene.Field(RequestType, uuid=graphene.String(required=True)) + get_team_offers = graphene.List(OfferType, team_uuid=graphene.String(required=True)) + + @require_scopes("teams:member") + def resolve_get_requests(self, info, user_id): + return Request.objects.filter(user_id=user_id).order_by('-created_at') + + @require_scopes("teams:member") + def resolve_get_request(self, info, uuid): + try: + return Request.objects.get(uuid=uuid) + except Request.DoesNotExist: + return None + + @require_scopes("teams:member") + def resolve_get_team_offers(self, info, team_uuid): + return Offer.objects.filter(team_uuid=team_uuid).order_by('-created_at') + + +class CreateRequest(graphene.Mutation): + class Arguments: + input = RequestInput(required=True) + + request = graphene.Field(RequestType) + + @require_scopes("teams:member") + def mutate(self, info, input): + request = Request( + uuid=str(uuid_lib.uuid4()), + product_uuid=input.product_uuid, + quantity=input.quantity, + source_location_uuid=input.source_location_uuid, + user_id=input.user_id, + ) + request.save() + return CreateRequest(request=request) + + +class CreateOffer(graphene.Mutation): + class Arguments: + input = OfferInput(required=True) + + success = graphene.Boolean() + message = graphene.String() + workflowId = graphene.String() + offerUuid = graphene.String() + + @require_scopes("teams:member") + def mutate(self, info, input): + from ..temporal_client import start_offer_workflow + + offer_uuid = str(uuid_lib.uuid4()) + workflow_id, _ = start_offer_workflow( + offer_uuid=offer_uuid, + team_uuid=input.team_uuid, + product_uuid=input.product_uuid, + product_name=input.product_name, + category_name=input.category_name, + location_uuid=input.location_uuid, + location_name=input.location_name, + location_country=input.location_country, + location_country_code=input.location_country_code, + location_latitude=input.location_latitude, + location_longitude=input.location_longitude, + quantity=input.quantity, + unit=input.unit, + price_per_unit=input.price_per_unit, + currency=input.currency, + description=input.description, + valid_until=input.valid_until, + terminus_schema_id=getattr(input, "terminus_schema_id", None), + terminus_payload=getattr(input, "terminus_payload", None), + ) + return CreateOffer( + success=True, + message="Offer workflow started", + workflowId=workflow_id, + offerUuid=offer_uuid, + ) + + +class UpdateOffer(graphene.Mutation): + class Arguments: + uuid = graphene.String(required=True) + input = OfferInput(required=True) + + offer = graphene.Field(OfferType) + + @require_scopes("teams:member") + def mutate(self, info, uuid, input): + try: + offer = Offer.objects.get(uuid=uuid) + except Offer.DoesNotExist: + raise Exception("Offer not found") + + # Обновляем поля + offer.product_uuid = input.product_uuid + offer.product_name = input.product_name + offer.category_name = input.category_name or '' + offer.location_uuid = input.location_uuid or '' + offer.location_name = input.location_name or '' + offer.location_country = input.location_country or '' + offer.location_country_code = input.location_country_code or '' + offer.location_latitude = input.location_latitude + offer.location_longitude = input.location_longitude + offer.quantity = input.quantity + offer.unit = input.unit or 'ton' + offer.price_per_unit = input.price_per_unit + offer.currency = input.currency or 'USD' + offer.description = input.description or '' + offer.valid_until = input.valid_until + if input.terminus_schema_id is not None: + offer.terminus_schema_id = input.terminus_schema_id + offer.save() + + return UpdateOffer(offer=offer) + + +class DeleteOffer(graphene.Mutation): + class Arguments: + uuid = graphene.String(required=True) + + success = graphene.Boolean() + + @require_scopes("teams:member") + def mutate(self, info, uuid): + try: + offer = Offer.objects.get(uuid=uuid) + offer.delete() + return DeleteOffer(success=True) + except Offer.DoesNotExist: + return DeleteOffer(success=False) + + +class TeamMutation(graphene.ObjectType): + """Team mutations - Team Access Token authentication""" + create_request = CreateRequest.Field() + create_offer = CreateOffer.Field() + update_offer = UpdateOffer.Field() + delete_offer = DeleteOffer.Field() + + +team_schema = graphene.Schema(query=TeamQuery, mutation=TeamMutation) diff --git a/exchange/schemas/user_schema.py b/exchange/schemas/user_schema.py new file mode 100644 index 0000000..eff779d --- /dev/null +++ b/exchange/schemas/user_schema.py @@ -0,0 +1,12 @@ +import graphene + + +class UserQuery(graphene.ObjectType): + """User schema - ID token authentication""" + _placeholder = graphene.String(description="Placeholder field") + + def resolve__placeholder(self, info): + return None + + +user_schema = graphene.Schema(query=UserQuery) diff --git a/exchange/services.py b/exchange/services.py new file mode 100644 index 0000000..01580ab --- /dev/null +++ b/exchange/services.py @@ -0,0 +1,25 @@ +import requests as http_requests +from django.conf import settings +import logging + +logger = logging.getLogger(__name__) + + +class OdooService: + def __init__(self): + self.base_url = f"http://{settings.ODOO_INTERNAL_URL}" + + def get_products(self): + """Получить список всех товаров из Odoo""" + try: + url = f"{self.base_url}/fastapi/products/products" + response = http_requests.get(url, timeout=10) + if response.status_code == 200: + return response.json() + else: + logger.error(f"Error fetching products: {response.status_code}") + return [] + except Exception as e: + logger.error(f"Error fetching products from Odoo: {e}") + return [] + diff --git a/exchange/settings.py b/exchange/settings.py new file mode 100644 index 0000000..93d98c0 --- /dev/null +++ b/exchange/settings.py @@ -0,0 +1,141 @@ +import os +from pathlib import Path +from urllib.parse import urlparse +from infisical_sdk import InfisicalSDKClient +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration + +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 /exchange and /shared +for secret_path in ["/exchange", "/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://exchange.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', + 'offers', + 'purchase_requests', + 'suppliers', +] + +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 = 'exchange.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 = 'exchange.wsgi.application' + +db_url = os.environ["EXCHANGE_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 + +# 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_EXCHANGE_AUDIENCE = os.getenv('LOGTO_EXCHANGE_AUDIENCE', 'https://exchange.optovia.ru') +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/exchange/settings_local.py b/exchange/settings_local.py new file mode 100644 index 0000000..8cf4956 --- /dev/null +++ b/exchange/settings_local.py @@ -0,0 +1,110 @@ +import os +from pathlib import Path +from urllib.parse import urlparse +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration + + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production') + +DEBUG = 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://exchange.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', + 'offers', + 'purchase_requests', + 'suppliers', +] + +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 = 'exchange.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 = 'exchange.wsgi.application' + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} +# 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 = ['http://localhost:3000', 'https://optovia.ru'] +CORS_ALLOW_CREDENTIALS = True + +# 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_EXCHANGE_AUDIENCE = os.getenv('LOGTO_EXCHANGE_AUDIENCE', 'https://exchange.optovia.ru') +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/exchange/temporal_client.py b/exchange/temporal_client.py new file mode 100644 index 0000000..ea0c351 --- /dev/null +++ b/exchange/temporal_client.py @@ -0,0 +1,79 @@ +import asyncio +import logging +import os +from typing import Tuple + +from temporalio.client import Client + +logger = logging.getLogger(__name__) + +TEMPORAL_INTERNAL_URL = os.getenv("TEMPORAL_INTERNAL_URL", "temporal:7233") +TEMPORAL_NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "default") +TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "platform-worker") + + +async def _start_offer_workflow_async(payload: dict) -> Tuple[str, str]: + client = await Client.connect(TEMPORAL_INTERNAL_URL, namespace=TEMPORAL_NAMESPACE) + + workflow_id = f"offer-{payload['offer_uuid']}" + + handle = await client.start_workflow( + "create_offer", + payload, + id=workflow_id, + task_queue=TEMPORAL_TASK_QUEUE, + ) + + logger.info("Started offer workflow %s", workflow_id) + return handle.id, handle.result_run_id + + +def start_offer_workflow( + *, + offer_uuid: str, + team_uuid: str, + product_uuid: str, + product_name: str, + category_name: str | None = None, + location_uuid: str | None = None, + location_name: str | None = None, + location_country: str | None = None, + location_country_code: str | None = None, + location_latitude: float | None = None, + location_longitude: float | None = None, + quantity=None, + unit: str | None = None, + price_per_unit=None, + currency: str | None = None, + description: str | None = None, + valid_until=None, + terminus_schema_id: str | None = None, + terminus_payload: dict | None = None, +) -> Tuple[str, str]: + payload = { + "offer_uuid": offer_uuid, + "team_uuid": team_uuid, + "product_uuid": product_uuid, + "product_name": product_name, + "category_name": category_name, + "location_uuid": location_uuid, + "location_name": location_name, + "location_country": location_country, + "location_country_code": location_country_code, + "location_latitude": location_latitude, + "location_longitude": location_longitude, + "quantity": str(quantity) if quantity is not None else None, + "unit": unit, + "price_per_unit": str(price_per_unit) if price_per_unit is not None else None, + "currency": currency, + "description": description, + "valid_until": valid_until.isoformat() if hasattr(valid_until, "isoformat") else valid_until, + "terminus_schema_id": terminus_schema_id, + "terminus_payload": terminus_payload, + } + + try: + return asyncio.run(_start_offer_workflow_async(payload)) + except Exception: + logger.exception("Failed to start offer workflow %s", offer_uuid) + raise diff --git a/exchange/urls.py b/exchange/urls.py new file mode 100644 index 0000000..b57243c --- /dev/null +++ b/exchange/urls.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from django.urls import path +from django.views.decorators.csrf import csrf_exempt +from .views import PublicGraphQLView, UserGraphQLView, TeamGraphQLView, M2MGraphQLView +from .schemas.public_schema import public_schema +from .schemas.user_schema import user_schema +from .schemas.team_schema import team_schema +from .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))), +] diff --git a/exchange/views.py b/exchange/views.py new file mode 100644 index 0000000..f9e0bfb --- /dev/null +++ b/exchange/views.py @@ -0,0 +1,45 @@ +""" +GraphQL Views for Exchange API. + +Authentication is handled by GRAPHENE MIDDLEWARE in settings.py +""" +from graphene_django.views import GraphQLView + +from .graphql_middleware import ( + M2MNoAuthMiddleware, + PublicNoAuthMiddleware, + TeamJWTMiddleware, + UserJWTMiddleware, +) + + +class PublicGraphQLView(GraphQLView): + """Public endpoint - no authentication required.""" + + def __init__(self, *args, **kwargs): + kwargs['middleware'] = [PublicNoAuthMiddleware()] + super().__init__(*args, **kwargs) + + +class UserGraphQLView(GraphQLView): + """User endpoint - requires ID Token.""" + + def __init__(self, *args, **kwargs): + kwargs['middleware'] = [UserJWTMiddleware()] + super().__init__(*args, **kwargs) + + +class TeamGraphQLView(GraphQLView): + """Team endpoint - requires Organization Access Token.""" + + def __init__(self, *args, **kwargs): + kwargs['middleware'] = [TeamJWTMiddleware()] + super().__init__(*args, **kwargs) + + +class M2MGraphQLView(GraphQLView): + """M2M endpoint - internal services only.""" + + def __init__(self, *args, **kwargs): + kwargs['middleware'] = [M2MNoAuthMiddleware()] + super().__init__(*args, **kwargs) diff --git a/exchange/wsgi.py b/exchange/wsgi.py new file mode 100644 index 0000000..5f47a2d --- /dev/null +++ b/exchange/wsgi.py @@ -0,0 +1,11 @@ +""" +WSGI config for exchange project. +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'exchange.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..97ecf2e --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'exchange.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) + + +if __name__ == '__main__': + main() diff --git a/nixpacks.toml b/nixpacks.toml new file mode 100644 index 0000000..46631c3 --- /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 exchange.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/offers/__init__.py b/offers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/offers/admin.py b/offers/admin.py new file mode 100644 index 0000000..bc49b32 --- /dev/null +++ b/offers/admin.py @@ -0,0 +1,55 @@ +from django.contrib import admin, messages + +from .models import Offer +from .services import OfferService + + +@admin.register(Offer) +class OfferAdmin(admin.ModelAdmin): + list_display = [ + 'product_name', + 'status', + 'workflow_status', + 'team_uuid', + 'location_name', + 'location_country', + 'quantity', + 'price_per_unit', + 'created_at', + ] + list_filter = ['status', 'workflow_status', 'created_at', 'category_name', 'location_country'] + search_fields = ['product_name', 'description', 'location_name', 'uuid'] + readonly_fields = ['uuid', 'workflow_status', 'workflow_error', 'created_at', 'updated_at'] + actions = ['sync_to_graph'] + + @admin.action(description="Синхронизировать в граф (запустить workflow)") + def sync_to_graph(self, request, queryset): + """Запускает workflow для пересинхронизации выбранных офферов в ArangoDB граф""" + success_count = 0 + error_count = 0 + + for offer in queryset: + try: + workflow_id, run_id = OfferService.resync_offer_via_workflow(offer) + offer.workflow_status = 'pending' + offer.workflow_error = '' + offer.save(update_fields=['workflow_status', 'workflow_error']) + success_count += 1 + except Exception as e: + offer.workflow_status = 'error' + offer.workflow_error = str(e) + offer.save(update_fields=['workflow_status', 'workflow_error']) + error_count += 1 + + if success_count: + self.message_user( + request, + f"Запущен workflow для {success_count} офферов", + messages.SUCCESS, + ) + if error_count: + self.message_user( + request, + f"Ошибка при запуске workflow для {error_count} офферов", + messages.ERROR, + ) diff --git a/offers/apps.py b/offers/apps.py new file mode 100644 index 0000000..ed6fa80 --- /dev/null +++ b/offers/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OffersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'offers' diff --git a/offers/management/__init__.py b/offers/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/offers/management/__init__.py @@ -0,0 +1 @@ + diff --git a/offers/management/commands/__init__.py b/offers/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/offers/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/offers/management/commands/seed_exchange.py b/offers/management/commands/seed_exchange.py new file mode 100644 index 0000000..d9d5672 --- /dev/null +++ b/offers/management/commands/seed_exchange.py @@ -0,0 +1,405 @@ +""" +Seed Suppliers and Offers for African cocoa belt. +Creates offers via Temporal workflow so they sync to the graph. +""" +import random +import uuid +from decimal import Decimal + +import requests +from django.core.management.base import BaseCommand +from django.db import transaction + +from offers.models import Offer +from offers.services import OfferService, OfferData +from suppliers.models import SupplierProfile + + +# African cocoa belt countries +AFRICAN_COUNTRIES = [ + ("Côte d'Ivoire", "CI", 6.8276, -5.2893), # Abidjan + ("Ghana", "GH", 5.6037, -0.1870), # Accra + ("Nigeria", "NG", 6.5244, 3.3792), # Lagos + ("Cameroon", "CM", 4.0511, 9.7679), # Douala + ("Togo", "TG", 6.1725, 1.2314), # Lomé +] + +# Products will be fetched from Odoo at runtime +# Format: (name, category, uuid) - populated by _fetch_products_from_odoo() + + +class Command(BaseCommand): + help = "Seed Suppliers and Offers for African cocoa belt with workflow sync" + + def add_arguments(self, parser): + parser.add_argument( + "--suppliers", + type=int, + default=10, + help="How many suppliers to create (default: 10)", + ) + parser.add_argument( + "--offers", + type=int, + default=50, + help="How many offers to create (default: 50)", + ) + parser.add_argument( + "--clear", + action="store_true", + help="Delete all existing suppliers and offers before seeding", + ) + parser.add_argument( + "--no-workflow", + action="store_true", + help="Create offers directly in DB without workflow (no graph sync)", + ) + parser.add_argument( + "--geo-url", + type=str, + default="http://geo:8000/graphql/public/", + help="Geo service GraphQL URL (default: http://geo:8000/graphql/public/)", + ) + parser.add_argument( + "--odoo-url", + type=str, + default="http://odoo:8069", + help="Odoo URL (default: http://odoo:8069)", + ) + parser.add_argument( + "--product", + type=str, + default=None, + help="Filter offers by product name (e.g., 'Cocoa Beans')", + ) + + def handle(self, *args, **options): + if options["clear"]: + with transaction.atomic(): + offers_deleted, _ = Offer.objects.all().delete() + suppliers_deleted, _ = SupplierProfile.objects.all().delete() + self.stdout.write(self.style.WARNING( + f"Deleted {suppliers_deleted} supplier profiles and {offers_deleted} offers" + )) + + suppliers_count = max(0, options["suppliers"]) + offers_count = max(0, options["offers"]) + use_workflow = not options["no_workflow"] + geo_url = options["geo_url"] + odoo_url = options["odoo_url"] + product_filter = options["product"] + + # Fetch products from Odoo + self.stdout.write("Fetching products from Odoo...") + products = self._fetch_products_from_odoo(odoo_url) + if not products: + self.stdout.write(self.style.ERROR("No products found in Odoo. Cannot create offers.")) + return + self.stdout.write(f"Found {len(products)} products") + + # Filter by product name if specified + if product_filter: + products = [p for p in products if product_filter.lower() in p[0].lower()] + if not products: + self.stdout.write(self.style.ERROR(f"No products matching '{product_filter}' found.")) + return + self.stdout.write(f"Filtered to {len(products)} products matching '{product_filter}'") + + # Fetch African hubs from geo service + self.stdout.write("Fetching African hubs from geo service...") + hubs = self._fetch_african_hubs(geo_url) + + if not hubs: + self.stdout.write(self.style.WARNING( + "No African hubs found. Using default locations." + )) + hubs = self._default_african_hubs() + + self.stdout.write(f"Found {len(hubs)} African hubs") + + # Create suppliers + self.stdout.write(f"Creating {suppliers_count} suppliers...") + new_suppliers = self._create_suppliers(suppliers_count, hubs) + self.stdout.write(self.style.SUCCESS(f"Created {len(new_suppliers)} suppliers")) + + # Create offers + self.stdout.write(f"Creating {offers_count} offers (workflow={use_workflow})...") + if use_workflow: + created_offers = self._create_offers_via_workflow(offers_count, hubs, products) + else: + created_offers = self._create_offers_direct(offers_count, hubs, products) + self.stdout.write(self.style.SUCCESS(f"Created {len(created_offers)} offers")) + + def _fetch_products_from_odoo(self, odoo_url: str) -> list: + """Fetch products from Odoo via JSON-RPC""" + products = [] + try: + # Search for products + response = requests.post( + f"{odoo_url}/jsonrpc", + json={ + "jsonrpc": "2.0", + "method": "call", + "params": { + "service": "object", + "method": "execute_kw", + "args": [ + "odoo", # database + 2, # uid (admin) + "admin", # password + "products.product", # model + "search_read", + [[]], # domain (all products) + {"fields": ["uuid", "name", "category_id"]}, + ], + }, + "id": 1, + }, + timeout=10, + ) + if response.status_code == 200: + data = response.json() + result = data.get("result", []) + for p in result: + category_name = p.get("category_id", [None, "Agriculture"])[1] if p.get("category_id") else "Agriculture" + products.append((p["name"], category_name, p["uuid"])) + except Exception as e: + self.stdout.write(self.style.WARNING(f"Failed to fetch products from Odoo: {e}")) + + return products + + def _fetch_african_hubs(self, geo_url: str) -> list: + """Fetch African hubs from geo service via GraphQL. + + Gets all nodes and filters by African countries in Python + since the GraphQL schema doesn't support country filter. + """ + african_countries = { + "Côte d'Ivoire", "Ivory Coast", "Ghana", "Nigeria", + "Cameroon", "Togo", "Senegal", "Mali", "Burkina Faso", + "Guinea", "Benin", "Niger", "Sierra Leone", "Liberia", + } + + query = """ + query GetNodes($limit: Int) { + nodes(limit: $limit) { + uuid + name + country + countryCode + latitude + longitude + } + } + """ + try: + response = requests.post( + geo_url, + json={"query": query, "variables": {"limit": 5000}}, + timeout=30, + ) + if response.status_code == 200: + data = response.json() + if "errors" in data: + self.stdout.write(self.style.WARNING(f"GraphQL errors: {data['errors']}")) + return [] + nodes = data.get("data", {}).get("nodes", []) + # Filter by African countries + african_hubs = [ + n for n in nodes + if n.get("country") in african_countries + ] + return african_hubs + except Exception as e: + self.stdout.write(self.style.WARNING(f"Failed to fetch hubs: {e}")) + + return [] + + def _default_african_hubs(self) -> list: + """Default African hubs if geo service is unavailable""" + return [ + { + "uuid": str(uuid.uuid4()), + "name": "Port of Abidjan", + "country": "Côte d'Ivoire", + "countryCode": "CI", + "latitude": 5.3167, + "longitude": -4.0167, + }, + { + "uuid": str(uuid.uuid4()), + "name": "Port of San Pedro", + "country": "Côte d'Ivoire", + "countryCode": "CI", + "latitude": 4.7500, + "longitude": -6.6333, + }, + { + "uuid": str(uuid.uuid4()), + "name": "Port of Tema", + "country": "Ghana", + "countryCode": "GH", + "latitude": 5.6333, + "longitude": -0.0167, + }, + { + "uuid": str(uuid.uuid4()), + "name": "Port of Takoradi", + "country": "Ghana", + "countryCode": "GH", + "latitude": 4.8833, + "longitude": -1.7500, + }, + { + "uuid": str(uuid.uuid4()), + "name": "Port of Lagos", + "country": "Nigeria", + "countryCode": "NG", + "latitude": 6.4531, + "longitude": 3.3958, + }, + { + "uuid": str(uuid.uuid4()), + "name": "Port of Douala", + "country": "Cameroon", + "countryCode": "CM", + "latitude": 4.0483, + "longitude": 9.7043, + }, + { + "uuid": str(uuid.uuid4()), + "name": "Port of Lomé", + "country": "Togo", + "countryCode": "TG", + "latitude": 6.1375, + "longitude": 1.2125, + }, + ] + + def _create_suppliers(self, count: int, hubs: list) -> list: + """Create supplier profiles in African countries""" + created = [] + for idx in range(count): + hub = random.choice(hubs) if hubs else None + country, country_code = self._get_random_african_country() + + # Use hub coordinates if available, otherwise use country defaults + if hub: + lat = hub["latitude"] + random.uniform(-0.5, 0.5) + lng = hub["longitude"] + random.uniform(-0.5, 0.5) + else: + lat, lng = self._get_country_coords(country) + lat += random.uniform(-0.5, 0.5) + lng += random.uniform(-0.5, 0.5) + + profile = SupplierProfile.objects.create( + uuid=str(uuid.uuid4()), + team_uuid=str(uuid.uuid4()), + name=f"Cocoa Supplier {idx + 1} ({country})", + description=f"Premium cocoa supplier from {country}. Specializing in high-quality cocoa beans.", + country=country, + country_code=country_code, + logo_url="", + latitude=lat, + longitude=lng, + is_verified=random.choice([True, True, False]), # 66% verified + is_active=True, + ) + created.append(profile) + return created + + def _create_offers_via_workflow(self, count: int, hubs: list, products: list) -> list: + """Create offers via Temporal workflow (syncs to graph)""" + created = [] + suppliers = list(SupplierProfile.objects.all()) + + if not suppliers: + self.stdout.write(self.style.ERROR("No suppliers found. Create suppliers first.")) + return created + + for idx in range(count): + supplier = random.choice(suppliers) + hub = random.choice(hubs) + product_name, category_name, product_uuid = random.choice(products) + + data = OfferData( + team_uuid=supplier.team_uuid, + product_uuid=product_uuid, + product_name=product_name, + category_name=category_name, + location_uuid=hub["uuid"], + location_name=hub["name"], + location_country=hub["country"], + location_country_code=hub.get("countryCode", ""), + location_latitude=hub["latitude"], + location_longitude=hub["longitude"], + quantity=self._rand_decimal(10, 500, 2), + unit="ton", + price_per_unit=self._rand_decimal(2000, 4000, 2), # Cocoa ~$2000-4000/ton + currency="USD", + description=f"High quality {product_name} from {hub['country']}", + ) + + try: + offer_uuid, workflow_id, _ = OfferService.create_offer_via_workflow(data) + self.stdout.write(f" [{idx+1}/{count}] Created offer {offer_uuid[:8]}... workflow: {workflow_id}") + created.append(offer_uuid) + except Exception as e: + self.stdout.write(self.style.ERROR(f" [{idx+1}/{count}] Failed: {e}")) + + return created + + def _create_offers_direct(self, count: int, hubs: list, products: list) -> list: + """Create offers directly in DB (no workflow, no graph sync)""" + created = [] + suppliers = list(SupplierProfile.objects.all()) + + if not suppliers: + self.stdout.write(self.style.ERROR("No suppliers found. Create suppliers first.")) + return created + + for idx in range(count): + supplier = random.choice(suppliers) + hub = random.choice(hubs) + product_name, category_name, product_uuid = random.choice(products) + + offer = Offer.objects.create( + uuid=str(uuid.uuid4()), + team_uuid=supplier.team_uuid, + status="active", + workflow_status="pending", + location_uuid=hub["uuid"], + location_name=hub["name"], + location_country=hub["country"], + location_country_code=hub.get("countryCode", ""), + location_latitude=hub["latitude"], + location_longitude=hub["longitude"], + product_uuid=product_uuid, + product_name=product_name, + category_name=category_name, + quantity=self._rand_decimal(10, 500, 2), + unit="ton", + price_per_unit=self._rand_decimal(2000, 4000, 2), + currency="USD", + description=f"High quality {product_name} from {hub['country']}", + ) + created.append(offer) + + return created + + def _get_random_african_country(self) -> tuple: + """Get random African country name and code""" + country, code, _, _ = random.choice(AFRICAN_COUNTRIES) + return country, code + + def _get_country_coords(self, country: str) -> tuple: + """Get default coordinates for a country""" + for name, code, lat, lng in AFRICAN_COUNTRIES: + if name == country: + return lat, lng + return 6.0, 0.0 # Default: Gulf of Guinea + + def _rand_decimal(self, low: int, high: int, places: int) -> Decimal: + value = random.uniform(low, high) + quantize_str = "1." + "0" * places + return Decimal(str(value)).quantize(Decimal(quantize_str)) diff --git a/offers/migrations/0001_initial.py b/offers/migrations/0001_initial.py new file mode 100644 index 0000000..3d9ac04 --- /dev/null +++ b/offers/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated manually for exchange refactoring + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Offer', + 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)), + ('team_uuid', models.CharField(max_length=100)), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='')), + ('status', models.CharField(choices=[('draft', 'Черновик'), ('active', 'Активно'), ('closed', 'Закрыто'), ('cancelled', 'Отменено')], default='active', max_length=50)), + ('location_uuid', models.CharField(max_length=100)), + ('location_name', models.CharField(blank=True, default='', max_length=255)), + ('valid_until', models.DateField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'offers', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='OfferLine', + 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)), + ('product_uuid', models.CharField(max_length=100)), + ('product_name', models.CharField(blank=True, default='', max_length=255)), + ('category_name', models.CharField(blank=True, default='', max_length=255)), + ('quantity', models.DecimalField(decimal_places=2, max_digits=10)), + ('unit', models.CharField(default='ton', max_length=20)), + ('price_per_unit', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)), + ('currency', models.CharField(default='USD', max_length=3)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='offers.offer')), + ], + options={ + 'db_table': 'offer_lines', + 'ordering': ['id'], + }, + ), + ] diff --git a/offers/migrations/0002_remove_offer_title_offer_category_name_and_more.py b/offers/migrations/0002_remove_offer_title_offer_category_name_and_more.py new file mode 100644 index 0000000..72c5fde --- /dev/null +++ b/offers/migrations/0002_remove_offer_title_offer_category_name_and_more.py @@ -0,0 +1,80 @@ +# Generated by Django 5.2.9 on 2025-12-10 04:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('offers', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='offer', + name='title', + ), + migrations.AddField( + model_name='offer', + name='category_name', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='offer', + name='currency', + field=models.CharField(default='USD', max_length=3), + ), + migrations.AddField( + model_name='offer', + name='location_country', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.AddField( + model_name='offer', + name='location_country_code', + field=models.CharField(blank=True, default='', max_length=3), + ), + migrations.AddField( + model_name='offer', + name='location_latitude', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='offer', + name='location_longitude', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='offer', + name='price_per_unit', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True), + ), + migrations.AddField( + model_name='offer', + name='product_name', + field=models.CharField(default='', max_length=255), + ), + migrations.AddField( + model_name='offer', + name='product_uuid', + field=models.CharField(default='', max_length=100), + ), + migrations.AddField( + model_name='offer', + name='quantity', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10), + ), + migrations.AddField( + model_name='offer', + name='unit', + field=models.CharField(default='ton', max_length=20), + ), + migrations.AlterField( + model_name='offer', + name='location_uuid', + field=models.CharField(blank=True, default='', max_length=100), + ), + migrations.DeleteModel( + name='OfferLine', + ), + ] diff --git a/offers/migrations/0003_offer_workflow_status.py b/offers/migrations/0003_offer_workflow_status.py new file mode 100644 index 0000000..d502e61 --- /dev/null +++ b/offers/migrations/0003_offer_workflow_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2025-12-30 02:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('offers', '0002_remove_offer_title_offer_category_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='offer', + name='workflow_status', + field=models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20), + ), + ] diff --git a/offers/migrations/0004_offer_terminus_document_id_offer_terminus_schema_id_and_more.py b/offers/migrations/0004_offer_terminus_document_id_offer_terminus_schema_id_and_more.py new file mode 100644 index 0000000..49b469f --- /dev/null +++ b/offers/migrations/0004_offer_terminus_document_id_offer_terminus_schema_id_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.9 on 2025-12-30 03:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('offers', '0003_offer_workflow_status'), + ] + + operations = [ + migrations.AddField( + model_name='offer', + name='terminus_document_id', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='offer', + name='terminus_schema_id', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='offer', + name='workflow_error', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/offers/migrations/__init__.py b/offers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/offers/models.py b/offers/models.py new file mode 100644 index 0000000..cccf18d --- /dev/null +++ b/offers/models.py @@ -0,0 +1,64 @@ +from django.db import models +import uuid + + +class Offer(models.Model): + """Оффер (предложение) от поставщика в каталоге — один товар по одной цене""" + STATUS_CHOICES = [ + ('draft', 'Черновик'), + ('active', 'Активно'), + ('closed', 'Закрыто'), + ('cancelled', 'Отменено'), + ] + WORKFLOW_STATUS_CHOICES = [ + ('pending', 'Ожидает обработки'), + ('active', 'Активен'), + ('error', 'Ошибка'), + ] + + uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4) + team_uuid = models.CharField(max_length=100) # Команда поставщика + status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='active') + workflow_status = models.CharField( + max_length=20, + choices=WORKFLOW_STATUS_CHOICES, + default='pending', + ) + workflow_error = models.TextField(blank=True, default='') + + # Локация отгрузки + location_uuid = models.CharField(max_length=100, blank=True, default='') + location_name = models.CharField(max_length=255, blank=True, default='') + location_country = models.CharField(max_length=100, blank=True, default='') + location_country_code = models.CharField(max_length=3, blank=True, default='') + location_latitude = models.FloatField(null=True, blank=True) + location_longitude = models.FloatField(null=True, blank=True) + + # Товар + product_uuid = models.CharField(max_length=100, default='') + product_name = models.CharField(max_length=255, default='') + category_name = models.CharField(max_length=255, blank=True, default='') + + # Количество и цена + quantity = models.DecimalField(max_digits=10, decimal_places=2, default=0) + unit = models.CharField(max_length=20, default='ton') + price_per_unit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + currency = models.CharField(max_length=3, default='USD') + + # Описание (опционально) + description = models.TextField(blank=True, default='') + terminus_schema_id = models.CharField(max_length=255, blank=True, default='') + terminus_document_id = models.CharField(max_length=255, blank=True, default='') + + # Срок действия + valid_until = models.DateField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'offers' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.product_name} - {self.quantity} {self.unit} ({self.status})" diff --git a/offers/services.py b/offers/services.py new file mode 100644 index 0000000..3a74dec --- /dev/null +++ b/offers/services.py @@ -0,0 +1,103 @@ +""" +Сервис для создания офферов через Temporal workflow. +Используется в Django admin action и в seed командах. +""" +import uuid +import logging +from dataclasses import dataclass +from decimal import Decimal +from typing import Optional, Tuple + +from exchange.temporal_client import start_offer_workflow + +logger = logging.getLogger(__name__) + + +@dataclass +class OfferData: + """Данные для создания оффера""" + team_uuid: str + product_uuid: str + product_name: str + location_uuid: str + location_name: str + location_country: str + location_country_code: str + location_latitude: float + location_longitude: float + quantity: Decimal + unit: str = "ton" + price_per_unit: Optional[Decimal] = None + currency: str = "USD" + category_name: str = "" + description: str = "" + + +class OfferService: + """Сервис для создания офферов через workflow""" + + @staticmethod + def create_offer_via_workflow(data: OfferData) -> Tuple[str, str, str]: + """ + Создает оффер через Temporal workflow. + + Returns: + Tuple[offer_uuid, workflow_id, run_id] + """ + offer_uuid = str(uuid.uuid4()) + + workflow_id, run_id = start_offer_workflow( + offer_uuid=offer_uuid, + team_uuid=data.team_uuid, + product_uuid=data.product_uuid, + product_name=data.product_name, + category_name=data.category_name, + location_uuid=data.location_uuid, + location_name=data.location_name, + location_country=data.location_country, + location_country_code=data.location_country_code, + location_latitude=data.location_latitude, + location_longitude=data.location_longitude, + quantity=data.quantity, + unit=data.unit, + price_per_unit=data.price_per_unit, + currency=data.currency, + description=data.description, + ) + + logger.info(f"Started offer workflow: {workflow_id} for offer {offer_uuid}") + return offer_uuid, workflow_id, run_id + + @staticmethod + def resync_offer_via_workflow(offer) -> Tuple[str, str]: + """ + Пересоздает workflow для существующего оффера. + Используется для пере-синхронизации в граф. + + Args: + offer: Offer model instance + + Returns: + Tuple[workflow_id, run_id] + """ + workflow_id, run_id = start_offer_workflow( + offer_uuid=offer.uuid, + team_uuid=offer.team_uuid, + product_uuid=offer.product_uuid, + product_name=offer.product_name, + category_name=offer.category_name, + location_uuid=offer.location_uuid, + location_name=offer.location_name, + location_country=offer.location_country, + location_country_code=offer.location_country_code, + location_latitude=offer.location_latitude, + location_longitude=offer.location_longitude, + quantity=offer.quantity, + unit=offer.unit, + price_per_unit=offer.price_per_unit, + currency=offer.currency, + description=offer.description, + ) + + logger.info(f"Restarted offer workflow: {workflow_id} for offer {offer.uuid}") + return workflow_id, run_id diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..5c7ff32 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1022 @@ +# 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.42.1" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "boto3-1.42.1-py3-none-any.whl", hash = "sha256:9a8f9799afff600ff5cb43f57a619a5375ea71077ec958bda70e296378da7024"}, + {file = "boto3-1.42.1.tar.gz", hash = "sha256:137fbea593a30afa1b75656ea1f1ff8796be608a8c77f1b606c4473289679898"}, +] + +[package.dependencies] +botocore = ">=1.42.0,<1.43.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.16.0,<0.17.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.42.1" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "botocore-1.42.1-py3-none-any.whl", hash = "sha256:9d49f5197487f9f71daa9c5397f81484ffcc0dc1cf89a63e94ae3e5a27faa98c"}, + {file = "botocore-1.42.1.tar.gz", hash = "sha256:3337df815c69dd87c314ee29329b8ea411ad3562fb6563d139bbe085dac14ce0"}, +] + +[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.1)"] + +[[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.9" +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.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a"}, + {file = "django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495"}, +] + +[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 = "django-environ" +version = "0.12.0" +description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." +optional = false +python-versions = "<4,>=3.9" +groups = ["main"] +files = [ + {file = "django_environ-0.12.0-py2.py3-none-any.whl", hash = "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca"}, + {file = "django_environ-0.12.0.tar.gz", hash = "sha256:227dc891453dd5bde769c3449cf4a74b6f2ee8f7ab2361c93a07068f4179041a"}, +] + +[package.extras] +develop = ["coverage[toml] (>=5.0a4)", "furo (>=2024.8.6)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)", "sphinx (>=5.0)", "sphinx-notfound-page"] +docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-notfound-page"] +testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"] + +[[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.13" +description = "Official Infisical SDK for Python (Latest)" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "infisicalsdk-1.0.13-py3-none-any.whl", hash = "sha256:bd67918e9c748d37fc8aed2925323f964462062d3877edc6d51883f29cc8fcd1"}, + {file = "infisicalsdk-1.0.13.tar.gz", hash = "sha256:baf85d51844f62f748e12e1ec451c06c27adf6256e97735272accc848174a950"}, +] + +[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.3.0" +description = "Nexus Python SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "nexus_rpc-1.3.0-py3-none-any.whl", hash = "sha256:aee0707b4861b22d8124ecb3f27d62dafbe8777dc50c66c91e49c006f971b92d"}, + {file = "nexus_rpc-1.3.0.tar.gz", hash = "sha256:e56d3b57b60d707ce7a72f83f23f106b86eca1043aa658e44582ab5ff30ab9ad"}, +] + +[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.2" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d"}, + {file = "protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4"}, + {file = "protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43"}, + {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e"}, + {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872"}, + {file = "protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f"}, + {file = "protobuf-6.33.2-cp39-cp39-win32.whl", hash = "sha256:7109dcc38a680d033ffb8bf896727423528db9163be1b6a02d6a49606dcadbfe"}, + {file = "protobuf-6.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:2981c58f582f44b6b13173e12bb8656711189c2a70250845f264b877f00b1913"}, + {file = "protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c"}, + {file = "protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4"}, +] + +[[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.16.0" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe"}, + {file = "s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920"}, +] + +[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.46.0" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "sentry_sdk-2.46.0-py2.py3-none-any.whl", hash = "sha256:4eeeb60198074dff8d066ea153fa6f241fef1668c10900ea53a4200abc8da9b1"}, + {file = "sentry_sdk-2.46.0.tar.gz", hash = "sha256:91821a23460725734b7741523021601593f35731808afc0bb2ba46c27b8acd91"}, +] + +[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.4" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb"}, + {file = "sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e"}, +] + +[package.extras] +dev = ["build"] +doc = ["sphinx"] + +[[package]] +name = "temporalio" +version = "1.21.1" +description = "Temporal.io Python SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "temporalio-1.21.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:476c575a8eb16ee0ebc388de42c8582465c5b2e01e6c662b23585b96afdda29e"}, + {file = "temporalio-1.21.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1ba3980d6dff925aeff09d7de0bf82336a2c0159096801e9e755e0f01524a9a7"}, + {file = "temporalio-1.21.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38dd23396e7a8acad1419873d189e2ae49ae4357b1c2a005f19e94aaaf702f90"}, + {file = "temporalio-1.21.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f62aabb4df8855e40a1f66bd2b9d9226ebb4e2641377dceaf4eab4aaf708a6"}, + {file = "temporalio-1.21.1-cp310-abi3-win_amd64.whl", hash = "sha256:a9bebb9f55f287b44fc88e9446e3abf1f91c7e6bff1842b40b04260ce6d9ce24"}, + {file = "temporalio-1.21.1.tar.gz", hash = "sha256:9d4fbfd5d8cf1afdbf9e9c34f68158073904cee227eb602602ed86c39e992bd8"}, +] + +[package.dependencies] +nexus-rpc = "1.3.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.7)"] +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.20251210" +description = "Typing stubs for protobuf" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140"}, + {file = "types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763"}, +] + +[[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 = "b76d64c0c0d5cef3e1967c92df9d60d611363c5e7b4172e161b0d7fee845167f" diff --git a/purchase_requests/__init__.py b/purchase_requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/purchase_requests/admin.py b/purchase_requests/admin.py new file mode 100644 index 0000000..16c0ef8 --- /dev/null +++ b/purchase_requests/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from .models import Request + + +@admin.register(Request) +class RequestAdmin(admin.ModelAdmin): + list_display = ['uuid', 'product_uuid', 'quantity', 'user_id', 'created_at'] + list_filter = ['created_at'] + search_fields = ['uuid', 'user_id'] diff --git a/purchase_requests/apps.py b/purchase_requests/apps.py new file mode 100644 index 0000000..2dd10c3 --- /dev/null +++ b/purchase_requests/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PurchaseRequestsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'purchase_requests' diff --git a/purchase_requests/migrations/0001_initial.py b/purchase_requests/migrations/0001_initial.py new file mode 100644 index 0000000..629699c --- /dev/null +++ b/purchase_requests/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated manually for exchange refactoring + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Request', + 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)), + ('product_uuid', models.CharField(max_length=100)), + ('quantity', models.DecimalField(decimal_places=2, max_digits=10)), + ('source_location_uuid', models.CharField(max_length=100)), + ('user_id', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'calculations', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/purchase_requests/migrations/__init__.py b/purchase_requests/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/purchase_requests/models.py b/purchase_requests/models.py new file mode 100644 index 0000000..c65e529 --- /dev/null +++ b/purchase_requests/models.py @@ -0,0 +1,21 @@ +from django.db import models +import uuid + + +class Request(models.Model): + """Заявка покупателя (RFQ - Request For Quotation)""" + uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4) + product_uuid = models.CharField(max_length=100) + quantity = models.DecimalField(max_digits=10, decimal_places=2) + source_location_uuid = models.CharField(max_length=100) + user_id = models.CharField(max_length=255) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'calculations' # Keep old table name for data compatibility + ordering = ['-created_at'] + + def __str__(self): + return f"Заявка {self.uuid} - {self.quantity}" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d52c32a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "exchange" +version = "0.1.0" +description = "Exchange backend service (offers & requests)" +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)", + "gunicorn (>=23.0.0,<24.0.0)", + "whitenoise (>=6.11.0,<7.0.0)", + "django-environ (>=0.12.0,<0.13.0)", + "sentry-sdk (>=2.46.0,<3.0.0)", + "python-dotenv (>=1.2.1,<2.0.0)", + "django-cors-headers (>=4.9.0,<5.0.0)", + "graphene-django (>=3.2.3,<4.0.0)", + "psycopg2-binary (>=2.9.11,<3.0.0)", + "infisicalsdk (>=1.0.12,<2.0.0)", + "pyjwt (>=2.10.1,<3.0.0)", + "cryptography (>=46.0.3,<47.0.0)", + "temporalio (>=1.21.1,<2.0.0)", +] + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/suppliers/__init__.py b/suppliers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/suppliers/admin.py b/suppliers/admin.py new file mode 100644 index 0000000..6b49f89 --- /dev/null +++ b/suppliers/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from .models import SupplierProfile + + +@admin.register(SupplierProfile) +class SupplierProfileAdmin(admin.ModelAdmin): + list_display = ['name', 'country', 'is_verified', 'is_active', 'created_at'] + list_filter = ['is_verified', 'is_active', 'country'] + search_fields = ['name', 'description', 'team_uuid'] + readonly_fields = ['uuid', 'created_at', 'updated_at'] diff --git a/suppliers/apps.py b/suppliers/apps.py new file mode 100644 index 0000000..b2c187d --- /dev/null +++ b/suppliers/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SuppliersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'suppliers' + verbose_name = 'Профили поставщиков' diff --git a/suppliers/management/__init__.py b/suppliers/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/suppliers/management/commands/__init__.py b/suppliers/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/suppliers/management/commands/fix_country_codes.py b/suppliers/management/commands/fix_country_codes.py new file mode 100644 index 0000000..031f43d --- /dev/null +++ b/suppliers/management/commands/fix_country_codes.py @@ -0,0 +1,34 @@ +from django.core.management.base import BaseCommand +from django.db import transaction + +from suppliers.models import SupplierProfile + + +COUNTRY_TO_CODE = { + "Russia": "RU", + "Kazakhstan": "KZ", + "Uzbekistan": "UZ", + "Turkey": "TR", + "UAE": "AE", + "China": "CN", + "India": "IN", + "Germany": "DE", + "Brazil": "BR", + "Kenya": "KE", +} + + +class Command(BaseCommand): + help = "Fill empty country_code based on country name" + + @transaction.atomic + def handle(self, *args, **options): + updated = 0 + for profile in SupplierProfile.objects.filter(country_code=""): + if profile.country in COUNTRY_TO_CODE: + profile.country_code = COUNTRY_TO_CODE[profile.country] + profile.save(update_fields=["country_code"]) + updated += 1 + self.stdout.write(f"Updated {profile.name}: {profile.country} -> {profile.country_code}") + + self.stdout.write(self.style.SUCCESS(f"Updated {updated} supplier profiles")) diff --git a/suppliers/migrations/0001_initial.py b/suppliers/migrations/0001_initial.py new file mode 100644 index 0000000..3862d42 --- /dev/null +++ b/suppliers/migrations/0001_initial.py @@ -0,0 +1,32 @@ +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name='Supplier', + 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)), + ('team_uuid', models.CharField(max_length=100, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='')), + ('country', models.CharField(blank=True, default='', max_length=100)), + ('logo_url', models.URLField(blank=True, default='')), + ('is_verified', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'suppliers', + 'ordering': ['name'], + }, + ), + ] diff --git a/suppliers/migrations/0002_supplier_country_code.py b/suppliers/migrations/0002_supplier_country_code.py new file mode 100644 index 0000000..594b607 --- /dev/null +++ b/suppliers/migrations/0002_supplier_country_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2025-12-10 05:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('suppliers', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='supplier', + name='country_code', + field=models.CharField(blank=True, default='', max_length=3), + ), + ] diff --git a/suppliers/migrations/0003_add_coordinates.py b/suppliers/migrations/0003_add_coordinates.py new file mode 100644 index 0000000..d6ed6ff --- /dev/null +++ b/suppliers/migrations/0003_add_coordinates.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.9 on 2025-12-10 11:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('suppliers', '0002_supplier_country_code'), + ] + + operations = [ + migrations.AddField( + model_name='supplier', + name='latitude', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='supplier', + name='longitude', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/suppliers/migrations/__init__.py b/suppliers/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/suppliers/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/suppliers/models.py b/suppliers/models.py new file mode 100644 index 0000000..4b36cbb --- /dev/null +++ b/suppliers/models.py @@ -0,0 +1,41 @@ +from django.db import models +import uuid + + +class SupplierProfile(models.Model): + """Профиль поставщика на бирже - витринные данные для marketplace. + + Первоисточник данных о поставщике - Team (team_type=SELLER). + Этот профиль содержит только витринную информацию для отображения на бирже. + """ + uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4) + team_uuid = models.CharField(max_length=100, unique=True) # Связь с Team + + name = models.CharField(max_length=255) + description = models.TextField(blank=True, default='') + country = models.CharField(max_length=100, blank=True, default='') + country_code = models.CharField(max_length=3, blank=True, default='') + logo_url = models.URLField(blank=True, default='') + + # Координаты для карты + latitude = models.FloatField(null=True, blank=True) + longitude = models.FloatField(null=True, blank=True) + + is_verified = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'suppliers' # Сохраняем имя таблицы для совместимости + ordering = ['name'] + verbose_name = 'Supplier Profile' + verbose_name_plural = 'Supplier Profiles' + + def __str__(self): + return f"{self.name} ({self.country})" + + +# Alias for backwards compatibility +Supplier = SupplierProfile