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