Initial commit from monorepo
This commit is contained in:
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -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}"]
|
||||||
43
README.md
Normal file
43
README.md
Normal file
@@ -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
|
||||||
0
exchange/__init__.py
Normal file
0
exchange/__init__.py
Normal file
11
exchange/asgi.py
Normal file
11
exchange/asgi.py
Normal file
@@ -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()
|
||||||
66
exchange/auth.py
Normal file
66
exchange/auth.py
Normal file
@@ -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"),
|
||||||
|
)
|
||||||
74
exchange/graphql_middleware.py
Normal file
74
exchange/graphql_middleware.py
Normal file
@@ -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)
|
||||||
74
exchange/permissions.py
Normal file
74
exchange/permissions.py
Normal file
@@ -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
|
||||||
0
exchange/schemas/__init__.py
Normal file
0
exchange/schemas/__init__.py
Normal file
127
exchange/schemas/m2m_schema.py
Normal file
127
exchange/schemas/m2m_schema.py
Normal file
@@ -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)
|
||||||
150
exchange/schemas/public_schema.py
Normal file
150
exchange/schemas/public_schema.py
Normal file
@@ -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)
|
||||||
198
exchange/schemas/team_schema.py
Normal file
198
exchange/schemas/team_schema.py
Normal file
@@ -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)
|
||||||
12
exchange/schemas/user_schema.py
Normal file
12
exchange/schemas/user_schema.py
Normal file
@@ -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)
|
||||||
25
exchange/services.py
Normal file
25
exchange/services.py
Normal file
@@ -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 []
|
||||||
|
|
||||||
141
exchange/settings.py
Normal file
141
exchange/settings.py
Normal file
@@ -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')
|
||||||
110
exchange/settings_local.py
Normal file
110
exchange/settings_local.py
Normal file
@@ -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')
|
||||||
79
exchange/temporal_client.py
Normal file
79
exchange/temporal_client.py
Normal file
@@ -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
|
||||||
16
exchange/urls.py
Normal file
16
exchange/urls.py
Normal file
@@ -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))),
|
||||||
|
]
|
||||||
45
exchange/views.py
Normal file
45
exchange/views.py
Normal file
@@ -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)
|
||||||
11
exchange/wsgi.py
Normal file
11
exchange/wsgi.py
Normal file
@@ -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()
|
||||||
22
manage.py
Normal file
22
manage.py
Normal file
@@ -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()
|
||||||
18
nixpacks.toml
Normal file
18
nixpacks.toml
Normal file
@@ -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"
|
||||||
0
offers/__init__.py
Normal file
0
offers/__init__.py
Normal file
55
offers/admin.py
Normal file
55
offers/admin.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
6
offers/apps.py
Normal file
6
offers/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class OffersConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'offers'
|
||||||
1
offers/management/__init__.py
Normal file
1
offers/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
offers/management/commands/__init__.py
Normal file
1
offers/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
405
offers/management/commands/seed_exchange.py
Normal file
405
offers/management/commands/seed_exchange.py
Normal file
@@ -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))
|
||||||
56
offers/migrations/0001_initial.py
Normal file
56
offers/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
18
offers/migrations/0003_offer_workflow_status.py
Normal file
18
offers/migrations/0003_offer_workflow_status.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
offers/migrations/__init__.py
Normal file
0
offers/migrations/__init__.py
Normal file
64
offers/models.py
Normal file
64
offers/models.py
Normal file
@@ -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})"
|
||||||
103
offers/services.py
Normal file
103
offers/services.py
Normal file
@@ -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
|
||||||
1022
poetry.lock
generated
Normal file
1022
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
0
purchase_requests/__init__.py
Normal file
0
purchase_requests/__init__.py
Normal file
9
purchase_requests/admin.py
Normal file
9
purchase_requests/admin.py
Normal file
@@ -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']
|
||||||
6
purchase_requests/apps.py
Normal file
6
purchase_requests/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequestsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'purchase_requests'
|
||||||
32
purchase_requests/migrations/0001_initial.py
Normal file
32
purchase_requests/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
purchase_requests/migrations/__init__.py
Normal file
0
purchase_requests/migrations/__init__.py
Normal file
21
purchase_requests/models.py
Normal file
21
purchase_requests/models.py
Normal file
@@ -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}"
|
||||||
28
pyproject.toml
Normal file
28
pyproject.toml
Normal file
@@ -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"
|
||||||
0
suppliers/__init__.py
Normal file
0
suppliers/__init__.py
Normal file
10
suppliers/admin.py
Normal file
10
suppliers/admin.py
Normal file
@@ -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']
|
||||||
7
suppliers/apps.py
Normal file
7
suppliers/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SuppliersConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'suppliers'
|
||||||
|
verbose_name = 'Профили поставщиков'
|
||||||
0
suppliers/management/__init__.py
Normal file
0
suppliers/management/__init__.py
Normal file
0
suppliers/management/commands/__init__.py
Normal file
0
suppliers/management/commands/__init__.py
Normal file
34
suppliers/management/commands/fix_country_codes.py
Normal file
34
suppliers/management/commands/fix_country_codes.py
Normal file
@@ -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"))
|
||||||
32
suppliers/migrations/0001_initial.py
Normal file
32
suppliers/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
suppliers/migrations/0002_supplier_country_code.py
Normal file
18
suppliers/migrations/0002_supplier_country_code.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
23
suppliers/migrations/0003_add_coordinates.py
Normal file
23
suppliers/migrations/0003_add_coordinates.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
suppliers/migrations/__init__.py
Normal file
1
suppliers/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
41
suppliers/models.py
Normal file
41
suppliers/models.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user