From 91fb2ec0dc00c7040400a50ec72a685f148abc8e Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Wed, 21 Jan 2026 09:19:37 +0700 Subject: [PATCH] Rename KYC models (Application/Profile) and add public schema with MongoDB --- kyc/settings.py | 4 + kyc/urls.py | 6 +- kyc_app/admin.py | 24 +-- kyc_app/models.py | 57 ++++--- kyc_app/schemas/m2m_schema.py | 98 ++++++++++++ kyc_app/schemas/public_schema.py | 264 +++++++++++++++++++++++++++++++ kyc_app/schemas/user_schema.py | 66 +++++--- kyc_app/views.py | 34 ++++ poetry.lock | 117 +++++++++++++- pyproject.toml | 3 +- 10 files changed, 607 insertions(+), 66 deletions(-) create mode 100644 kyc_app/schemas/m2m_schema.py create mode 100644 kyc_app/schemas/public_schema.py diff --git a/kyc/settings.py b/kyc/settings.py index 65a23da..1bbb279 100644 --- a/kyc/settings.py +++ b/kyc/settings.py @@ -145,3 +145,7 @@ ODOO_INTERNAL_URL = os.getenv('ODOO_INTERNAL_URL', 'odoo:8069') 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') + +# MongoDB connection (for company monitoring data) +MONGODB_URI = os.getenv('MONGODB_URI', '') +MONGODB_DB = os.getenv('MONGODB_DB', 'optovia') diff --git a/kyc/urls.py b/kyc/urls.py index 7c398af..79e4284 100644 --- a/kyc/urls.py +++ b/kyc/urls.py @@ -1,10 +1,14 @@ 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.views import UserGraphQLView, M2MGraphQLView, PublicGraphQLView from kyc_app.schemas.user_schema import user_schema +from kyc_app.schemas.m2m_schema import m2m_schema +from kyc_app.schemas.public_schema import public_schema urlpatterns = [ path('admin/', admin.site.urls), path('graphql/user/', csrf_exempt(UserGraphQLView.as_view(graphiql=True, schema=user_schema))), + path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=True, schema=m2m_schema))), + path('graphql/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=public_schema))), ] \ No newline at end of file diff --git a/kyc_app/admin.py b/kyc_app/admin.py index c22cf57..c17680d 100644 --- a/kyc_app/admin.py +++ b/kyc_app/admin.py @@ -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',) diff --git a/kyc_app/models.py b/kyc_app/models.py index 519b1c5..3658706 100644 --- a/kyc_app/models.py +++ b/kyc_app/models.py @@ -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 diff --git a/kyc_app/schemas/m2m_schema.py b/kyc_app/schemas/m2m_schema.py new file mode 100644 index 0000000..27a774c --- /dev/null +++ b/kyc_app/schemas/m2m_schema.py @@ -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) diff --git a/kyc_app/schemas/public_schema.py b/kyc_app/schemas/public_schema.py new file mode 100644 index 0000000..84c7b07 --- /dev/null +++ b/kyc_app/schemas/public_schema.py @@ -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) diff --git a/kyc_app/schemas/user_schema.py b/kyc_app/schemas/user_schema.py index 2aeec6c..876afe6 100644 --- a/kyc_app/schemas/user_schema.py +++ b/kyc_app/schemas/user_schema.py @@ -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) diff --git a/kyc_app/views.py b/kyc_app/views.py index 7f8a5b3..04ef8aa 100644 --- a/kyc_app/views.py +++ b/kyc_app/views.py @@ -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) diff --git a/poetry.lock b/poetry.lock index 3e2e543..53f3734 100644 --- a/poetry.lock +++ b/poetry.lock @@ -414,6 +414,27 @@ files = [ asgiref = ">=3.6" django = ">=4.2" +[[package]] +name = "dnspython" +version = "2.8.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, +] + +[package.extras] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] + [[package]] name = "graphene" version = "3.4.3" @@ -728,6 +749,100 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +[[package]] +name = "pymongo" +version = "4.16.0" +description = "PyMongo - the Official MongoDB Python driver" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pymongo-4.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ed162b2227f98d5b270ecbe1d53be56c8c81db08a1a8f5f02d89c7bb4d19591d"}, + {file = "pymongo-4.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a9390dce61d705a88218f0d7b54d7e1fa1b421da8129fc7c009e029a9a6b81e"}, + {file = "pymongo-4.16.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:92a232af9927710de08a6c16a9710cc1b175fb9179c0d946cd4e213b92b2a69a"}, + {file = "pymongo-4.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d79aa147ce86aef03079096d83239580006ffb684eead593917186aee407767"}, + {file = "pymongo-4.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19a1c96e7f39c7a59a9cfd4d17920cf9382f6f684faeff4649bf587dc59f8edc"}, + {file = "pymongo-4.16.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efe020c46ce3c3a89af6baec6569635812129df6fb6cf76d4943af3ba6ee2069"}, + {file = "pymongo-4.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dc2c00bed568732b89e211b6adca389053d5e6d2d5a8979e80b813c3ec4d1f9"}, + {file = "pymongo-4.16.0-cp310-cp310-win32.whl", hash = "sha256:5b9c6d689bbe5beb156374508133218610e14f8c81e35bc17d7a14e30ab593e6"}, + {file = "pymongo-4.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:2290909275c9b8f637b0a92eb9b89281e18a72922749ebb903403ab6cc7da914"}, + {file = "pymongo-4.16.0-cp310-cp310-win_arm64.whl", hash = "sha256:6af1aaa26f0835175d2200e62205b78e7ec3ffa430682e322cc91aaa1a0dbf28"}, + {file = "pymongo-4.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f2077ec24e2f1248f9cac7b9a2dfb894e50cc7939fcebfb1759f99304caabef"}, + {file = "pymongo-4.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d4f7ba040f72a9f43a44059872af5a8c8c660aa5d7f90d5344f2ed1c3c02721"}, + {file = "pymongo-4.16.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a0f73af1ea56c422b2dcfc0437459148a799ef4231c6aee189d2d4c59d6728f"}, + {file = "pymongo-4.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa30cd16ddd2f216d07ba01d9635c873e97ddb041c61cf0847254edc37d1c60e"}, + {file = "pymongo-4.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d638b0b1b294d95d0fdc73688a3b61e05cc4188872818cd240d51460ccabcb5"}, + {file = "pymongo-4.16.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:21d02cc10a158daa20cb040985e280e7e439832fc6b7857bff3d53ef6914ad50"}, + {file = "pymongo-4.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fbb8d3552c2ad99d9e236003c0b5f96d5f05e29386ba7abae73949bfebc13dd"}, + {file = "pymongo-4.16.0-cp311-cp311-win32.whl", hash = "sha256:be1099a8295b1a722d03fb7b48be895d30f4301419a583dcf50e9045968a041c"}, + {file = "pymongo-4.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:61567f712bda04c7545a037e3284b4367cad8d29b3dec84b4bf3b2147020a75b"}, + {file = "pymongo-4.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:c53338613043038005bf2e41a2fafa08d29cdbc0ce80891b5366c819456c1ae9"}, + {file = "pymongo-4.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd4911c40a43a821dfd93038ac824b756b6e703e26e951718522d29f6eb166a8"}, + {file = "pymongo-4.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25a6b03a68f9907ea6ec8bc7cf4c58a1b51a18e23394f962a6402f8e46d41211"}, + {file = "pymongo-4.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:91ac0cb0fe2bf17616c2039dac88d7c9a5088f5cb5829b27c9d250e053664d31"}, + {file = "pymongo-4.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf0ec79e8ca7077f455d14d915d629385153b6a11abc0b93283ed73a8013e376"}, + {file = "pymongo-4.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d0082631a7510318befc2b4fdab140481eb4b9dd62d9245e042157085da2a70"}, + {file = "pymongo-4.16.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85dc2f3444c346ea019a371e321ac868a4fab513b7a55fe368f0cc78de8177cc"}, + {file = "pymongo-4.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dabbf3c14de75a20cc3c30bf0c6527157224a93dfb605838eabb1a2ee3be008d"}, + {file = "pymongo-4.16.0-cp312-cp312-win32.whl", hash = "sha256:60307bb91e0ab44e560fe3a211087748b2b5f3e31f403baf41f5b7b0a70bd104"}, + {file = "pymongo-4.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:f513b2c6c0d5c491f478422f6b5b5c27ac1af06a54c93ef8631806f7231bd92e"}, + {file = "pymongo-4.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:dfc320f08ea9a7ec5b2403dc4e8150636f0d6150f4b9792faaae539c88e7db3b"}, + {file = "pymongo-4.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d15f060bc6d0964a8bb70aba8f0cb6d11ae99715438f640cff11bbcf172eb0e8"}, + {file = "pymongo-4.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a19ea46a0fe71248965305a020bc076a163311aefbaa1d83e47d06fa30ac747"}, + {file = "pymongo-4.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:311d4549d6bf1f8c61d025965aebb5ba29d1481dc6471693ab91610aaffbc0eb"}, + {file = "pymongo-4.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46ffb728d92dd5b09fc034ed91acf5595657c7ca17d4cf3751322cd554153c17"}, + {file = "pymongo-4.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:acda193f440dd88c2023cb00aa8bd7b93a9df59978306d14d87a8b12fe426b05"}, + {file = "pymongo-4.16.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d9fdb386cf958e6ef6ff537d6149be7edb76c3268cd6833e6c36aa447e4443f"}, + {file = "pymongo-4.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91899dd7fb9a8c50f09c3c1cf0cb73bfbe2737f511f641f19b9650deb61c00ca"}, + {file = "pymongo-4.16.0-cp313-cp313-win32.whl", hash = "sha256:2cd60cd1e05de7f01927f8e25ca26b3ea2c09de8723241e5d3bcfdc70eaff76b"}, + {file = "pymongo-4.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3ead8a0050c53eaa55935895d6919d393d0328ec24b2b9115bdbe881aa222673"}, + {file = "pymongo-4.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:dbbc5b254c36c37d10abb50e899bc3939bbb7ab1e7c659614409af99bd3e7675"}, + {file = "pymongo-4.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a254d49a9ffe9d7f888e3c677eed3729b14ce85abb08cd74732cead6ccc3c66"}, + {file = "pymongo-4.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a1bf44e13cf2d44d2ea2e928a8140d5d667304abe1a61c4d55b4906f389fbe64"}, + {file = "pymongo-4.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f1c5f1f818b669875d191323a48912d3fcd2e4906410e8297bb09ac50c4d5ccc"}, + {file = "pymongo-4.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77cfd37a43a53b02b7bd930457c7994c924ad8bbe8dff91817904bcbf291b371"}, + {file = "pymongo-4.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:36ef2fee50eee669587d742fb456e349634b4fcf8926208766078b089054b24b"}, + {file = "pymongo-4.16.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55f8d5a6fe2fa0b823674db2293f92d74cd5f970bc0360f409a1fc21003862d3"}, + {file = "pymongo-4.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9caacac0dd105e2555521002e2d17afc08665187017b466b5753e84c016628e6"}, + {file = "pymongo-4.16.0-cp314-cp314-win32.whl", hash = "sha256:c789236366525c3ee3cd6e4e450a9ff629a7d1f4d88b8e18a0aea0615fd7ecf8"}, + {file = "pymongo-4.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b0714d7764efb29bf9d3c51c964aed7c4c7237b341f9346f15ceaf8321fdb35"}, + {file = "pymongo-4.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:12762e7cc0f8374a8cae3b9f9ed8dabb5d438c7b33329232dd9b7de783454033"}, + {file = "pymongo-4.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1c01e8a7cd0ea66baf64a118005535ab5bf9f9eb63a1b50ac3935dccf9a54abe"}, + {file = "pymongo-4.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4c4872299ebe315a79f7f922051061634a64fda95b6b17677ba57ef00b2ba2a4"}, + {file = "pymongo-4.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:78037d02389745e247fe5ab0bcad5d1ab30726eaac3ad79219c7d6bbb07eec53"}, + {file = "pymongo-4.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c126fb72be2518395cc0465d4bae03125119136462e1945aea19840e45d89cfc"}, + {file = "pymongo-4.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3867dc225d9423c245a51eaac2cfcd53dde8e0a8d8090bb6aed6e31bd6c2d4f"}, + {file = "pymongo-4.16.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f25001a955073b80510c0c3db0e043dbbc36904fd69e511c74e3d8640b8a5111"}, + {file = "pymongo-4.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d9885aad05f82fd7ea0c9ca505d60939746b39263fa273d0125170da8f59098"}, + {file = "pymongo-4.16.0-cp314-cp314t-win32.whl", hash = "sha256:948152b30eddeae8355495f9943a3bf66b708295c0b9b6f467de1c620f215487"}, + {file = "pymongo-4.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f6e42c1bc985d9beee884780ae6048790eb4cd565c46251932906bdb1630034a"}, + {file = "pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96"}, + {file = "pymongo-4.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e2d509786344aa844ae243f68f833ca1ac92ac3e35a92ae038e2ceb44aa355ef"}, + {file = "pymongo-4.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:15bb062c0d6d4b0be650410032152de656a2a9a2aa4e1a7443a22695afacb103"}, + {file = "pymongo-4.16.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cd047ba6cc83cc24193b9208c93e134a985ead556183077678c59af7aacc725"}, + {file = "pymongo-4.16.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96aa7ab896889bf330209d26459e493d00f8855772a9453bfb4520bb1f495baf"}, + {file = "pymongo-4.16.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66af44ed23686dd5422307619a6db4b56733c5e36fe8c4adf91326dcf993a043"}, + {file = "pymongo-4.16.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:03f42396c1b2c6f46f5401c5b185adc25f6113716e16d9503977ee5386fca0fb"}, + {file = "pymongo-4.16.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d284bf68daffc57516535f752e290609b3b643f4bd54b28fc13cb16a89a8bda6"}, + {file = "pymongo-4.16.0-cp39-cp39-win32.whl", hash = "sha256:7902882ed0efb7f0e991458ab3b8cf0eb052957264949ece2f09b63c58b04f78"}, + {file = "pymongo-4.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:e37469602473f41221cea93fd3736708f561f0fa08ab6b2873dd962014390d52"}, + {file = "pymongo-4.16.0-cp39-cp39-win_arm64.whl", hash = "sha256:2a3ba6be3d8acf64b77cdcd4e36f0e4a8e87965f14a8b09b90ca86f10a1dd2f2"}, + {file = "pymongo-4.16.0.tar.gz", hash = "sha256:8ba8405065f6e258a6f872fe62d797a28f383a12178c7153c01ed04e845c600c"}, +] + +[package.dependencies] +dnspython = ">=2.6.1,<3.0.0" + +[package.extras] +aws = ["pymongo-auth-aws (>=1.1.0,<2.0.0)"] +docs = ["furo (==2025.12.19)", "readthedocs-sphinx-search (>=0.3,<1.0)", "sphinx (>=5.3,<9)", "sphinx-autobuild (>=2020.9.1)", "sphinx-rtd-theme (>=2,<4)", "sphinxcontrib-shellcheck (>=1,<2)"] +encryption = ["certifi (>=2023.7.22) ; os_name == \"nt\" or sys_platform == \"darwin\"", "pymongo-auth-aws (>=1.1.0,<2.0.0)", "pymongocrypt (>=1.13.0,<2.0.0)"] +gssapi = ["pykerberos (>=1.2.4) ; os_name != \"nt\"", "winkerberos (>=0.5.0) ; os_name == \"nt\""] +ocsp = ["certifi (>=2023.7.22) ; os_name == \"nt\" or sys_platform == \"darwin\"", "cryptography (>=42.0.0)", "pyopenssl (>=23.2.0)", "requests (>=2.23.0,<3.0)", "service-identity (>=23.1.0)"] +snappy = ["python-snappy (>=0.6.0)"] +test = ["importlib-metadata (>=7.0) ; python_version < \"3.13\"", "pytest (>=8.2)", "pytest-asyncio (>=0.24.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1002,4 +1117,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "489568163820e27b9ef91c3484ac57aeff3284d0909f216fe6af37f59dfbc00e" +content-hash = "b6022569e46f079bf6775c23e68fd07035e5fa611f0bda14dddbfbbdee5b490f" diff --git a/pyproject.toml b/pyproject.toml index 119c646..24651c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,8 @@ dependencies = [ "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)" + "temporalio (>=1.20.0,<2.0.0)", + "pymongo (>=4.16.0,<5.0.0)" ] [build-system]