Initial commit from monorepo
This commit is contained in:
1
orders_app/__init__.py
Normal file
1
orders_app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Orders Django app
|
||||
2
orders_app/admin.py
Normal file
2
orders_app/admin.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# No Django admin needed - all data managed in Odoo ERP
|
||||
# Orders backend is a GraphQL proxy, not a data storage
|
||||
5
orders_app/apps.py
Normal file
5
orders_app/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class OrdersAppConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'orders_app'
|
||||
66
orders_app/auth.py
Normal file
66
orders_app/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"),
|
||||
)
|
||||
67
orders_app/graphql_middleware.py
Normal file
67
orders_app/graphql_middleware.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
GraphQL middleware for JWT authentication.
|
||||
|
||||
Each class is bound to a specific GraphQL endpoint (public/user/team).
|
||||
"""
|
||||
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 orders 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_ORDERS_AUDIENCE', None),
|
||||
)
|
||||
request.user_id = payload.get('sub')
|
||||
request.team_uuid = payload.get('team_uuid') # Custom claim from Logto
|
||||
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)
|
||||
63
orders_app/middleware.py
Normal file
63
orders_app/middleware.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
from jwt import InvalidTokenError
|
||||
|
||||
from .auth import get_bearer_token, scopes_from_payload, validator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LogtoJWTMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
JWT middleware для проверки токенов от Logto
|
||||
"""
|
||||
|
||||
def __init__(self, get_response=None):
|
||||
super().__init__(get_response)
|
||||
|
||||
# Audience validated only for non-introspection API calls
|
||||
self.audience = getattr(settings, "LOGTO_ORDERS_AUDIENCE", None)
|
||||
|
||||
def _is_introspection_query(self, request):
|
||||
"""Проверяет, является ли запрос introspection (для GraphQL codegen)"""
|
||||
if request.method != 'POST':
|
||||
return False
|
||||
try:
|
||||
body = json.loads(request.body.decode('utf-8'))
|
||||
query = body.get('query', '')
|
||||
return '__schema' in query or '__type' in query
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def process_request(self, request):
|
||||
"""Обрабатывает Team Access Token для Orders API"""
|
||||
|
||||
# Пропускаем проверку для admin панели и статики
|
||||
if request.path.startswith('/admin/') or request.path.startswith('/static/'):
|
||||
return None
|
||||
|
||||
# Пропускаем introspection запросы (для GraphQL codegen)
|
||||
if self._is_introspection_query(request):
|
||||
return None
|
||||
|
||||
try:
|
||||
token = get_bearer_token(request)
|
||||
payload = validator.decode(token, audience=self.audience)
|
||||
|
||||
# Проверяем scope
|
||||
scopes = scopes_from_payload(payload)
|
||||
if 'teams:member' not in scopes:
|
||||
return None
|
||||
|
||||
# Извлекаем данные пользователя и команды
|
||||
request.user_id = payload.get('sub')
|
||||
request.team_uuid = payload.get('team_uuid')
|
||||
request.scopes = scopes
|
||||
|
||||
except InvalidTokenError:
|
||||
# Любая ошибка = нет доступа (тихо)
|
||||
return None
|
||||
|
||||
return None
|
||||
0
orders_app/migrations/__init__.py
Normal file
0
orders_app/migrations/__init__.py
Normal file
2
orders_app/models.py
Normal file
2
orders_app/models.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Orders backend - GraphQL proxy for Odoo
|
||||
# No Django models needed - all data comes from Odoo FastAPI endpoints
|
||||
2
orders_app/odoo_models.py
Normal file
2
orders_app/odoo_models.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Odoo models removed - backend now uses direct Odoo FastAPI calls
|
||||
# No need to duplicate Odoo tables as Django models
|
||||
70
orders_app/permissions.py
Normal file
70
orders_app/permissions.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Декоратор для проверки scopes в JWT токене.
|
||||
Используется для защиты GraphQL резолверов.
|
||||
"""
|
||||
from functools import wraps
|
||||
from graphql import GraphQLError
|
||||
|
||||
|
||||
def require_scopes(*scopes: str):
|
||||
"""
|
||||
Декоратор для проверки наличия scopes в JWT токене.
|
||||
|
||||
Использование:
|
||||
@require_scopes("read:orders")
|
||||
def resolve_getTeamOrders(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:orders'}
|
||||
"""
|
||||
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
orders_app/schemas/__init__.py
Normal file
0
orders_app/schemas/__init__.py
Normal file
12
orders_app/schemas/public_schema.py
Normal file
12
orders_app/schemas/public_schema.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import graphene
|
||||
|
||||
|
||||
class PublicQuery(graphene.ObjectType):
|
||||
"""Public schema - no authentication required"""
|
||||
_placeholder = graphene.String(description="Placeholder field")
|
||||
|
||||
def resolve__placeholder(self, info):
|
||||
return None
|
||||
|
||||
|
||||
public_schema = graphene.Schema(query=PublicQuery)
|
||||
291
orders_app/schemas/team_schema.py
Normal file
291
orders_app/schemas/team_schema.py
Normal file
@@ -0,0 +1,291 @@
|
||||
import graphene
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from ..permissions import require_scopes
|
||||
|
||||
|
||||
class OrderType(graphene.ObjectType):
|
||||
uuid = graphene.String()
|
||||
name = graphene.String()
|
||||
teamUuid = graphene.String()
|
||||
userId = graphene.String()
|
||||
status = graphene.String()
|
||||
totalAmount = graphene.Float()
|
||||
currency = graphene.String()
|
||||
sourceLocationUuid = graphene.String()
|
||||
sourceLocationName = graphene.String()
|
||||
destinationLocationUuid = graphene.String()
|
||||
destinationLocationName = graphene.String()
|
||||
createdAt = graphene.String()
|
||||
updatedAt = graphene.String()
|
||||
notes = graphene.String()
|
||||
orderLines = graphene.List(lambda: OrderLineType)
|
||||
stages = graphene.List(lambda: StageType)
|
||||
|
||||
|
||||
class OrderLineType(graphene.ObjectType):
|
||||
uuid = graphene.String()
|
||||
productUuid = graphene.String()
|
||||
productName = graphene.String()
|
||||
quantity = graphene.Float()
|
||||
unit = graphene.String()
|
||||
priceUnit = graphene.Float()
|
||||
subtotal = graphene.Float()
|
||||
currency = graphene.String()
|
||||
notes = graphene.String()
|
||||
|
||||
|
||||
class CompanyType(graphene.ObjectType):
|
||||
uuid = graphene.String()
|
||||
name = graphene.String()
|
||||
taxId = graphene.String()
|
||||
country = graphene.String()
|
||||
countryCode = graphene.String()
|
||||
active = graphene.Boolean()
|
||||
|
||||
|
||||
class TripType(graphene.ObjectType):
|
||||
uuid = graphene.String()
|
||||
name = graphene.String()
|
||||
sequence = graphene.Int()
|
||||
company = graphene.Field(CompanyType)
|
||||
plannedLoadingDate = graphene.String()
|
||||
actualLoadingDate = graphene.String()
|
||||
realLoadingDate = graphene.String()
|
||||
plannedUnloadingDate = graphene.String()
|
||||
actualUnloadingDate = graphene.String()
|
||||
plannedWeight = graphene.Float()
|
||||
weightAtLoading = graphene.Float()
|
||||
weightAtUnloading = graphene.Float()
|
||||
|
||||
|
||||
class StageType(graphene.ObjectType):
|
||||
uuid = graphene.String()
|
||||
name = graphene.String()
|
||||
sequence = graphene.Int()
|
||||
stageType = graphene.String()
|
||||
transportType = graphene.String()
|
||||
sourceLocationName = graphene.String()
|
||||
sourceLatitude = graphene.Float()
|
||||
sourceLongitude = graphene.Float()
|
||||
destinationLocationName = graphene.String()
|
||||
destinationLatitude = graphene.Float()
|
||||
destinationLongitude = graphene.Float()
|
||||
locationName = graphene.String()
|
||||
locationLatitude = graphene.Float()
|
||||
locationLongitude = graphene.Float()
|
||||
selectedCompany = graphene.Field(CompanyType)
|
||||
trips = graphene.List(TripType)
|
||||
|
||||
|
||||
class TeamQuery(graphene.ObjectType):
|
||||
"""Team schema - Team Access Token authentication"""
|
||||
getTeamOrders = graphene.List(OrderType)
|
||||
getOrder = graphene.Field(OrderType, orderUuid=graphene.String(required=True))
|
||||
|
||||
@require_scopes("teams:member")
|
||||
def resolve_getTeamOrders(self, info):
|
||||
user_id = getattr(info.context, 'user_id', None)
|
||||
team_uuid = getattr(info.context, 'team_uuid', None)
|
||||
|
||||
if not user_id:
|
||||
raise Exception("User not authenticated")
|
||||
|
||||
if not team_uuid:
|
||||
return []
|
||||
|
||||
try:
|
||||
url = f"http://{settings.ODOO_INTERNAL_URL}/fastapi/orders/orders/team/{team_uuid}"
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
result = []
|
||||
for order_data in data:
|
||||
order = OrderType(
|
||||
uuid=order_data.get('uuid'),
|
||||
name=order_data.get('name'),
|
||||
teamUuid=order_data.get('team_uuid'),
|
||||
userId=order_data.get('user_id'),
|
||||
status='active',
|
||||
totalAmount=order_data.get('total_amount'),
|
||||
currency=order_data.get('currency'),
|
||||
sourceLocationUuid=order_data.get('source_location_uuid'),
|
||||
sourceLocationName=order_data.get('source_location_name'),
|
||||
destinationLocationUuid=order_data.get('destination_location_uuid'),
|
||||
destinationLocationName=order_data.get('destination_location_name'),
|
||||
createdAt=order_data.get('created_at'),
|
||||
updatedAt=order_data.get('updated_at'),
|
||||
notes=order_data.get('notes', ''),
|
||||
orderLines=[
|
||||
OrderLineType(
|
||||
uuid=line.get('uuid'),
|
||||
productUuid=line.get('product_uuid'),
|
||||
productName=line.get('product_name'),
|
||||
quantity=line.get('quantity'),
|
||||
unit=line.get('unit'),
|
||||
priceUnit=line.get('price_unit'),
|
||||
subtotal=line.get('subtotal'),
|
||||
currency=line.get('currency'),
|
||||
notes=line.get('notes', '')
|
||||
) for line in order_data.get('order_lines', [])
|
||||
],
|
||||
stages=[
|
||||
StageType(
|
||||
uuid=stage.get('uuid'),
|
||||
name=stage.get('name'),
|
||||
sequence=stage.get('sequence'),
|
||||
stageType=stage.get('stage_type'),
|
||||
transportType=stage.get('transport_type'),
|
||||
sourceLocationName=stage.get('source_location_name'),
|
||||
destinationLocationName=stage.get('destination_location_name'),
|
||||
locationName=stage.get('location_name'),
|
||||
selectedCompany=CompanyType(
|
||||
uuid=stage.get('selected_company', {}).get('uuid', ''),
|
||||
name=stage.get('selected_company', {}).get('name', ''),
|
||||
taxId=stage.get('selected_company', {}).get('tax_id', ''),
|
||||
country=stage.get('selected_company', {}).get('country', ''),
|
||||
countryCode=stage.get('selected_company', {}).get('country_code', ''),
|
||||
active=stage.get('selected_company', {}).get('active', True)
|
||||
) if stage.get('selected_company') else None,
|
||||
trips=[
|
||||
TripType(
|
||||
uuid=trip.get('uuid'),
|
||||
name=trip.get('name'),
|
||||
sequence=trip.get('sequence'),
|
||||
company=CompanyType(
|
||||
uuid=trip.get('company', {}).get('uuid', ''),
|
||||
name=trip.get('company', {}).get('name', ''),
|
||||
taxId=trip.get('company', {}).get('tax_id', ''),
|
||||
country=trip.get('company', {}).get('country', ''),
|
||||
countryCode=trip.get('company', {}).get('country_code', ''),
|
||||
active=trip.get('company', {}).get('active', True)
|
||||
) if trip.get('company') else None,
|
||||
plannedLoadingDate=trip.get('planned_loading_date'),
|
||||
actualLoadingDate=trip.get('actual_loading_date'),
|
||||
realLoadingDate=trip.get('real_loading_date'),
|
||||
plannedUnloadingDate=trip.get('planned_unloading_date'),
|
||||
actualUnloadingDate=trip.get('actual_unloading_date'),
|
||||
plannedWeight=trip.get('planned_weight'),
|
||||
weightAtLoading=trip.get('weight_at_loading'),
|
||||
weightAtUnloading=trip.get('weight_at_unloading')
|
||||
) for trip in stage.get('trips', [])
|
||||
]
|
||||
) for stage in order_data.get('stages', [])
|
||||
]
|
||||
)
|
||||
result.append(order)
|
||||
return result
|
||||
else:
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calling Odoo: {e}")
|
||||
return []
|
||||
|
||||
@require_scopes("teams:member")
|
||||
def resolve_getOrder(self, info, orderUuid):
|
||||
user_id = getattr(info.context, 'user_id', None)
|
||||
team_uuid = getattr(info.context, 'team_uuid', None)
|
||||
|
||||
if not user_id or not team_uuid:
|
||||
raise Exception("User not authenticated")
|
||||
|
||||
try:
|
||||
url = f"http://{settings.ODOO_INTERNAL_URL}/fastapi/orders/orders/{orderUuid}"
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
order_data = response.json()
|
||||
|
||||
if order_data.get('team_uuid') != team_uuid:
|
||||
raise Exception("Access denied: order belongs to different team")
|
||||
|
||||
order = OrderType(
|
||||
uuid=order_data.get('uuid'),
|
||||
name=order_data.get('name'),
|
||||
teamUuid=order_data.get('team_uuid'),
|
||||
userId=order_data.get('user_id'),
|
||||
status='active',
|
||||
totalAmount=order_data.get('total_amount'),
|
||||
currency=order_data.get('currency'),
|
||||
sourceLocationUuid=order_data.get('source_location_uuid'),
|
||||
sourceLocationName=order_data.get('source_location_name'),
|
||||
destinationLocationUuid=order_data.get('destination_location_uuid'),
|
||||
destinationLocationName=order_data.get('destination_location_name'),
|
||||
createdAt=order_data.get('created_at'),
|
||||
updatedAt=order_data.get('updated_at'),
|
||||
notes=order_data.get('notes', ''),
|
||||
orderLines=[
|
||||
OrderLineType(
|
||||
uuid=line.get('uuid'),
|
||||
productUuid=line.get('product_uuid'),
|
||||
productName=line.get('product_name'),
|
||||
quantity=line.get('quantity'),
|
||||
unit=line.get('unit'),
|
||||
priceUnit=line.get('price_unit'),
|
||||
subtotal=line.get('subtotal'),
|
||||
currency=line.get('currency'),
|
||||
notes=line.get('notes', '')
|
||||
) for line in order_data.get('order_lines', [])
|
||||
],
|
||||
stages=[
|
||||
StageType(
|
||||
uuid=stage.get('uuid'),
|
||||
name=stage.get('name'),
|
||||
sequence=stage.get('sequence'),
|
||||
stageType=stage.get('stage_type'),
|
||||
transportType=stage.get('transport_type'),
|
||||
sourceLocationName=stage.get('source_location_name'),
|
||||
sourceLatitude=stage.get('source_latitude'),
|
||||
sourceLongitude=stage.get('source_longitude'),
|
||||
destinationLocationName=stage.get('destination_location_name'),
|
||||
destinationLatitude=stage.get('destination_latitude'),
|
||||
destinationLongitude=stage.get('destination_longitude'),
|
||||
locationName=stage.get('location_name'),
|
||||
locationLatitude=stage.get('location_latitude'),
|
||||
locationLongitude=stage.get('location_longitude'),
|
||||
selectedCompany=CompanyType(
|
||||
uuid=stage.get('selected_company', {}).get('uuid', ''),
|
||||
name=stage.get('selected_company', {}).get('name', ''),
|
||||
taxId=stage.get('selected_company', {}).get('tax_id', ''),
|
||||
country=stage.get('selected_company', {}).get('country', ''),
|
||||
countryCode=stage.get('selected_company', {}).get('country_code', ''),
|
||||
active=stage.get('selected_company', {}).get('active', True)
|
||||
) if stage.get('selected_company') else None,
|
||||
trips=[
|
||||
TripType(
|
||||
uuid=trip.get('uuid'),
|
||||
name=trip.get('name'),
|
||||
sequence=trip.get('sequence'),
|
||||
company=CompanyType(
|
||||
uuid=trip.get('company', {}).get('uuid', ''),
|
||||
name=trip.get('company', {}).get('name', ''),
|
||||
taxId=trip.get('company', {}).get('tax_id', ''),
|
||||
country=trip.get('company', {}).get('country', ''),
|
||||
countryCode=trip.get('company', {}).get('country_code', ''),
|
||||
active=trip.get('company', {}).get('active', True)
|
||||
) if trip.get('company') else None,
|
||||
plannedLoadingDate=trip.get('planned_loading_date'),
|
||||
actualLoadingDate=trip.get('actual_loading_date'),
|
||||
realLoadingDate=trip.get('real_loading_date'),
|
||||
plannedUnloadingDate=trip.get('planned_unloading_date'),
|
||||
actualUnloadingDate=trip.get('actual_unloading_date'),
|
||||
plannedWeight=trip.get('planned_weight'),
|
||||
weightAtLoading=trip.get('weight_at_loading'),
|
||||
weightAtUnloading=trip.get('weight_at_unloading')
|
||||
) for trip in stage.get('trips', [])
|
||||
]
|
||||
) for stage in order_data.get('stages', [])
|
||||
]
|
||||
)
|
||||
return order
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Exception: {e}")
|
||||
return None
|
||||
|
||||
|
||||
team_schema = graphene.Schema(query=TeamQuery)
|
||||
12
orders_app/schemas/user_schema.py
Normal file
12
orders_app/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)
|
||||
169
orders_app/services.py
Normal file
169
orders_app/services.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from .models import Order, OrderLine, Stage, Trip
|
||||
|
||||
class OdooService:
|
||||
def __init__(self):
|
||||
self.base_url = f"http://{settings.ODOO_INTERNAL_URL}"
|
||||
|
||||
def get_odoo_orders(self, team_uuid):
|
||||
"""Получить заказы из Odoo API"""
|
||||
try:
|
||||
# Прямой вызов Odoo FastAPI
|
||||
url = f"{self.base_url}/fastapi/orders/api/v1/orders/team/{team_uuid}"
|
||||
print(f"Calling Odoo API: {url}")
|
||||
|
||||
response = requests.get(url, timeout=10)
|
||||
print(f"Odoo response: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"Odoo data: {len(data)} orders")
|
||||
return data
|
||||
else:
|
||||
print(f"Odoo error: {response.text}")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Error fetching from Odoo: {e}")
|
||||
return []
|
||||
|
||||
def get_odoo_order(self, order_uuid):
|
||||
"""Получить заказ из Odoo API"""
|
||||
try:
|
||||
url = f"{self.base_url}/fastapi/orders/api/v1/orders/{order_uuid}"
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error fetching order from Odoo: {e}")
|
||||
return None
|
||||
|
||||
def sync_team_orders(self, team_uuid):
|
||||
"""Синхронизировать заказы команды с Odoo"""
|
||||
odoo_orders = self.get_odoo_orders(team_uuid)
|
||||
django_orders = []
|
||||
|
||||
for odoo_order in odoo_orders:
|
||||
# Создаем или обновляем заказ в Django
|
||||
order, created = Order.objects.get_or_create(
|
||||
uuid=odoo_order['uuid'],
|
||||
defaults={
|
||||
'name': odoo_order['name'],
|
||||
'team_uuid': odoo_order['teamUuid'],
|
||||
'user_id': odoo_order['userId'],
|
||||
'source_location_uuid': odoo_order['sourceLocationUuid'],
|
||||
'source_location_name': odoo_order['sourceLocationName'],
|
||||
'destination_location_uuid': odoo_order['destinationLocationUuid'],
|
||||
'destination_location_name': odoo_order['destinationLocationName'],
|
||||
'status': odoo_order['status'],
|
||||
'total_amount': odoo_order['totalAmount'],
|
||||
'currency': odoo_order['currency'],
|
||||
'notes': odoo_order.get('notes', ''),
|
||||
}
|
||||
)
|
||||
|
||||
# Синхронизируем order lines
|
||||
self.sync_order_lines(order, odoo_order.get('orderLines', []))
|
||||
|
||||
# Синхронизируем stages
|
||||
self.sync_stages(order, odoo_order.get('stages', []))
|
||||
|
||||
django_orders.append(order)
|
||||
|
||||
return django_orders
|
||||
|
||||
def sync_order(self, order_uuid):
|
||||
"""Синхронизировать один заказ с Odoo"""
|
||||
odoo_order = self.get_odoo_order(order_uuid)
|
||||
if not odoo_order:
|
||||
return None
|
||||
|
||||
# Создаем или обновляем заказ
|
||||
order, created = Order.objects.get_or_create(
|
||||
uuid=odoo_order['uuid'],
|
||||
defaults={
|
||||
'name': odoo_order['name'],
|
||||
'team_uuid': odoo_order['teamUuid'],
|
||||
'user_id': odoo_order['userId'],
|
||||
'source_location_uuid': odoo_order['sourceLocationUuid'],
|
||||
'source_location_name': odoo_order['sourceLocationName'],
|
||||
'destination_location_uuid': odoo_order['destinationLocationUuid'],
|
||||
'destination_location_name': odoo_order['destinationLocationName'],
|
||||
'status': odoo_order['status'],
|
||||
'total_amount': odoo_order['totalAmount'],
|
||||
'currency': odoo_order['currency'],
|
||||
'notes': odoo_order.get('notes', ''),
|
||||
}
|
||||
)
|
||||
|
||||
# Синхронизируем связанные данные
|
||||
self.sync_order_lines(order, odoo_order.get('orderLines', []))
|
||||
self.sync_stages(order, odoo_order.get('stages', []))
|
||||
|
||||
return order
|
||||
|
||||
def sync_order_lines(self, order, odoo_lines):
|
||||
"""Синхронизировать строки заказа"""
|
||||
# Удаляем старые
|
||||
order.order_lines.all().delete()
|
||||
|
||||
# Создаем новые
|
||||
for line_data in odoo_lines:
|
||||
OrderLine.objects.create(
|
||||
uuid=line_data['uuid'],
|
||||
order=order,
|
||||
product_uuid=line_data['productUuid'],
|
||||
product_name=line_data['productName'],
|
||||
quantity=line_data['quantity'],
|
||||
unit=line_data['unit'],
|
||||
price_unit=line_data['priceUnit'],
|
||||
subtotal=line_data['subtotal'],
|
||||
currency=line_data.get('currency', 'RUB'),
|
||||
notes=line_data.get('notes', ''),
|
||||
)
|
||||
|
||||
def sync_stages(self, order, odoo_stages):
|
||||
"""Синхронизировать этапы заказа"""
|
||||
# Удаляем старые
|
||||
order.stages.all().delete()
|
||||
|
||||
# Создаем новые
|
||||
for stage_data in odoo_stages:
|
||||
stage = Stage.objects.create(
|
||||
uuid=stage_data['uuid'],
|
||||
order=order,
|
||||
name=stage_data['name'],
|
||||
sequence=stage_data['sequence'],
|
||||
stage_type=stage_data['stageType'],
|
||||
transport_type=stage_data.get('transportType', ''),
|
||||
source_location_name=stage_data.get('sourceLocationName', ''),
|
||||
destination_location_name=stage_data.get('destinationLocationName', ''),
|
||||
location_name=stage_data.get('locationName', ''),
|
||||
selected_company_uuid=stage_data.get('selectedCompany', {}).get('uuid', '') if stage_data.get('selectedCompany') else '',
|
||||
selected_company_name=stage_data.get('selectedCompany', {}).get('name', '') if stage_data.get('selectedCompany') else '',
|
||||
)
|
||||
|
||||
# Синхронизируем trips
|
||||
self.sync_trips(stage, stage_data.get('trips', []))
|
||||
|
||||
def sync_trips(self, stage, odoo_trips):
|
||||
"""Синхронизировать рейсы этапа"""
|
||||
for trip_data in odoo_trips:
|
||||
Trip.objects.create(
|
||||
uuid=trip_data['uuid'],
|
||||
stage=stage,
|
||||
name=trip_data['name'],
|
||||
sequence=trip_data['sequence'],
|
||||
company_uuid=trip_data.get('company', {}).get('uuid', '') if trip_data.get('company') else '',
|
||||
company_name=trip_data.get('company', {}).get('name', '') if trip_data.get('company') else '',
|
||||
planned_weight=trip_data.get('plannedWeight'),
|
||||
weight_at_loading=trip_data.get('weightAtLoading'),
|
||||
weight_at_unloading=trip_data.get('weightAtUnloading'),
|
||||
planned_loading_date=trip_data.get('plannedLoadingDate'),
|
||||
actual_loading_date=trip_data.get('actualLoadingDate'),
|
||||
real_loading_date=trip_data.get('realLoadingDate'),
|
||||
planned_unloading_date=trip_data.get('plannedUnloadingDate'),
|
||||
actual_unloading_date=trip_data.get('actualUnloadingDate'),
|
||||
notes=trip_data.get('notes', ''),
|
||||
)
|
||||
119
orders_app/tests.py
Normal file
119
orders_app/tests.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from django.test import TestCase
|
||||
from graphene.test import Client
|
||||
from orders_app.schema import schema
|
||||
|
||||
class OrdersGraphQLTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client(schema)
|
||||
|
||||
def test_get_team_orders(self):
|
||||
"""Тест getTeamOrders"""
|
||||
query = '''
|
||||
{
|
||||
getTeamOrders(teamUuid: "4993148a-cd2b-4987-b3f2-0f92b7479885") {
|
||||
uuid
|
||||
name
|
||||
totalAmount
|
||||
sourceLocationName
|
||||
destinationLocationName
|
||||
orderLines {
|
||||
productName
|
||||
quantity
|
||||
unit
|
||||
}
|
||||
stages {
|
||||
name
|
||||
stageType
|
||||
transportType
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
result = self.client.execute(query)
|
||||
print(f"\n=== TEAM ORDERS TEST ===")
|
||||
print(f"Result: {result}")
|
||||
|
||||
if result.get('errors'):
|
||||
print(f"ERRORS: {result['errors']}")
|
||||
|
||||
if result.get('data'):
|
||||
orders = result['data']['getTeamOrders']
|
||||
print(f"Found {len(orders)} orders")
|
||||
for order in orders:
|
||||
print(f"Order: {order.get('name')} - {order.get('totalAmount')}")
|
||||
if order.get('stages'):
|
||||
print(f" Stages: {len(order['stages'])}")
|
||||
|
||||
def test_get_order_single(self):
|
||||
"""Тест getOrder для конкретного заказа"""
|
||||
query = '''
|
||||
{
|
||||
getOrder(orderUuid: "order-coffee-kenya-2024") {
|
||||
uuid
|
||||
name
|
||||
totalAmount
|
||||
sourceLocationName
|
||||
destinationLocationName
|
||||
orderLines {
|
||||
productName
|
||||
quantity
|
||||
unit
|
||||
}
|
||||
stages {
|
||||
name
|
||||
stageType
|
||||
transportType
|
||||
trips {
|
||||
name
|
||||
company {
|
||||
name
|
||||
}
|
||||
plannedWeight
|
||||
weightAtLoading
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
result = self.client.execute(query)
|
||||
print(f"\n=== SINGLE ORDER TEST ===")
|
||||
print(f"Result: {result}")
|
||||
|
||||
if result.get('errors'):
|
||||
print(f"ERRORS: {result['errors']}")
|
||||
|
||||
if result.get('data') and result['data']['getOrder']:
|
||||
order = result['data']['getOrder']
|
||||
print(f"Order: {order.get('name')}")
|
||||
print(f"Amount: {order.get('totalAmount')}")
|
||||
print(f"Route: {order.get('sourceLocationName')} → {order.get('destinationLocationName')}")
|
||||
|
||||
if order.get('orderLines'):
|
||||
print(f"Order lines: {len(order['orderLines'])}")
|
||||
for line in order['orderLines']:
|
||||
print(f" - {line.get('productName')}: {line.get('quantity')} {line.get('unit')}")
|
||||
|
||||
if order.get('stages'):
|
||||
print(f"Stages: {len(order['stages'])}")
|
||||
for stage in order['stages']:
|
||||
print(f" - {stage.get('name')} ({stage.get('stageType')})")
|
||||
if stage.get('trips'):
|
||||
print(f" Trips: {len(stage['trips'])}")
|
||||
|
||||
def test_schema_types(self):
|
||||
"""Тест доступных типов в схеме"""
|
||||
query = '{ __schema { types { name } } }'
|
||||
result = self.client.execute(query)
|
||||
|
||||
print(f"\n=== SCHEMA TYPES ===")
|
||||
if result.get('data'):
|
||||
types = [t['name'] for t in result['data']['__schema']['types']]
|
||||
print(f"Available types: {types}")
|
||||
|
||||
# Проверяем что есть нужные типы
|
||||
required_types = ['OrderType', 'StageType', 'TripType', 'CompanyType']
|
||||
for req_type in required_types:
|
||||
if req_type in types:
|
||||
print(f"✅ {req_type} - OK")
|
||||
else:
|
||||
print(f"❌ {req_type} - MISSING")
|
||||
68
orders_app/views.py
Normal file
68
orders_app/views.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Views for Orders API.
|
||||
|
||||
Authentication is handled by GRAPHENE MIDDLEWARE in settings.py
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from jwt import InvalidTokenError
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
from .graphql_middleware import PublicNoAuthMiddleware, TeamJWTMiddleware, UserJWTMiddleware
|
||||
|
||||
from .auth import get_bearer_token, scopes_from_payload, validator
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def test_jwt_orders(request):
|
||||
"""Test endpoint for Orders API JWT validation"""
|
||||
|
||||
try:
|
||||
token = get_bearer_token(request)
|
||||
except InvalidTokenError as exc:
|
||||
return JsonResponse({"status": "error", "error": str(exc)}, status=403)
|
||||
|
||||
response = {"token_length": len(token), "token_preview": f"{token[:32]}...{token[-32:]}"}
|
||||
|
||||
try:
|
||||
payload = validator.decode(token, audience=getattr(settings, "LOGTO_ORDERS_AUDIENCE", None))
|
||||
response.update(
|
||||
{
|
||||
"status": "ok",
|
||||
"payload": payload,
|
||||
"user_id": payload.get("sub"),
|
||||
"team_uuid": payload.get("team_uuid"),
|
||||
"audience": payload.get("aud"),
|
||||
"scopes": scopes_from_payload(payload),
|
||||
}
|
||||
)
|
||||
return JsonResponse(response, json_dumps_params={'indent': 2})
|
||||
except InvalidTokenError as exc:
|
||||
response["status"] = "invalid"
|
||||
response["error"] = str(exc)
|
||||
return JsonResponse(response, status=403, json_dumps_params={'indent': 2})
|
||||
|
||||
|
||||
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 Team Access Token."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['middleware'] = [TeamJWTMiddleware()]
|
||||
super().__init__(*args, **kwargs)
|
||||
Reference in New Issue
Block a user