Rename KYC models (Application/Profile) and add public schema with MongoDB
All checks were successful
Build Docker Image / build (push) Successful in 3m7s

This commit is contained in:
Ruslan Bakiev
2026-01-21 09:19:37 +07:00
parent ef4b6b6b1b
commit 91fb2ec0dc
10 changed files with 607 additions and 66 deletions

View File

@@ -1,21 +1,21 @@
from django.contrib import admin, messages
from .models import KYCRequest, KYCMonitoring, KYCRequestRussia
from .models import KYCApplication, KYCProfile, KYCDetailsRussia
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)
"""Start KYC workflow for selected applications."""
for kyc_app in queryset:
workflow_id = KycWorkflowClient.start(kyc_app)
if workflow_id:
messages.success(request, f"Workflow started for {kyc_request.uuid}")
messages.success(request, f"Workflow started for {kyc_app.uuid}")
else:
messages.error(request, f"Failed to start workflow for {kyc_request.uuid}")
messages.error(request, f"Failed to start workflow for {kyc_app.uuid}")
@admin.register(KYCRequest)
class KYCRequestAdmin(admin.ModelAdmin):
@admin.register(KYCApplication)
class KYCApplicationAdmin(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')
@@ -40,8 +40,8 @@ class KYCRequestAdmin(admin.ModelAdmin):
)
@admin.register(KYCMonitoring)
class KYCMonitoringAdmin(admin.ModelAdmin):
@admin.register(KYCProfile)
class KYCProfileAdmin(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')
@@ -65,8 +65,8 @@ class KYCMonitoringAdmin(admin.ModelAdmin):
)
@admin.register(KYCRequestRussia)
class KYCRequestRussiaAdmin(admin.ModelAdmin):
@admin.register(KYCDetailsRussia)
class KYCDetailsRussiaAdmin(admin.ModelAdmin):
list_display = ('id', 'company_name', 'inn', 'ogrn')
search_fields = ('company_name', 'inn', 'ogrn')
ordering = ('-id',)

View File

@@ -35,7 +35,7 @@ class AbstractKycBase(models.Model):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='kyc_requests'
related_name='%(class)s_set'
)
object_id = models.PositiveIntegerField(null=True, blank=True)
country_details = GenericForeignKey('content_type', 'object_id')
@@ -49,24 +49,16 @@ class AbstractKycBase(models.Model):
abstract = True
class KYCRequest(AbstractKycBase):
"""Главная модель KYC заявки. Ссылается на детали страны через 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')
class KYCApplication(AbstractKycBase):
"""KYC заявка на верификацию. Одноразовый процесс."""
class Meta:
db_table = 'kyc_requests'
db_table = 'kyc_requests' # Сохраняем для совместимости
verbose_name = 'KYC Application'
verbose_name_plural = 'KYC Applications'
def __str__(self):
return f"KYC {self.user_id} - {self.workflow_status}"
return f"KYC Application {self.user_id} - {self.workflow_status}"
def get_country_data(self) -> dict:
"""Получить данные страны как словарь для Temporal workflow."""
@@ -75,27 +67,28 @@ class KYCRequest(AbstractKycBase):
return {}
class KYCMonitoring(AbstractKycBase):
"""KYC мониторинг (копия заявки для долгосрочного наблюдения)."""
class KYCProfile(AbstractKycBase):
"""KYC профиль компании для долгосрочного мониторинга.
content_type = models.ForeignKey(
ContentType,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='kyc_monitoring'
)
object_id = models.PositiveIntegerField(null=True, blank=True)
country_details = GenericForeignKey('content_type', 'object_id')
Создается после успешной верификации (approval) заявки.
Используется Dagster для сбора данных о компании.
"""
class Meta:
db_table = 'kyc_monitoring'
db_table = 'kyc_monitoring' # Сохраняем для совместимости
verbose_name = 'KYC Profile'
verbose_name_plural = 'KYC Profiles'
def __str__(self):
return f"KYC Monitoring {self.user_id} - {self.workflow_status}"
return f"KYC Profile {self.user_id} - {self.workflow_status}"
class KYCRequestRussia(models.Model):
# Aliases for backwards compatibility
KYCRequest = KYCApplication
KYCMonitoring = KYCProfile
class KYCDetailsRussia(models.Model):
"""Детали KYC для России. Отдельная модель без наследования."""
# Данные компании от DaData
@@ -113,6 +106,8 @@ class KYCRequestRussia(models.Model):
class Meta:
db_table = 'kyc_details_russia'
verbose_name = 'KYC Details Russia'
verbose_name_plural = 'KYC Details Russia'
def __str__(self):
return f"KYC Russia: {self.company_name} (ИНН: {self.inn})"
@@ -130,3 +125,7 @@ class KYCRequestRussia(models.Model):
"bik": self.bik,
"correspondent_account": self.correspondent_account,
}
# Alias for backwards compatibility
KYCRequestRussia = KYCDetailsRussia

View File

@@ -0,0 +1,98 @@
"""
M2M GraphQL Schema for internal service-to-service calls.
This endpoint is called by Temporal worker to create KYCProfile records.
No authentication required (internal network only).
"""
import graphene
from ..models import KYCApplication, KYCProfile
class CreateKycProfileMutation(graphene.Mutation):
"""Create KYCProfile record from existing KYCApplication.
Called after KYC approval to create long-term monitoring profile.
"""
class Arguments:
kyc_application_id = graphene.String(required=True)
success = graphene.Boolean()
profile_uuid = graphene.String()
message = graphene.String()
def mutate(self, info, kyc_application_id: str):
try:
# Find the KYCApplication
kyc_application = KYCApplication.objects.get(uuid=kyc_application_id)
# Check if profile already exists for this user/team
existing = KYCProfile.objects.filter(
user_id=kyc_application.user_id,
team_name=kyc_application.team_name,
).first()
if existing:
return CreateKycProfileMutation(
success=True,
profile_uuid=str(existing.uuid),
message="Profile already exists",
)
# Create KYCProfile by copying fields from KYCApplication
profile = KYCProfile.objects.create(
user_id=kyc_application.user_id,
team_name=kyc_application.team_name,
country_code=kyc_application.country_code,
workflow_status='active', # Approved = active for profile
score=kyc_application.score,
contact_person=kyc_application.contact_person,
contact_email=kyc_application.contact_email,
contact_phone=kyc_application.contact_phone,
content_type=kyc_application.content_type,
object_id=kyc_application.object_id,
approved_by=kyc_application.approved_by,
approved_at=kyc_application.approved_at,
)
return CreateKycProfileMutation(
success=True,
profile_uuid=str(profile.uuid),
message="Profile created",
)
except KYCApplication.DoesNotExist:
return CreateKycProfileMutation(
success=False,
profile_uuid="",
message=f"KYCApplication not found: {kyc_application_id}",
)
except Exception as e:
return CreateKycProfileMutation(
success=False,
profile_uuid="",
message=str(e),
)
class M2MQuery(graphene.ObjectType):
"""M2M Query - health check only."""
health = graphene.String()
def resolve_health(self, info):
return "ok"
class M2MMutation(graphene.ObjectType):
"""M2M Mutations for internal service calls."""
# New name
create_kyc_profile = CreateKycProfileMutation.Field()
# Old name for backwards compatibility
create_kyc_monitoring = CreateKycProfileMutation.Field()
m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation)

View File

@@ -0,0 +1,264 @@
"""
Public GraphQL Schema for company data.
This endpoint provides:
- companyTeaser: public data (no auth required)
- companyFull: full data (requires authentication)
Data is read from MongoDB company_documents collection.
Queries can be by INN (direct) or by KYC Profile UUID.
"""
import graphene
from django.conf import settings
from pymongo import MongoClient
from ..models import KYCProfile, KYCDetailsRussia
def get_mongo_client():
"""Get MongoDB client."""
if not settings.MONGODB_URI:
return None
return MongoClient(settings.MONGODB_URI)
def get_company_documents(inn: str) -> list:
"""Get all documents for a company by INN."""
client = get_mongo_client()
if not client:
return []
try:
db = client[settings.MONGODB_DB]
collection = db["company_documents"]
return list(collection.find({"inn": inn}))
finally:
client.close()
def get_inn_by_profile_uuid(profile_uuid: str) -> str | None:
"""Get INN from KYCProfile by its UUID."""
try:
profile = KYCProfile.objects.get(uuid=profile_uuid)
# Get country details (KYCDetailsRussia) via GenericForeignKey
if profile.country_details and isinstance(profile.country_details, KYCDetailsRussia):
return profile.country_details.inn
return None
except KYCProfile.DoesNotExist:
return None
def aggregate_company_data(documents: list) -> dict:
"""Aggregate data from multiple source documents into summary."""
if not documents:
return {}
summary = {
"inn": None,
"ogrn": None,
"name": None,
"company_type": None,
"registration_year": None,
"is_active": True,
"address": None,
"director": None,
"capital": None,
"activities": [],
"sources": [],
"last_updated": None,
}
for doc in documents:
source = doc.get("source", "unknown")
summary["sources"].append(source)
data = doc.get("data", {})
# Extract common fields
if not summary["inn"]:
summary["inn"] = doc.get("inn")
if not summary["ogrn"] and data.get("ogrn"):
summary["ogrn"] = data["ogrn"]
if not summary["name"] and data.get("name"):
summary["name"] = data["name"]
# Parse company type from name
if not summary["company_type"] and summary["name"]:
name = summary["name"].upper()
if "ООО" in name or "ОБЩЕСТВО С ОГРАНИЧЕННОЙ" in name:
summary["company_type"] = "ООО"
elif "АО" in name or "АКЦИОНЕРНОЕ ОБЩЕСТВО" in name:
summary["company_type"] = "АО"
elif "ИП" in name or "ИНДИВИДУАЛЬНЫЙ ПРЕДПРИНИМАТЕЛЬ" in name:
summary["company_type"] = "ИП"
elif "ПАО" in name:
summary["company_type"] = "ПАО"
# Track last update
collected_at = doc.get("collected_at")
if collected_at:
if not summary["last_updated"] or collected_at > summary["last_updated"]:
summary["last_updated"] = collected_at
return summary
class CompanyTeaserType(graphene.ObjectType):
"""Public company data (teaser)."""
company_type = graphene.String(description="Company type: ООО, АО, ИП, etc.")
registration_year = graphene.Int(description="Year of registration")
is_active = graphene.Boolean(description="Is company active")
sources_count = graphene.Int(description="Number of data sources")
class CompanyFullType(graphene.ObjectType):
"""Full company data (requires auth)."""
inn = graphene.String()
ogrn = graphene.String()
name = graphene.String()
company_type = graphene.String()
registration_year = graphene.Int()
is_active = graphene.Boolean()
address = graphene.String()
director = graphene.String()
capital = graphene.String()
activities = graphene.List(graphene.String)
sources = graphene.List(graphene.String)
last_updated = graphene.DateTime()
class PublicQuery(graphene.ObjectType):
"""Public queries - no authentication required."""
# Query by KYC Profile UUID (preferred - used by frontend)
company_teaser_by_profile = graphene.Field(
CompanyTeaserType,
profile_uuid=graphene.String(required=True),
description="Get public company teaser data by KYC Profile UUID",
)
company_full_by_profile = graphene.Field(
CompanyFullType,
profile_uuid=graphene.String(required=True),
description="Get full company data by KYC Profile UUID (requires auth)",
)
# Query by INN (legacy/direct)
company_teaser = graphene.Field(
CompanyTeaserType,
inn=graphene.String(required=True),
description="Get public company teaser data by INN",
)
company_full = graphene.Field(
CompanyFullType,
inn=graphene.String(required=True),
description="Get full company data by INN (requires auth)",
)
health = graphene.String()
def resolve_health(self, info):
return "ok"
def resolve_company_teaser_by_profile(self, info, profile_uuid: str):
"""Return public teaser data by KYC Profile UUID."""
inn = get_inn_by_profile_uuid(profile_uuid)
if not inn:
return None
documents = get_company_documents(inn)
if not documents:
return None
summary = aggregate_company_data(documents)
return CompanyTeaserType(
company_type=summary.get("company_type"),
registration_year=summary.get("registration_year"),
is_active=summary.get("is_active", True),
sources_count=len(summary.get("sources", [])),
)
def resolve_company_full_by_profile(self, info, profile_uuid: str):
"""Return full company data by KYC Profile UUID (requires auth)."""
# Check authentication
user_id = getattr(info.context, 'user_id', None)
if not user_id:
return None # Not authenticated
inn = get_inn_by_profile_uuid(profile_uuid)
if not inn:
return None
documents = get_company_documents(inn)
if not documents:
return None
summary = aggregate_company_data(documents)
return CompanyFullType(
inn=summary.get("inn"),
ogrn=summary.get("ogrn"),
name=summary.get("name"),
company_type=summary.get("company_type"),
registration_year=summary.get("registration_year"),
is_active=summary.get("is_active", True),
address=summary.get("address"),
director=summary.get("director"),
capital=summary.get("capital"),
activities=summary.get("activities", []),
sources=summary.get("sources", []),
last_updated=summary.get("last_updated"),
)
def resolve_company_teaser(self, info, inn: str):
"""Return public teaser data by INN (no auth required)."""
documents = get_company_documents(inn)
if not documents:
return None
summary = aggregate_company_data(documents)
return CompanyTeaserType(
company_type=summary.get("company_type"),
registration_year=summary.get("registration_year"),
is_active=summary.get("is_active", True),
sources_count=len(summary.get("sources", [])),
)
def resolve_company_full(self, info, inn: str):
"""Return full company data by INN (requires auth)."""
# Check authentication
user_id = getattr(info.context, 'user_id', None)
if not user_id:
return None # Not authenticated
documents = get_company_documents(inn)
if not documents:
return None
summary = aggregate_company_data(documents)
return CompanyFullType(
inn=summary.get("inn"),
ogrn=summary.get("ogrn"),
name=summary.get("name"),
company_type=summary.get("company_type"),
registration_year=summary.get("registration_year"),
is_active=summary.get("is_active", True),
address=summary.get("address"),
director=summary.get("director"),
capital=summary.get("capital"),
activities=summary.get("activities", []),
sources=summary.get("sources", []),
last_updated=summary.get("last_updated"),
)
public_schema = graphene.Schema(query=PublicQuery)

View File

@@ -1,13 +1,14 @@
import graphene
from graphene_django import DjangoObjectType
from django.contrib.contenttypes.models import ContentType
from ..models import KYCRequest, KYCRequestRussia
from ..models import KYCApplication, KYCDetailsRussia
from ..temporal import KycWorkflowClient
class KYCRequestType(DjangoObjectType):
class KYCApplicationType(DjangoObjectType):
"""GraphQL type for KYC Application (заявка)."""
class Meta:
model = KYCRequest
model = KYCApplication
fields = '__all__'
country_data = graphene.JSONString()
@@ -17,13 +18,15 @@ class KYCRequestType(DjangoObjectType):
return self.get_country_data()
class KYCRequestRussiaType(DjangoObjectType):
class KYCDetailsRussiaType(DjangoObjectType):
"""GraphQL type for Russia-specific KYC details."""
class Meta:
model = KYCRequestRussia
model = KYCDetailsRussia
fields = '__all__'
class KYCRequestRussiaInput(graphene.InputObjectType):
class KYCApplicationRussiaInput(graphene.InputObjectType):
"""Input for creating KYC Application for Russia."""
companyName = graphene.String(required=True)
companyFullName = graphene.String(required=True)
inn = graphene.String(required=True)
@@ -38,11 +41,12 @@ class KYCRequestRussiaInput(graphene.InputObjectType):
contactPhone = graphene.String(required=True)
class CreateKYCRequestRussia(graphene.Mutation):
class CreateKYCApplicationRussia(graphene.Mutation):
"""Create KYC Application for Russian company."""
class Arguments:
input = KYCRequestRussiaInput(required=True)
input = KYCApplicationRussiaInput(required=True)
kyc_request = graphene.Field(KYCRequestType)
kyc_application = graphene.Field(KYCApplicationType)
success = graphene.Boolean()
def mutate(self, info, input):
@@ -52,7 +56,7 @@ class CreateKYCRequestRussia(graphene.Mutation):
raise Exception("Not authenticated")
# 1. Create Russia details
russia_details = KYCRequestRussia.objects.create(
russia_details = KYCDetailsRussia.objects.create(
company_name=input.companyName,
company_full_name=input.companyFullName,
inn=input.inn,
@@ -64,49 +68,67 @@ class CreateKYCRequestRussia(graphene.Mutation):
correspondent_account=input.correspondentAccount or '',
)
# 2. Create main KYCRequest with reference to details
kyc_request = KYCRequest.objects.create(
# 2. Create main KYC Application with reference to details
kyc_application = KYCApplication.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),
content_type=ContentType.objects.get_for_model(KYCDetailsRussia),
object_id=russia_details.id,
)
# 3. Start Temporal workflow
KycWorkflowClient.start(kyc_request)
KycWorkflowClient.start(kyc_application)
return CreateKYCRequestRussia(kyc_request=kyc_request, success=True)
return CreateKYCApplicationRussia(kyc_application=kyc_application, 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))
# Keep old names for backwards compatibility
kyc_requests = graphene.List(KYCApplicationType, description="Get user's KYC applications")
kyc_request = graphene.Field(KYCApplicationType, uuid=graphene.String(required=True))
# New names
kyc_applications = graphene.List(KYCApplicationType, description="Get user's KYC applications")
kyc_application = graphene.Field(KYCApplicationType, uuid=graphene.String(required=True))
def resolve_kyc_requests(self, info):
# Filter by user_id from JWT token
return self._get_applications(info)
def resolve_kyc_applications(self, info):
return self._get_applications(info)
def _get_applications(self, info):
user_id = getattr(info.context, 'user_id', None)
if not user_id:
return []
return KYCRequest.objects.filter(user_id=user_id)
return KYCApplication.objects.filter(user_id=user_id)
def resolve_kyc_request(self, info, uuid):
return self._get_application(info, uuid)
def resolve_kyc_application(self, info, uuid):
return self._get_application(info, uuid)
def _get_application(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 KYCApplication.objects.get(uuid=uuid, user_id=user_id)
except KYCApplication.DoesNotExist:
return None
class UserMutation(graphene.ObjectType):
"""User mutations - ID token authentication"""
create_kyc_request_russia = CreateKYCRequestRussia.Field()
# Keep old name for backwards compatibility
create_kyc_request_russia = CreateKYCApplicationRussia.Field()
# New name
create_kyc_application_russia = CreateKYCApplicationRussia.Field()
user_schema = graphene.Schema(query=UserQuery, mutation=UserMutation)

View File

@@ -13,3 +13,37 @@ class UserGraphQLView(GraphQLView):
def __init__(self, *args, **kwargs):
kwargs['middleware'] = [UserJWTMiddleware()]
super().__init__(*args, **kwargs)
class M2MGraphQLView(GraphQLView):
"""M2M endpoint - no authentication (internal network only)."""
pass
class OptionalUserJWTMiddleware:
"""Middleware that optionally extracts user_id but doesn't fail if missing."""
def resolve(self, next, root, info, **args):
request = info.context
# Try to extract user_id from Authorization header if present
auth_header = request.META.get('HTTP_AUTHORIZATION', '')
if auth_header.startswith('Bearer '):
try:
from .auth import validate_jwt_token
token = auth_header.split(' ', 1)[1]
payload = validate_jwt_token(token)
if payload:
request.user_id = payload.get('sub')
except Exception:
pass # Ignore auth errors - user just won't get full data
return next(root, info, **args)
class PublicGraphQLView(GraphQLView):
"""Public endpoint - optional auth for full data, no auth for teaser."""
def __init__(self, *args, **kwargs):
# Use optional auth middleware that doesn't fail on missing token
kwargs['middleware'] = [OptionalUserJWTMiddleware()]
super().__init__(*args, **kwargs)