Initial commit from monorepo

This commit is contained in:
Ruslan Bakiev
2026-01-07 09:12:35 +07:00
commit 5a5186732b
53 changed files with 3347 additions and 0 deletions

24
Dockerfile Normal file
View 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
View 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
View File

11
exchange/asgi.py Normal file
View 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
View 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"),
)

View 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
View 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

View File

View 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)

View 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)

View 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)

View 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
View 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
View 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
View 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')

View 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
View 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
View 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
View 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
View 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
View 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
View File

55
offers/admin.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class OffersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'offers'

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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))

View 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'],
},
),
]

View File

@@ -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',
),
]

View 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),
),
]

View File

@@ -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=''),
),
]

View File

64
offers/models.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

View 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']

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PurchaseRequestsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'purchase_requests'

View 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'],
},
),
]

View File

View 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
View 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
View File

10
suppliers/admin.py Normal file
View 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
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class SuppliersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'suppliers'
verbose_name = 'Профили поставщиков'

View File

View 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"))

View 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'],
},
),
]

View 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),
),
]

View 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),
),
]

View File

@@ -0,0 +1 @@

41
suppliers/models.py Normal file
View 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