From 623b46c691cc5f1982db5e59e497873694899587 Mon Sep 17 00:00:00 2001 From: Viswamedha Nalabotu Date: Sun, 8 Mar 2026 13:09:32 +0000 Subject: [PATCH] Added extra code actions for viewsets --- apps/onboarding/viewsets.py | 770 +++++++++++++++++++++++++++++++++--- 1 file changed, 713 insertions(+), 57 deletions(-) diff --git a/apps/onboarding/viewsets.py b/apps/onboarding/viewsets.py index 34004f4..b380551 100644 --- a/apps/onboarding/viewsets.py +++ b/apps/onboarding/viewsets.py @@ -1,49 +1,150 @@ -from django.db.models import Q +import httpx +from django.conf import settings from django.db import transaction +from django.db.models import Q from django.utils import timezone -from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN, HTTP_201_CREATED, HTTP_200_OK from rest_framework.decorators import action +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet -from apps.onboarding.models import AgentConfig, OnboardingSession, AgentInteractionLog, OnboardingFlow -from apps.onboarding.serializers import ( - AgentConfigSerializer, - OnboardingSessionSerializer, - AgentInteractionLogSerializer, - OnboardingFlowSerializer -) +from apps.accounts.models import Organization, Role +from apps.accounts.permissions import CanManageOrganization, can_manage_organization +from apps.onboarding.models import AgentConfig, AgentInteractionLog, OnboardingFlow, OnboardingSession +from apps.onboarding.serializers import AgentConfigSerializer, AgentInteractionLogSerializer, OnboardingFlowSerializer, OnboardingSessionSerializer class OnboardingFlowViewSet(ModelViewSet): queryset = OnboardingFlow.objects.all() serializer_class = OnboardingFlowSerializer permission_classes = [IsAuthenticated] lookup_field = 'uuid' - - filterset_fields = { - 'role__uuid': ['exact'], - } + def get_permissions(self): + permissions = super().get_permissions() + if self.action in ['update', 'partial_update', 'destroy']: + return [*permissions, CanManageOrganization()] + return permissions + def get_queryset(self): user = self.request.user - return OnboardingFlow.objects.filter( + queryset = OnboardingFlow.objects.filter( Q(role__organization__owner=user) | Q(role__organization__members=user) ).distinct().order_by('-created_at') - def destroy(self, request, *args, **kwargs): + organization_uuid = self.request.query_params.get('organization_uuid') + if organization_uuid in (None, ''): + organization_uuid = self.request.data.get('organization_uuid') + if organization_uuid: + queryset = queryset.filter(role__organization__uuid=organization_uuid) + + role_uuid = self.request.query_params.get('role_uuid') + if role_uuid in (None, ''): + role_uuid = self.request.data.get('role_uuid') + if role_uuid: + queryset = queryset.filter(role__uuid=role_uuid) + + return queryset + + def create(self, request, *args, **kwargs): + role_uuid = request.data.get('role_uuid') + if not role_uuid: + raise ValidationError({'role_uuid': 'role_uuid is required.'}) + + role = Role.objects.filter(uuid=role_uuid).first() + if not role: + raise NotFound('Role not found') + if not can_manage_organization(request.user, role.organization): + raise PermissionDenied('Forbidden') + + is_active = self._parse_bool(request.data.get('is_active'), default=True, field_name='is_active') + + flow = OnboardingFlow.objects.create( + title=(request.data.get('title') or f'Onboarding Flow: {role.name}').strip(), + role=role, + structure=request.data.get('structure') or [], + is_active=is_active, + ) + + serializer = self.get_serializer(flow) + return Response(serializer.data, status=HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + flow = self.get_object() + title = request.data.get('title') + structure = request.data.get('structure') + is_active = request.data.get('is_active') + + if title is not None: + flow.title = str(title).strip() + if structure is not None: + flow.structure = structure if isinstance(structure, list) else flow.structure + if is_active is not None: + flow.is_active = self._parse_bool(is_active, field_name='is_active') + + flow.save(update_fields=['title', 'structure', 'is_active', 'updated_at']) + serializer = self.get_serializer(flow) + return Response(serializer.data, status=HTTP_200_OK) + + def partial_update(self, request, *args, **kwargs): flow = self.get_object() + if 'title' in request.data: + flow.title = str(request.data.get('title') or '').strip() + if 'structure' in request.data: + structure = request.data.get('structure') + if not isinstance(structure, list): + raise ValidationError({'structure': 'structure must be a list'}) + flow.structure = structure + if 'is_active' in request.data: + flow.is_active = self._parse_bool(request.data.get('is_active'), field_name='is_active') + + flow.save(update_fields=['title', 'structure', 'is_active', 'updated_at']) + serializer = self.get_serializer(flow) + return Response(serializer.data, status=HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + flow = self.get_object() with transaction.atomic(): OnboardingSession.objects.filter(role=flow.role).delete() self.perform_destroy(flow) return Response(status=204) + def _parse_bool(self, value, *, default=None, field_name='value'): + if value is None: + if default is not None: + return default + raise ValidationError({field_name: f'{field_name} is required'}) + + if isinstance(value, bool): + return value + + if isinstance(value, int): + if value in (0, 1): + return bool(value) + raise ValidationError({field_name: f'{field_name} must be a boolean value'}) + + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ['true', '1', 'yes', 'on']: + return True + if normalized in ['false', '0', 'no', 'off']: + return False + + raise ValidationError({field_name: f'{field_name} must be a boolean value'}) + @action(detail=True, methods=['post'], url_path='start-session') def start_session(self, request, uuid=None): flow = self.get_object() + + if not request.user.is_manager and not flow.role.members.filter(id=request.user.id).exists(): + return Response( + {'error': 'Join this role before starting onboarding.'}, + status=HTTP_403_FORBIDDEN, + ) session, created = OnboardingSession.objects.get_or_create( user=request.user, @@ -73,18 +174,114 @@ class AgentConfigViewSet(ModelViewSet): serializer_class = AgentConfigSerializer permission_classes = [IsAuthenticated] lookup_field = 'uuid' + + def get_permissions(self): + permissions = super().get_permissions() + if self.action in ['update', 'partial_update', 'destroy']: + return [*permissions, CanManageOrganization()] + return permissions - filterset_fields = { - 'organization__uuid': ['exact'], - } - def get_queryset(self): - return AgentConfig.objects.filter(organization__members=self.request.user).distinct() + queryset = AgentConfig.objects.filter( + Q(organization__owner=self.request.user) | Q(organization__members=self.request.user) + ).distinct().order_by('-updated_at') - def perform_create(self, serializer): - if not self.request.user.is_manager: - return Response({'error': 'Forbidden'}, status=HTTP_403_FORBIDDEN) - serializer.save() + organization_uuid = self.request.query_params.get('organization_uuid') + if organization_uuid in (None, ''): + organization_uuid = self.request.data.get('organization_uuid') + if organization_uuid: + queryset = queryset.filter(organization__uuid=organization_uuid) + + role_uuid = self.request.query_params.get('role_uuid') + if role_uuid in (None, ''): + role_uuid = self.request.data.get('role_uuid') + if role_uuid: + queryset = queryset.filter(role__uuid=role_uuid) + + return queryset + + def create(self, request, *args, **kwargs): + organization_uuid = request.query_params.get('organization_uuid') + if organization_uuid in (None, ''): + organization_uuid = request.data.get('organization_uuid') + if not organization_uuid: + raise ValidationError({'organization_uuid': 'organization_uuid is required.'}) + + organization = Organization.objects.filter(uuid=organization_uuid).filter( + Q(owner=request.user) | Q(members=request.user), + ).first() + if not organization: + raise NotFound('Organization not found') + if not can_manage_organization(request.user, organization): + raise PermissionDenied('Forbidden') + + name = str(request.data.get('name') or '').strip() + if not name: + raise ValidationError({'name': 'Name is required.'}) + + agent_type = str(request.data.get('agent_type') or '').strip() + if not agent_type: + raise ValidationError({'agent_type': 'agent_type is required.'}) + + role_uuid = request.data.get('role_uuid') + role = None + if role_uuid: + role = Role.objects.filter(uuid=role_uuid, organization=organization).first() + if not role: + raise NotFound('Role not found in this organization') + + config = AgentConfig.objects.create( + organization=organization, + role=role, + name=name, + agent_type=agent_type, + system_prompt=str(request.data.get('system_prompt') or ''), + llm_config=request.data.get('llm_config') or {}, + tool_permissions=request.data.get('tool_permissions') or [], + ) + + serializer = self.get_serializer(config) + return Response(serializer.data, status=HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + config = self.get_object() + + updatable_fields = { + 'name': request.data.get('name'), + 'agent_type': request.data.get('agent_type'), + 'system_prompt': request.data.get('system_prompt'), + 'llm_config': request.data.get('llm_config'), + 'tool_permissions': request.data.get('tool_permissions'), + } + + for field, value in updatable_fields.items(): + if value is not None: + setattr(config, field, value) + + config.save(update_fields=['name', 'agent_type', 'system_prompt', 'llm_config', 'tool_permissions', 'updated_at']) + serializer = self.get_serializer(config) + return Response(serializer.data, status=HTTP_200_OK) + + def partial_update(self, request, *args, **kwargs): + config = self.get_object() + + if 'name' in request.data: + config.name = request.data.get('name') + if 'agent_type' in request.data: + config.agent_type = request.data.get('agent_type') + if 'system_prompt' in request.data: + config.system_prompt = request.data.get('system_prompt') + if 'llm_config' in request.data: + config.llm_config = request.data.get('llm_config') + if 'tool_permissions' in request.data: + config.tool_permissions = request.data.get('tool_permissions') + + config.save(update_fields=['name', 'agent_type', 'system_prompt', 'llm_config', 'tool_permissions', 'updated_at']) + serializer = self.get_serializer(config) + return Response(serializer.data, status=HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) class OnboardingSessionViewSet(ModelViewSet): queryset = OnboardingSession.objects.all() @@ -92,10 +289,6 @@ class OnboardingSessionViewSet(ModelViewSet): permission_classes = [IsAuthenticated] lookup_field = 'uuid' - filterset_fields = { - 'role__uuid': ['exact'], - } - def get_queryset(self): user = self.request.user if user.is_manager: @@ -103,8 +296,351 @@ class OnboardingSessionViewSet(ModelViewSet): else: queryset = OnboardingSession.objects.filter(user=user) + organization_uuid = self.request.query_params.get('organization_uuid') + if organization_uuid in (None, ''): + organization_uuid = self.request.data.get('organization_uuid') + if organization_uuid: + queryset = queryset.filter(role__organization__uuid=organization_uuid) + + role_uuid = self.request.query_params.get('role_uuid') + if role_uuid in (None, ''): + role_uuid = self.request.data.get('role_uuid') + if role_uuid: + queryset = queryset.filter(role__uuid=role_uuid) + + status_value = self.request.query_params.get('status') + if status_value: + queryset = queryset.filter(status=status_value) + return queryset.order_by('-created_at') + def create(self, request, *args, **kwargs): + return Response( + {'error': 'Use onboarding-flow//start-session/ to create a session.'}, + status=HTTP_400_BAD_REQUEST, + ) + + def _has_attempt(self, value): + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, (list, dict, tuple, set)): + return len(value) > 0 + return True + + def _get_flow_for_session(self, session): + state = session.state or {} + flow_uuid = state.get('flow_uuid') + + flow = None + if flow_uuid: + flow = OnboardingFlow.objects.filter(uuid=flow_uuid, role=session.role).first() + + if not flow: + flow = OnboardingFlow.objects.filter(role=session.role, is_active=True).order_by('-updated_at').first() + + return flow + + def _get_page_from_flow(self, flow, page_uuid): + pages = flow.structure if isinstance(flow.structure, list) else [] + page = next( + ( + item for item in pages + if isinstance(item, dict) and str(item.get('uuid')) == str(page_uuid) + ), + None, + ) + return page, pages + + def _upsert_page_responses(self, session, page_uuid, responses): + state = session.state or {} + stored_responses = state.get('responses', {}) + if not isinstance(stored_responses, dict): + stored_responses = {} + + stored_responses[str(page_uuid)] = responses + state['responses'] = stored_responses + state['last_page_uuid'] = str(page_uuid) + session.state = state + session.save(update_fields=['state', 'updated_at']) + + def _record_page_visit(self, session, page_uuid): + state = session.state or {} + visited_pages = state.get('visited_pages', []) + if not isinstance(visited_pages, list): + visited_pages = [] + + page_uuid_str = str(page_uuid) + if page_uuid_str not in visited_pages: + visited_pages.append(page_uuid_str) + + state['visited_pages'] = visited_pages + state['last_page_uuid'] = page_uuid_str + session.state = state + session.save(update_fields=['state', 'updated_at']) + + def _mark_page_progress(self, session, page_uuid, total_pages, is_final_quiz=False): + if is_final_quiz: + return + + state = session.state or {} + completed_modules = state.get('completed_modules', []) + if not isinstance(completed_modules, list): + completed_modules = [] + + page_uuid_str = str(page_uuid) + if page_uuid_str not in completed_modules: + completed_modules.append(page_uuid_str) + + state['completed_modules'] = completed_modules + completed_count = len(completed_modules) + state['progress_percentage'] = int(round((completed_count / total_pages) * 100)) if total_pages else 0 + + session.state = state + session.save(update_fields=['state', 'updated_at']) + + def _build_system_prompt(self, config): + if not config: + return "You are a helpful onboarding assistant." + base_prompt = config.system_prompt or "You are a helpful onboarding assistant." + permissions = config.tool_permissions or [] + if permissions: + return f"{base_prompt}\n\nAllowed tools: {', '.join(str(p) for p in permissions)}" + return base_prompt + + def _get_knowledge_agent_config(self, session): + role_specific = AgentConfig.objects.filter( + role=session.role, + agent_type='knowledge', + ).order_by('-updated_at').first() + if role_specific: + return role_specific + + return AgentConfig.objects.filter( + organization=session.role.organization, + role__isnull=True, + agent_type='knowledge', + ).order_by('-updated_at').first() + + def _run_ka_help(self, session, page_title, page_body, user_message): + config = self._get_knowledge_agent_config(session) + fallback = ( + "I couldn't reach the knowledge model right now. " + "Please try again, or clarify which part of this module is confusing and I can provide a shorter explanation." + ) + + if not config: + return fallback + + prompt = ( + "Help the learner understand this onboarding page. Keep the explanation concise and practical. " + "Use markdown with bullets when useful.\n\n" + f"Role: {session.role.name}\n" + f"Page Title: {page_title}\n" + f"Page Body (excerpt): {str(page_body)[:2000]}\n" + f"Learner question: {user_message}" + ) + + try: + with httpx.Client(timeout=60.0) as client: + response = client.post( + f"{settings.INFERENCE_URL}/v1/chat/completions", + json={ + "model": (config.llm_config or {}).get("model_id", "meta-llama-3.1-8b"), + "messages": [ + {"role": "system", "content": self._build_system_prompt(config)}, + {"role": "user", "content": prompt}, + ], + }, + ) + response.raise_for_status() + res_json = response.json() + content = res_json.get('choices', [{}])[0].get('message', {}).get('content') + if isinstance(content, str) and content.strip(): + return content.strip() + except Exception: + pass + + return fallback + + def _run_ka_page_revision(self, session, page_title, page_body, user_message): + config = self._get_knowledge_agent_config(session) + if not config: + return None + + prompt = ( + "Revise the onboarding page content by integrating the learner's clarification request directly into the main page text. " + "Use the current page as the source of truth, preserve useful structure, and improve clarity and examples where needed. " + "Do not append a separate 'Clarification' section. Return ONLY the fully revised markdown page body.\n\n" + f"Role: {session.role.name}\n" + f"Page Title: {page_title}\n" + f"Learner clarification request: {user_message}\n\n" + f"Current page markdown:\n{str(page_body)[:12000]}" + ) + + try: + with httpx.Client(timeout=60.0) as client: + response = client.post( + f"{settings.INFERENCE_URL}/v1/chat/completions", + json={ + "model": (config.llm_config or {}).get("model_id", "meta-llama-3.1-8b"), + "messages": [ + {"role": "system", "content": self._build_system_prompt(config)}, + {"role": "user", "content": prompt}, + ], + }, + ) + response.raise_for_status() + res_json = response.json() + content = res_json.get('choices', [{}])[0].get('message', {}).get('content') + revised = str(content or '').strip() + if revised: + return revised + except Exception: + pass + + return None + + def _append_page_help(self, session, page_uuid, user_message, assistant_message): + state = session.state or {} + page_help = state.get('page_help', {}) + if not isinstance(page_help, dict): + page_help = {} + + thread = page_help.get(str(page_uuid), []) + if not isinstance(thread, list): + thread = [] + + thread.append({ + 'question': str(user_message), + 'answer': str(assistant_message), + 'timestamp': timezone.now().isoformat(), + }) + + page_help[str(page_uuid)] = thread[-20:] + state['page_help'] = page_help + session.state = state + session.save(update_fields=['state', 'updated_at']) + + def _update_flow_page_body(self, session, page_uuid, new_body): + flow = self._get_flow_for_session(session) + if not flow: + return False + + structure = flow.structure if isinstance(flow.structure, list) else [] + updated = False + for page in structure: + if not isinstance(page, dict): + continue + if str(page.get('uuid')) != str(page_uuid): + continue + page['body'] = str(new_body) + updated = True + break + + if not updated: + return False + + flow.structure = structure + flow.save(update_fields=['structure', 'updated_at']) + return True + + def _evaluate_final_quiz(self, session): + flow = self._get_flow_for_session(session) + if not flow: + return None, {'error': 'Onboarding flow not found for this session.'} + + pages = flow.structure if isinstance(flow.structure, list) else [] + if not pages: + return None, {'error': 'Onboarding flow has no pages.'} + + quiz_page = None + for page in pages: + if not isinstance(page, dict): + continue + meta = page.get('meta') if isinstance(page.get('meta'), dict) else {} + if str(meta.get('page_type') or '').strip() == 'final_quiz': + quiz_page = page + break + + if quiz_page is None: + quiz_page = pages[-1] if isinstance(pages[-1], dict) else None + + if not isinstance(quiz_page, dict): + return None, {'error': 'Final quiz page could not be identified.'} + + quiz_fields = quiz_page.get('fields') if isinstance(quiz_page.get('fields'), list) else [] + if not quiz_fields: + return None, {'error': 'Final quiz has no questions.'} + + meta = quiz_page.get('meta') if isinstance(quiz_page.get('meta'), dict) else {} + pass_mark = meta.get('pass_mark', 80) + try: + pass_mark = int(pass_mark) + except Exception: + pass_mark = 80 + + state = session.state or {} + responses = state.get('responses', {}) + if not isinstance(responses, dict): + responses = {} + + page_uuid = str(quiz_page.get('uuid') or '') + page_responses = responses.get(page_uuid, {}) + if not isinstance(page_responses, dict): + page_responses = {} + + required_count = 0 + missing_required_keys = [] + gradable_count = 0 + correct_count = 0 + + for field in quiz_fields: + if not isinstance(field, dict): + continue + + key = str(field.get('key') or '').strip() + if not key: + continue + + answer = page_responses.get(key) + attempted = self._has_attempt(answer) + + if bool(field.get('required', False)): + required_count += 1 + if not attempted: + missing_required_keys.append(key) + + validation = field.get('validation') if isinstance(field.get('validation'), dict) else {} + correct_option = validation.get('correct_option') + if correct_option is None: + continue + + gradable_count += 1 + if attempted and str(answer) == str(correct_option): + correct_count += 1 + + score_percentage = int(round((correct_count / gradable_count) * 100)) if gradable_count else 0 + passed = len(missing_required_keys) == 0 and gradable_count > 0 and score_percentage >= pass_mark + + quiz_result = { + 'page_uuid': page_uuid, + 'pass_mark': pass_mark, + 'required_count': required_count, + 'missing_required_keys': missing_required_keys, + 'gradable_count': gradable_count, + 'correct_count': correct_count, + 'score_percentage': score_percentage, + 'passed': passed, + } + + state['final_quiz_result'] = quiz_result + session.state = state + session.save(update_fields=['state', 'updated_at']) + + return quiz_result, None + @action(detail=True, methods=['post'], url_path='interact') def interact(self, request, uuid=None): session = self.get_object() @@ -115,37 +651,113 @@ class OnboardingSessionViewSet(ModelViewSet): if not user_message and not page_uuid: return Response({'error': 'Message or page_uuid is required'}, status=HTTP_400_BAD_REQUEST) + if page_uuid: + self._record_page_visit(session, page_uuid) + if isinstance(responses, dict): - state = session.state or {} - stored_responses = state.get('responses', {}) - if not isinstance(stored_responses, dict): - stored_responses = {} - + flow = self._get_flow_for_session(session) + total_pages = len(flow.structure) if flow and isinstance(flow.structure, list) else 0 if page_uuid: - stored_responses[str(page_uuid)] = responses + self._upsert_page_responses(session, page_uuid, responses) + is_final_quiz = False + if flow: + page, _ = self._get_page_from_flow(flow, page_uuid) + if isinstance(page, dict): + meta = page.get('meta') if isinstance(page.get('meta'), dict) else {} + is_final_quiz = str(meta.get('page_type') or '').strip() == 'final_quiz' + if total_pages: + self._mark_page_progress(session, page_uuid, total_pages, is_final_quiz=is_final_quiz) else: + state = session.state or {} + stored_responses = state.get('responses', {}) + if not isinstance(stored_responses, dict): + stored_responses = {} stored_responses.update(responses) + state['responses'] = stored_responses + session.state = state + session.save(update_fields=['state', 'updated_at']) - state['responses'] = stored_responses - if page_uuid: - state['last_page_uuid'] = str(page_uuid) - session.state = state - session.save(update_fields=['state', 'updated_at']) - - AgentInteractionLog.objects.create( - session=session, - sender_type='user', - content=user_message or f'Submitted onboarding responses for page {page_uuid or "unknown"}', - tool_call_metadata={'page_uuid': page_uuid, 'has_responses': isinstance(responses, dict)} - ) + if user_message or isinstance(responses, dict): + AgentInteractionLog.objects.create( + session=session, + sender_type='user', + content=user_message or f'Submitted onboarding responses for page {page_uuid or "unknown"}', + tool_call_metadata={'page_uuid': page_uuid, 'has_responses': isinstance(responses, dict)} + ) return Response({ 'status': 'received', - 'session_state': session.state + 'session_state': session.state, }) + @action(detail=True, methods=['post'], url_path='ask-ka') + def ask_ka(self, request, uuid=None): + session = self.get_object() + page_uuid = request.data.get('page_uuid') + user_message = request.data.get('message') + mode = request.data.get('mode', 'separate') + + if not page_uuid or not user_message: + return Response({'error': 'page_uuid and message are required.'}, status=HTTP_400_BAD_REQUEST) + + flow = self._get_flow_for_session(session) + if not flow: + return Response({'error': 'Onboarding flow not found.'}, status=HTTP_400_BAD_REQUEST) + + page, _ = self._get_page_from_flow(flow, page_uuid) + if not isinstance(page, dict): + return Response({'error': 'Page not found for this flow.'}, status=HTTP_400_BAD_REQUEST) + + page_title = str(page.get('title') or 'Onboarding Page') + page_body = str(page.get('body') or '') + updated_page = False + assistant_message = '' + if str(mode) == 'update_page': + revised_body = self._run_ka_page_revision(session, page_title, page_body, str(user_message)) + if revised_body: + updated_page = self._update_flow_page_body(session, page_uuid, revised_body) + if updated_page: + assistant_message = ( + "Updated this page by integrating your clarification request into the core content. " + "Please review the revised page text above." + ) + + if not assistant_message: + assistant_message = self._run_ka_help(session, page_title, page_body, str(user_message)) + + self._append_page_help(session, page_uuid, user_message, assistant_message) + + AgentInteractionLog.objects.create( + session=session, + sender_type='user', + content=str(user_message), + tool_call_metadata={ + 'action': 'ask_ka', + 'page_uuid': str(page_uuid), + 'mode': str(mode), + }, + ) + AgentInteractionLog.objects.create( + session=session, + sender_type='ai', + content=str(assistant_message), + tool_call_metadata={ + 'action': 'ask_ka_response', + 'page_uuid': str(page_uuid), + 'mode': str(mode), + 'updated_page': updated_page, + }, + ) + + return Response({ + 'status': 'ok', + 'answer': assistant_message, + 'updated_page': updated_page, + 'session_state': session.state, + }, status=HTTP_200_OK) + @action(detail=True, methods=['get'], url_path='history') def history(self, request, uuid=None): session = self.get_object() @@ -156,10 +768,33 @@ class OnboardingSessionViewSet(ModelViewSet): @action(detail=True, methods=['post'], url_path='complete') def complete(self, request, uuid=None): session = self.get_object() + + quiz_result, quiz_error = self._evaluate_final_quiz(session) + if quiz_error: + return Response(quiz_error, status=HTTP_400_BAD_REQUEST) + + if not quiz_result.get('passed'): + return Response({ + 'error': 'Final quiz pass mark not met.', + 'quiz_result': quiz_result, + }, status=HTTP_400_BAD_REQUEST) + + state = session.state or {} + completed_modules = state.get('completed_modules', []) + if not isinstance(completed_modules, list): + completed_modules = [] + if quiz_result.get('page_uuid') and quiz_result['page_uuid'] not in completed_modules: + completed_modules.append(quiz_result['page_uuid']) + state['completed_modules'] = completed_modules + flow = self._get_flow_for_session(session) + total_pages = len(flow.structure) if flow and isinstance(flow.structure, list) else 0 + state['progress_percentage'] = int(round((len(completed_modules) / total_pages) * 100)) if total_pages else 100 + session.state = state + session.status = 'completed' session.completed_at = timezone.now() - session.save() - return Response({'message': 'Session marked as completed'}) + session.save(update_fields=['status', 'completed_at', 'state', 'updated_at']) + return Response({'message': 'Session marked as completed', 'quiz_result': quiz_result}) class AgentInteractionLogViewSet(ReadOnlyModelViewSet): queryset = AgentInteractionLog.objects.all() @@ -167,13 +802,34 @@ class AgentInteractionLogViewSet(ReadOnlyModelViewSet): permission_classes = [IsAuthenticated] lookup_field = 'uuid' - filterset_fields = { - 'session__uuid': ['exact'], - 'session__role__uuid': ['exact'], - } - def get_queryset(self): - return AgentInteractionLog.objects.filter( - Q(session__user=self.request.user) | - Q(session__role__organization__owner=self.request.user) + user = self.request.user + manager_scope = Q() + if user.is_manager: + manager_scope = Q(session__role__organization__members=user) + + queryset = AgentInteractionLog.objects.filter( + Q(session__user=user) | + Q(session__role__organization__owner=user) | + manager_scope ).distinct() + + session_uuid = self.request.query_params.get('session_uuid') + if session_uuid in (None, ''): + session_uuid = self.request.data.get('session_uuid') + if session_uuid: + queryset = queryset.filter(session__uuid=session_uuid) + + role_uuid = self.request.query_params.get('role_uuid') + if role_uuid in (None, ''): + role_uuid = self.request.data.get('role_uuid') + if role_uuid: + queryset = queryset.filter(session__role__uuid=role_uuid) + + organization_uuid = self.request.query_params.get('organization_uuid') + if organization_uuid in (None, ''): + organization_uuid = self.request.data.get('organization_uuid') + if organization_uuid: + queryset = queryset.filter(session__role__organization__uuid=organization_uuid) + + return queryset.order_by('created_at')