Initial commit from monorepo
This commit is contained in:
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
NIXPACKS_POETRY_VERSION=2.2.1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN python -m venv --copies /opt/venv
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \
|
||||||
|
&& poetry install --no-interaction --no-ansi
|
||||||
|
|
||||||
|
ENV PORT=8000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn kyc.wsgi:application --bind 0.0.0.0:${PORT:-8000}"]
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# KYC Service
|
||||||
|
|
||||||
|
Backend сервис для процедур Know Your Customer (KYC) в системе Optovia.
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Сервис для проверки и верификации компаний с интеграцией DaData и поддержкой различных стран. Включает проверку российских компаний с использованием ИНН, КПП, ОГРН.
|
||||||
|
|
||||||
|
## Основные функции
|
||||||
|
|
||||||
|
- Проверка и верификация компаний
|
||||||
|
- Интеграция с DaData для российских компаний
|
||||||
|
- Обработка банковских реквизитов
|
||||||
|
- Управление статусами KYC (pending, in_review, approved, rejected, expired)
|
||||||
|
- Поддержка различных стран
|
||||||
|
|
||||||
|
## Модели данных
|
||||||
|
|
||||||
|
- **KYCRequest** - базовая модель для KYC запросов
|
||||||
|
- **KYCRequestRussia** - специализированная модель для российских компаний
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
- Django 5.2.8
|
||||||
|
- GraphQL (Graphene-Django)
|
||||||
|
- PostgreSQL
|
||||||
|
- DaData API Integration
|
||||||
|
- Gunicorn
|
||||||
|
|
||||||
|
## Развертывание
|
||||||
|
|
||||||
|
Проект развертывается через Nixpacks на Dokploy с автоматическими миграциями.
|
||||||
|
|
||||||
|
## Автор
|
||||||
|
|
||||||
|
Ruslan Bakiev
|
||||||
1
kyc/__init__.py
Normal file
1
kyc/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Django orders service
|
||||||
147
kyc/settings.py
Normal file
147
kyc/settings.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from infisical_sdk import InfisicalSDKClient
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
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 /kyc and /shared
|
||||||
|
for secret_path in ["/kyc", "/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://kyc.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',
|
||||||
|
'kyc_app',
|
||||||
|
]
|
||||||
|
|
||||||
|
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 = 'kyc.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 = 'kyc.wsgi.application'
|
||||||
|
|
||||||
|
db_url = os.environ["KYC_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_KYC_AUDIENCE = os.getenv('LOGTO_KYC_AUDIENCE', 'https://kyc.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')
|
||||||
|
|
||||||
|
# Temporal connection
|
||||||
|
TEMPORAL_HOST = os.getenv('TEMPORAL_HOST', 'temporal:7233')
|
||||||
|
TEMPORAL_NAMESPACE = os.getenv('TEMPORAL_NAMESPACE', 'default')
|
||||||
|
TEMPORAL_TASK_QUEUE = os.getenv('TEMPORAL_TASK_QUEUE', 'platform-worker')
|
||||||
117
kyc/settings_local.py
Normal file
117
kyc/settings_local.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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://kyc.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',
|
||||||
|
'kyc_app',
|
||||||
|
]
|
||||||
|
|
||||||
|
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 = 'kyc.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 = 'kyc.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_KYC_AUDIENCE = os.getenv('LOGTO_KYC_AUDIENCE', 'https://kyc.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')
|
||||||
|
|
||||||
|
# Temporal connection
|
||||||
|
TEMPORAL_HOST = os.getenv('TEMPORAL_HOST', 'temporal:7233')
|
||||||
|
TEMPORAL_NAMESPACE = os.getenv('TEMPORAL_NAMESPACE', 'default')
|
||||||
|
TEMPORAL_TASK_QUEUE = os.getenv('TEMPORAL_TASK_QUEUE', 'platform-worker')
|
||||||
10
kyc/urls.py
Normal file
10
kyc/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from kyc_app.views import UserGraphQLView
|
||||||
|
from kyc_app.schemas.user_schema import user_schema
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('graphql/user/', csrf_exempt(UserGraphQLView.as_view(graphiql=True, schema=user_schema))),
|
||||||
|
]
|
||||||
6
kyc/wsgi.py
Normal file
6
kyc/wsgi.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kyc.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
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)
|
||||||
17
manage.py
Normal file
17
manage.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'kyc.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)
|
||||||
18
nixpacks.toml
Normal file
18
nixpacks.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
providers = ["python"]
|
||||||
|
|
||||||
|
[build]
|
||||||
|
|
||||||
|
[phases.install]
|
||||||
|
cmds = [
|
||||||
|
"python -m venv --copies /opt/venv",
|
||||||
|
". /opt/venv/bin/activate",
|
||||||
|
"pip install poetry==$NIXPACKS_POETRY_VERSION",
|
||||||
|
"poetry install --no-interaction --no-ansi"
|
||||||
|
]
|
||||||
|
|
||||||
|
[start]
|
||||||
|
cmd = "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn kyc.wsgi:application --bind 0.0.0.0:${PORT:-8000}"
|
||||||
|
|
||||||
|
[variables]
|
||||||
|
# Set Poetry version to match local environment
|
||||||
|
NIXPACKS_POETRY_VERSION = "2.2.1"
|
||||||
1005
poetry.lock
generated
Normal file
1005
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
pyproject.toml
Normal file
27
pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[project]
|
||||||
|
name = "kyc"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "KYC backend service"
|
||||||
|
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)",
|
||||||
|
"graphene-django (>=3.2.3,<4.0.0)",
|
||||||
|
"django-cors-headers (>=4.9.0,<5.0.0)",
|
||||||
|
"psycopg2-binary (>=2.9.11,<3.0.0)",
|
||||||
|
"python-dotenv (>=1.2.1,<2.0.0)",
|
||||||
|
"infisicalsdk (>=1.0.12,<2.0.0)",
|
||||||
|
"gunicorn (>=23.0.0,<24.0.0)",
|
||||||
|
"whitenoise (>=6.7.0,<7.0.0)",
|
||||||
|
"sentry-sdk (>=2.47.0,<3.0.0)",
|
||||||
|
"pyjwt (>=2.10.1,<3.0.0)",
|
||||||
|
"cryptography (>=41.0.0)",
|
||||||
|
"temporalio (>=1.20.0,<2.0.0)"
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
Reference in New Issue
Block a user