Initial commit from monorepo

This commit is contained in:
Ruslan Bakiev
2026-01-07 09:16:05 +07:00
commit 685fbbe14a
28 changed files with 2291 additions and 0 deletions

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NIXPACKS_POETRY_VERSION=2.2.1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv --copies /opt/venv
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \
&& poetry install --no-interaction --no-ansi
ENV PORT=8000
CMD ["sh", "-c", "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn kyc.wsgi:application --bind 0.0.0.0:${PORT:-8000}"]

36
README.md Normal file
View 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
View File

@@ -0,0 +1 @@
# Django orders service

147
kyc/settings.py Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# Orders Django app

56
kyc_app/admin.py Normal file
View 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
View 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
View 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"),
)

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

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

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

View File

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

View File

94
kyc_app/models.py Normal file
View 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,
}

View File

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

View File

@@ -0,0 +1,3 @@
from .kyc_workflow_client import KycWorkflowClient
__all__ = ["KycWorkflowClient"]

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

@@ -0,0 +1,18 @@
providers = ["python"]
[build]
[phases.install]
cmds = [
"python -m venv --copies /opt/venv",
". /opt/venv/bin/activate",
"pip install poetry==$NIXPACKS_POETRY_VERSION",
"poetry install --no-interaction --no-ansi"
]
[start]
cmd = "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn 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

File diff suppressed because it is too large Load Diff

27
pyproject.toml Normal file
View 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"