Initial commit from monorepo
This commit is contained in:
1
kyc_app/__init__.py
Normal file
1
kyc_app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Orders Django app
|
||||
56
kyc_app/admin.py
Normal file
56
kyc_app/admin.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from django.contrib import admin, messages
|
||||
from .models import KYCRequest, KYCRequestRussia
|
||||
from .temporal import KycWorkflowClient
|
||||
|
||||
|
||||
@admin.action(description="Start KYC workflow")
|
||||
def start_kyc_workflow(modeladmin, request, queryset):
|
||||
"""Start KYC workflow for selected requests."""
|
||||
for kyc_request in queryset:
|
||||
workflow_id = KycWorkflowClient.start(kyc_request)
|
||||
if workflow_id:
|
||||
messages.success(request, f"Workflow started for {kyc_request.uuid}")
|
||||
else:
|
||||
messages.error(request, f"Failed to start workflow for {kyc_request.uuid}")
|
||||
|
||||
|
||||
@admin.register(KYCRequest)
|
||||
class KYCRequestAdmin(admin.ModelAdmin):
|
||||
list_display = ('uuid', 'user_id', 'team_name', 'country_code', 'workflow_status', 'contact_person', 'created_at')
|
||||
list_filter = ('workflow_status', 'country_code', 'created_at')
|
||||
search_fields = ('uuid', 'user_id', 'team_name', 'contact_email', 'contact_person')
|
||||
readonly_fields = ('uuid', 'created_at', 'updated_at', 'content_type', 'object_id')
|
||||
ordering = ('-created_at',)
|
||||
actions = [start_kyc_workflow]
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': ('uuid', 'user_id', 'team_name', 'country_code', 'workflow_status')
|
||||
}),
|
||||
('Контактная информация', {
|
||||
'fields': ('contact_person', 'contact_email', 'contact_phone')
|
||||
}),
|
||||
('Детали страны (GenericFK)', {
|
||||
'fields': ('content_type', 'object_id'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Статус', {
|
||||
'fields': ('score', 'approved_by', 'approved_at', 'created_at', 'updated_at')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(KYCRequestRussia)
|
||||
class KYCRequestRussiaAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'company_name', 'inn', 'ogrn')
|
||||
search_fields = ('company_name', 'inn', 'ogrn')
|
||||
ordering = ('-id',)
|
||||
|
||||
fieldsets = (
|
||||
('Компания', {
|
||||
'fields': ('company_name', 'company_full_name', 'inn', 'kpp', 'ogrn', 'address')
|
||||
}),
|
||||
('Банковские реквизиты', {
|
||||
'fields': ('bank_name', 'bik', 'correspondent_account')
|
||||
}),
|
||||
)
|
||||
5
kyc_app/apps.py
Normal file
5
kyc_app/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class KycAppConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'kyc_app'
|
||||
73
kyc_app/auth.py
Normal file
73
kyc_app/auth.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
JWT authentication utilities for KYC API.
|
||||
"""
|
||||
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"),
|
||||
)
|
||||
32
kyc_app/graphql_middleware.py
Normal file
32
kyc_app/graphql_middleware.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
GraphQL middleware for JWT authentication.
|
||||
"""
|
||||
from graphql import GraphQLError
|
||||
from jwt import InvalidTokenError
|
||||
|
||||
from .auth import get_bearer_token, 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 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)
|
||||
58
kyc_app/migrations/0001_initial.py
Normal file
58
kyc_app/migrations/0001_initial.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# Generated manually
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='KYCRequest',
|
||||
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)),
|
||||
('user_id', models.CharField(max_length=255)),
|
||||
('team_uuid', models.CharField(max_length=100)),
|
||||
('team_name', models.CharField(blank=True, max_length=200)),
|
||||
('country_code', models.CharField(blank=True, max_length=2)),
|
||||
('status', models.CharField(choices=[('pending', 'Ожидает проверки'), ('in_review', 'На рассмотрении'), ('approved', 'Одобрено'), ('rejected', 'Отклонено'), ('expired', 'Истекло')], default='pending', max_length=50)),
|
||||
('score', models.IntegerField(default=0)),
|
||||
('contact_person', models.CharField(blank=True, default='', max_length=255)),
|
||||
('contact_email', models.EmailField(blank=True, default='', max_length=254)),
|
||||
('contact_phone', models.CharField(blank=True, default='', max_length=50)),
|
||||
('registration_number', models.CharField(blank=True, max_length=50)),
|
||||
('approved_by', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('approved_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'kyc_requests',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='KYCRequestRussia',
|
||||
fields=[
|
||||
('kycrequest_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='kyc_app.kycrequest')),
|
||||
('company_name', models.CharField(max_length=255)),
|
||||
('company_full_name', models.TextField()),
|
||||
('inn', models.CharField(max_length=12)),
|
||||
('kpp', models.CharField(blank=True, max_length=9)),
|
||||
('ogrn', models.CharField(blank=True, max_length=15)),
|
||||
('address', models.TextField()),
|
||||
('bank_name', models.CharField(max_length=255)),
|
||||
('bik', models.CharField(max_length=9)),
|
||||
('correspondent_account', models.CharField(blank=True, max_length=20)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'kyc_requests_russia',
|
||||
},
|
||||
bases=('kyc_app.kycrequest',),
|
||||
),
|
||||
]
|
||||
65
kyc_app/migrations/0002_remove_inheritance.py
Normal file
65
kyc_app/migrations/0002_remove_inheritance.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Migration: Remove inheritance, add ContentType GenericFK
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('kyc_app', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. Drop the old inherited table
|
||||
migrations.DeleteModel(
|
||||
name='KYCRequestRussia',
|
||||
),
|
||||
|
||||
# 2. Remove registration_number from KYCRequest (was for inherited ogrn)
|
||||
migrations.RemoveField(
|
||||
model_name='kycrequest',
|
||||
name='registration_number',
|
||||
),
|
||||
|
||||
# 3. Add ContentType FK to KYCRequest
|
||||
migrations.AddField(
|
||||
model_name='kycrequest',
|
||||
name='content_type',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='kyc_requests',
|
||||
to='contenttypes.contenttype',
|
||||
),
|
||||
),
|
||||
|
||||
# 4. Add object_id to KYCRequest
|
||||
migrations.AddField(
|
||||
model_name='kycrequest',
|
||||
name='object_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
|
||||
# 5. Create new KYCRequestRussia (without inheritance)
|
||||
migrations.CreateModel(
|
||||
name='KYCRequestRussia',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('company_name', models.CharField(max_length=255)),
|
||||
('company_full_name', models.TextField()),
|
||||
('inn', models.CharField(max_length=12)),
|
||||
('kpp', models.CharField(blank=True, max_length=9)),
|
||||
('ogrn', models.CharField(blank=True, max_length=15)),
|
||||
('address', models.TextField()),
|
||||
('bank_name', models.CharField(max_length=255)),
|
||||
('bik', models.CharField(max_length=9)),
|
||||
('correspondent_account', models.CharField(blank=True, max_length=20)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'kyc_details_russia',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-30 02:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('kyc_app', '0002_remove_inheritance'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='kycrequest',
|
||||
name='status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='kycrequest',
|
||||
name='team_uuid',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='kycrequest',
|
||||
name='workflow_status',
|
||||
field=models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='kycrequest',
|
||||
name='user_id',
|
||||
field=models.CharField(db_index=True, max_length=255),
|
||||
),
|
||||
]
|
||||
0
kyc_app/migrations/__init__.py
Normal file
0
kyc_app/migrations/__init__.py
Normal file
94
kyc_app/models.py
Normal file
94
kyc_app/models.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
import uuid
|
||||
|
||||
|
||||
class KYCRequest(models.Model):
|
||||
"""Главная модель KYC заявки. Ссылается на детали страны через ContentType."""
|
||||
WORKFLOW_STATUS_CHOICES = [
|
||||
('pending', 'Ожидает обработки'),
|
||||
('active', 'Активен'),
|
||||
('error', 'Ошибка'),
|
||||
]
|
||||
|
||||
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
|
||||
user_id = models.CharField(max_length=255, db_index=True)
|
||||
team_name = models.CharField(max_length=200, blank=True)
|
||||
country_code = models.CharField(max_length=2, blank=True)
|
||||
workflow_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=WORKFLOW_STATUS_CHOICES,
|
||||
default='pending',
|
||||
)
|
||||
score = models.IntegerField(default=0)
|
||||
|
||||
# Общие контактные данные
|
||||
contact_person = models.CharField(max_length=255, blank=True, default='')
|
||||
contact_email = models.EmailField(blank=True, default='')
|
||||
contact_phone = models.CharField(max_length=50, blank=True, default='')
|
||||
|
||||
# Ссылка на детали страны через ContentType
|
||||
content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='kyc_requests'
|
||||
)
|
||||
object_id = models.PositiveIntegerField(null=True, blank=True)
|
||||
country_details = GenericForeignKey('content_type', 'object_id')
|
||||
|
||||
approved_by = models.CharField(max_length=255, null=True, blank=True)
|
||||
approved_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'kyc_requests'
|
||||
|
||||
def __str__(self):
|
||||
return f"KYC {self.user_id} - {self.workflow_status}"
|
||||
|
||||
def get_country_data(self) -> dict:
|
||||
"""Получить данные страны как словарь для Temporal workflow."""
|
||||
if self.country_details and hasattr(self.country_details, 'to_dict'):
|
||||
return self.country_details.to_dict()
|
||||
return {}
|
||||
|
||||
|
||||
class KYCRequestRussia(models.Model):
|
||||
"""Детали KYC для России. Отдельная модель без наследования."""
|
||||
|
||||
# Данные компании от DaData
|
||||
company_name = models.CharField(max_length=255)
|
||||
company_full_name = models.TextField()
|
||||
inn = models.CharField(max_length=12)
|
||||
kpp = models.CharField(max_length=9, blank=True)
|
||||
ogrn = models.CharField(max_length=15, blank=True)
|
||||
address = models.TextField()
|
||||
|
||||
# Банковские реквизиты
|
||||
bank_name = models.CharField(max_length=255)
|
||||
bik = models.CharField(max_length=9)
|
||||
correspondent_account = models.CharField(max_length=20, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'kyc_details_russia'
|
||||
|
||||
def __str__(self):
|
||||
return f"KYC Russia: {self.company_name} (ИНН: {self.inn})"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Конвертировать в словарь для передачи в Temporal workflow."""
|
||||
return {
|
||||
"company_name": self.company_name,
|
||||
"company_full_name": self.company_full_name,
|
||||
"inn": self.inn,
|
||||
"kpp": self.kpp,
|
||||
"ogrn": self.ogrn,
|
||||
"address": self.address,
|
||||
"bank_name": self.bank_name,
|
||||
"bik": self.bik,
|
||||
"correspondent_account": self.correspondent_account,
|
||||
}
|
||||
0
kyc_app/schemas/__init__.py
Normal file
0
kyc_app/schemas/__init__.py
Normal file
112
kyc_app/schemas/user_schema.py
Normal file
112
kyc_app/schemas/user_schema.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import graphene
|
||||
from graphene_django import DjangoObjectType
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from ..models import KYCRequest, KYCRequestRussia
|
||||
from ..temporal import KycWorkflowClient
|
||||
|
||||
|
||||
class KYCRequestType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = KYCRequest
|
||||
fields = '__all__'
|
||||
|
||||
country_data = graphene.JSONString()
|
||||
workflow_status = graphene.String()
|
||||
|
||||
def resolve_country_data(self, info):
|
||||
return self.get_country_data()
|
||||
|
||||
|
||||
class KYCRequestRussiaType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = KYCRequestRussia
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class KYCRequestRussiaInput(graphene.InputObjectType):
|
||||
companyName = graphene.String(required=True)
|
||||
companyFullName = graphene.String(required=True)
|
||||
inn = graphene.String(required=True)
|
||||
kpp = graphene.String()
|
||||
ogrn = graphene.String()
|
||||
address = graphene.String(required=True)
|
||||
bankName = graphene.String(required=True)
|
||||
bik = graphene.String(required=True)
|
||||
correspondentAccount = graphene.String()
|
||||
contactPerson = graphene.String(required=True)
|
||||
contactEmail = graphene.String(required=True)
|
||||
contactPhone = graphene.String(required=True)
|
||||
|
||||
|
||||
class CreateKYCRequestRussia(graphene.Mutation):
|
||||
class Arguments:
|
||||
input = KYCRequestRussiaInput(required=True)
|
||||
|
||||
kyc_request = graphene.Field(KYCRequestType)
|
||||
success = graphene.Boolean()
|
||||
|
||||
def mutate(self, info, input):
|
||||
# Get user_id from JWT token
|
||||
user_id = getattr(info.context, 'user_id', None)
|
||||
if not user_id:
|
||||
raise Exception("Not authenticated")
|
||||
|
||||
# 1. Create Russia details
|
||||
russia_details = KYCRequestRussia.objects.create(
|
||||
company_name=input.companyName,
|
||||
company_full_name=input.companyFullName,
|
||||
inn=input.inn,
|
||||
kpp=input.kpp or '',
|
||||
ogrn=input.ogrn or '',
|
||||
address=input.address,
|
||||
bank_name=input.bankName,
|
||||
bik=input.bik,
|
||||
correspondent_account=input.correspondentAccount or '',
|
||||
)
|
||||
|
||||
# 2. Create main KYCRequest with reference to details
|
||||
kyc_request = KYCRequest.objects.create(
|
||||
user_id=user_id,
|
||||
team_name=input.companyName,
|
||||
country_code='RU',
|
||||
contact_person=input.contactPerson,
|
||||
contact_email=input.contactEmail,
|
||||
contact_phone=input.contactPhone,
|
||||
content_type=ContentType.objects.get_for_model(KYCRequestRussia),
|
||||
object_id=russia_details.id,
|
||||
)
|
||||
|
||||
# 3. Start Temporal workflow
|
||||
KycWorkflowClient.start(kyc_request)
|
||||
|
||||
return CreateKYCRequestRussia(kyc_request=kyc_request, success=True)
|
||||
|
||||
|
||||
class UserQuery(graphene.ObjectType):
|
||||
"""User schema - ID token authentication"""
|
||||
kyc_requests = graphene.List(KYCRequestType)
|
||||
kyc_request = graphene.Field(KYCRequestType, uuid=graphene.String(required=True))
|
||||
|
||||
def resolve_kyc_requests(self, info):
|
||||
# Filter by user_id from JWT token
|
||||
user_id = getattr(info.context, 'user_id', None)
|
||||
if not user_id:
|
||||
return []
|
||||
return KYCRequest.objects.filter(user_id=user_id)
|
||||
|
||||
def resolve_kyc_request(self, info, uuid):
|
||||
user_id = getattr(info.context, 'user_id', None)
|
||||
if not user_id:
|
||||
return None
|
||||
try:
|
||||
return KYCRequest.objects.get(uuid=uuid, user_id=user_id)
|
||||
except KYCRequest.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class UserMutation(graphene.ObjectType):
|
||||
"""User mutations - ID token authentication"""
|
||||
create_kyc_request_russia = CreateKYCRequestRussia.Field()
|
||||
|
||||
|
||||
user_schema = graphene.Schema(query=UserQuery, mutation=UserMutation)
|
||||
160
kyc_app/services.py
Normal file
160
kyc_app/services.py
Normal file
@@ -0,0 +1,160 @@
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from .models import Order, OrderLine, Stage, Trip
|
||||
|
||||
class OdooService:
|
||||
def __init__(self):
|
||||
self.base_url = f"http://{settings.ODOO_INTERNAL_URL}"
|
||||
|
||||
def get_odoo_orders(self, team_uuid):
|
||||
"""Получить заказы из Odoo API"""
|
||||
try:
|
||||
url = f"{self.base_url}/fastapi/orders/api/v1/orders/team/{team_uuid}"
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Error fetching from Odoo: {e}")
|
||||
return []
|
||||
|
||||
def get_odoo_order(self, order_uuid):
|
||||
"""Получить заказ из Odoo API"""
|
||||
try:
|
||||
url = f"{self.base_url}/fastapi/orders/api/v1/orders/{order_uuid}"
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error fetching order from Odoo: {e}")
|
||||
return None
|
||||
|
||||
def sync_team_orders(self, team_uuid):
|
||||
"""Синхронизировать заказы команды с Odoo"""
|
||||
odoo_orders = self.get_odoo_orders(team_uuid)
|
||||
django_orders = []
|
||||
|
||||
for odoo_order in odoo_orders:
|
||||
# Создаем или обновляем заказ в Django
|
||||
order, created = Order.objects.get_or_create(
|
||||
uuid=odoo_order['uuid'],
|
||||
defaults={
|
||||
'name': odoo_order['name'],
|
||||
'team_uuid': odoo_order['teamUuid'],
|
||||
'user_id': odoo_order['userId'],
|
||||
'source_location_uuid': odoo_order['sourceLocationUuid'],
|
||||
'source_location_name': odoo_order['sourceLocationName'],
|
||||
'destination_location_uuid': odoo_order['destinationLocationUuid'],
|
||||
'destination_location_name': odoo_order['destinationLocationName'],
|
||||
'status': odoo_order['status'],
|
||||
'total_amount': odoo_order['totalAmount'],
|
||||
'currency': odoo_order['currency'],
|
||||
'notes': odoo_order.get('notes', ''),
|
||||
}
|
||||
)
|
||||
|
||||
# Синхронизируем order lines
|
||||
self.sync_order_lines(order, odoo_order.get('orderLines', []))
|
||||
|
||||
# Синхронизируем stages
|
||||
self.sync_stages(order, odoo_order.get('stages', []))
|
||||
|
||||
django_orders.append(order)
|
||||
|
||||
return django_orders
|
||||
|
||||
def sync_order(self, order_uuid):
|
||||
"""Синхронизировать один заказ с Odoo"""
|
||||
odoo_order = self.get_odoo_order(order_uuid)
|
||||
if not odoo_order:
|
||||
return None
|
||||
|
||||
# Создаем или обновляем заказ
|
||||
order, created = Order.objects.get_or_create(
|
||||
uuid=odoo_order['uuid'],
|
||||
defaults={
|
||||
'name': odoo_order['name'],
|
||||
'team_uuid': odoo_order['teamUuid'],
|
||||
'user_id': odoo_order['userId'],
|
||||
'source_location_uuid': odoo_order['sourceLocationUuid'],
|
||||
'source_location_name': odoo_order['sourceLocationName'],
|
||||
'destination_location_uuid': odoo_order['destinationLocationUuid'],
|
||||
'destination_location_name': odoo_order['destinationLocationName'],
|
||||
'status': odoo_order['status'],
|
||||
'total_amount': odoo_order['totalAmount'],
|
||||
'currency': odoo_order['currency'],
|
||||
'notes': odoo_order.get('notes', ''),
|
||||
}
|
||||
)
|
||||
|
||||
# Синхронизируем связанные данные
|
||||
self.sync_order_lines(order, odoo_order.get('orderLines', []))
|
||||
self.sync_stages(order, odoo_order.get('stages', []))
|
||||
|
||||
return order
|
||||
|
||||
def sync_order_lines(self, order, odoo_lines):
|
||||
"""Синхронизировать строки заказа"""
|
||||
# Удаляем старые
|
||||
order.order_lines.all().delete()
|
||||
|
||||
# Создаем новые
|
||||
for line_data in odoo_lines:
|
||||
OrderLine.objects.create(
|
||||
uuid=line_data['uuid'],
|
||||
order=order,
|
||||
product_uuid=line_data['productUuid'],
|
||||
product_name=line_data['productName'],
|
||||
quantity=line_data['quantity'],
|
||||
unit=line_data['unit'],
|
||||
price_unit=line_data['priceUnit'],
|
||||
subtotal=line_data['subtotal'],
|
||||
currency=line_data.get('currency', 'RUB'),
|
||||
notes=line_data.get('notes', ''),
|
||||
)
|
||||
|
||||
def sync_stages(self, order, odoo_stages):
|
||||
"""Синхронизировать этапы заказа"""
|
||||
# Удаляем старые
|
||||
order.stages.all().delete()
|
||||
|
||||
# Создаем новые
|
||||
for stage_data in odoo_stages:
|
||||
stage = Stage.objects.create(
|
||||
uuid=stage_data['uuid'],
|
||||
order=order,
|
||||
name=stage_data['name'],
|
||||
sequence=stage_data['sequence'],
|
||||
stage_type=stage_data['stageType'],
|
||||
transport_type=stage_data.get('transportType', ''),
|
||||
source_location_name=stage_data.get('sourceLocationName', ''),
|
||||
destination_location_name=stage_data.get('destinationLocationName', ''),
|
||||
location_name=stage_data.get('locationName', ''),
|
||||
selected_company_uuid=stage_data.get('selectedCompany', {}).get('uuid', '') if stage_data.get('selectedCompany') else '',
|
||||
selected_company_name=stage_data.get('selectedCompany', {}).get('name', '') if stage_data.get('selectedCompany') else '',
|
||||
)
|
||||
|
||||
# Синхронизируем trips
|
||||
self.sync_trips(stage, stage_data.get('trips', []))
|
||||
|
||||
def sync_trips(self, stage, odoo_trips):
|
||||
"""Синхронизировать рейсы этапа"""
|
||||
for trip_data in odoo_trips:
|
||||
Trip.objects.create(
|
||||
uuid=trip_data['uuid'],
|
||||
stage=stage,
|
||||
name=trip_data['name'],
|
||||
sequence=trip_data['sequence'],
|
||||
company_uuid=trip_data.get('company', {}).get('uuid', '') if trip_data.get('company') else '',
|
||||
company_name=trip_data.get('company', {}).get('name', '') if trip_data.get('company') else '',
|
||||
planned_weight=trip_data.get('plannedWeight'),
|
||||
weight_at_loading=trip_data.get('weightAtLoading'),
|
||||
weight_at_unloading=trip_data.get('weightAtUnloading'),
|
||||
planned_loading_date=trip_data.get('plannedLoadingDate'),
|
||||
actual_loading_date=trip_data.get('actualLoadingDate'),
|
||||
real_loading_date=trip_data.get('realLoadingDate'),
|
||||
planned_unloading_date=trip_data.get('plannedUnloadingDate'),
|
||||
actual_unloading_date=trip_data.get('actualUnloadingDate'),
|
||||
notes=trip_data.get('notes', ''),
|
||||
)
|
||||
61
kyc_app/status_events.py
Normal file
61
kyc_app/status_events.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _surreal_headers() -> dict[str, str]:
|
||||
headers = {
|
||||
"Content-Type": "text/plain",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
ns = os.getenv("SURREALDB_NS", "optovia")
|
||||
db = os.getenv("SURREALDB_DB", "events")
|
||||
user = os.getenv("SURREALDB_USER")
|
||||
password = os.getenv("SURREALDB_PASS")
|
||||
|
||||
headers["NS"] = ns
|
||||
headers["DB"] = db
|
||||
|
||||
if user and password:
|
||||
token = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("utf-8")
|
||||
headers["Authorization"] = f"Basic {token}"
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
def log_kyc_event(kyc_id: str, user_id: str, event: str, description: str) -> bool:
|
||||
url = os.getenv("SURREALDB_URL")
|
||||
if not url:
|
||||
logger.warning("SURREALDB_URL is not set; skipping KYC event log")
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"kyc_id": kyc_id,
|
||||
"user_id": user_id,
|
||||
"event": event,
|
||||
"description": description,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
query = f"CREATE kyc_event CONTENT {json.dumps(payload)};"
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{url.rstrip('/')}/sql",
|
||||
data=query.encode("utf-8"),
|
||||
method="POST",
|
||||
headers=_surreal_headers(),
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
response.read()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to log KYC event: %s", exc)
|
||||
return False
|
||||
3
kyc_app/temporal/__init__.py
Normal file
3
kyc_app/temporal/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .kyc_workflow_client import KycWorkflowClient
|
||||
|
||||
__all__ = ["KycWorkflowClient"]
|
||||
117
kyc_app/temporal/kyc_workflow_client.py
Normal file
117
kyc_app/temporal/kyc_workflow_client.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
KYC Workflow Client - контракт взаимодействия с Temporal KYC workflow.
|
||||
|
||||
Этот файл содержит методы для запуска KYC workflow из Django.
|
||||
Approve/reject сигналы отправляются из Odoo.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from temporalio.client import Client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class KycWorkflowData:
|
||||
"""Данные для запуска KYC workflow."""
|
||||
|
||||
kyc_request_id: str
|
||||
team_name: str
|
||||
owner_id: str
|
||||
owner_email: str
|
||||
country_code: str
|
||||
country_data: dict = field(default_factory=dict)
|
||||
# team_id создаётся в workflow после approve, не передаётся сюда
|
||||
|
||||
|
||||
class KycWorkflowClient:
|
||||
"""
|
||||
Клиент для запуска KYC Application workflow.
|
||||
|
||||
Использование:
|
||||
KycWorkflowClient.start(kyc_request)
|
||||
|
||||
Flow:
|
||||
1. Django вызывает start() → запускает workflow
|
||||
2. Workflow добавляет KYC в Odoo, ставит статус KYC_IN_REVIEW
|
||||
3. Workflow ждёт сигнала approve/reject (из Odoo)
|
||||
4. После approve → создаёт Logto org, ставит ACTIVE
|
||||
"""
|
||||
|
||||
WORKFLOW_NAME = "kyc_application"
|
||||
|
||||
@classmethod
|
||||
async def _get_client(cls) -> Client:
|
||||
"""Получить подключение к Temporal."""
|
||||
return await Client.connect(
|
||||
settings.TEMPORAL_HOST,
|
||||
namespace=settings.TEMPORAL_NAMESPACE,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def start_async(cls, data: KycWorkflowData) -> str:
|
||||
"""
|
||||
Запустить KYC Application workflow (async).
|
||||
|
||||
Returns:
|
||||
workflow_id: ID запущенного workflow
|
||||
"""
|
||||
client = await cls._get_client()
|
||||
|
||||
workflow_id = data.kyc_request_id
|
||||
|
||||
await client.start_workflow(
|
||||
cls.WORKFLOW_NAME,
|
||||
{
|
||||
"kyc_request_id": data.kyc_request_id,
|
||||
"team_name": data.team_name,
|
||||
"owner_id": data.owner_id,
|
||||
"owner_email": data.owner_email,
|
||||
"country_code": data.country_code,
|
||||
"country_data": data.country_data,
|
||||
},
|
||||
id=workflow_id,
|
||||
task_queue=settings.TEMPORAL_TASK_QUEUE,
|
||||
)
|
||||
|
||||
logger.info(f"KYC workflow started: {workflow_id}")
|
||||
return workflow_id
|
||||
|
||||
@classmethod
|
||||
def start(cls, kyc_request) -> Optional[str]:
|
||||
"""
|
||||
Запустить KYC Application workflow (sync wrapper).
|
||||
|
||||
Args:
|
||||
kyc_request: KYCRequest model instance (главная модель)
|
||||
|
||||
Returns:
|
||||
workflow_id или None при ошибке
|
||||
"""
|
||||
# Собираем данные страны через GenericForeignKey
|
||||
country_data = kyc_request.get_country_data()
|
||||
|
||||
data = KycWorkflowData(
|
||||
kyc_request_id=str(kyc_request.uuid),
|
||||
team_name=kyc_request.team_name or country_data.get('company_name', ''),
|
||||
owner_id=kyc_request.user_id,
|
||||
owner_email=kyc_request.contact_email,
|
||||
country_code=kyc_request.country_code,
|
||||
country_data=country_data,
|
||||
)
|
||||
|
||||
try:
|
||||
workflow_id = asyncio.run(cls.start_async(data))
|
||||
kyc_request.workflow_status = "active"
|
||||
kyc_request.save(update_fields=["workflow_status", "updated_at"])
|
||||
return workflow_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start KYC workflow: {e}")
|
||||
kyc_request.workflow_status = "error"
|
||||
kyc_request.save(update_fields=["workflow_status", "updated_at"])
|
||||
return None
|
||||
15
kyc_app/views.py
Normal file
15
kyc_app/views.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Views for KYC API.
|
||||
|
||||
Authentication is handled by GRAPHENE MIDDLEWARE in settings.py
|
||||
"""
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
from .graphql_middleware import UserJWTMiddleware
|
||||
|
||||
|
||||
class UserGraphQLView(GraphQLView):
|
||||
"""User endpoint - requires ID Token."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['middleware'] = [UserJWTMiddleware()]
|
||||
super().__init__(*args, **kwargs)
|
||||
Reference in New Issue
Block a user