import logging from django.db.models import Q from rest_framework import status from rest_framework.views import APIView from rest_framework.permissions import AllowAny from rest_framework.response import Response from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.types import OpenApiTypes from apps.profiles.models import Institution from apps.projects.models import Project from apps.lms.models import Course from apps.cms.models import Post from apps.events.models import Event from apps.search.serializers import UnifiedSearchResponseSerializer logger = logging.getLogger(__name__) class UnifiedSearchView(APIView): """ Unified multi-entity search endpoint that powers global search, advanced search modals, and descriptive natural-language search bots. """ permission_classes = [AllowAny] serializer_class = UnifiedSearchResponseSerializer @extend_schema( summary="Unified Multi-Entity Search", description="Search simultaneously across institutions, collaborative projects, LMS academy courses, CMS articles/news, and international events with relevance ranking and facet filtering.", parameters=[ OpenApiParameter( name='q', type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Search query string / keywords (Persian, Arabic, English, Urdu, etc.)" ), OpenApiParameter( name='type', type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=False, description="Comma-separated entity types to include: institution, project, course, post, event" ), OpenApiParameter( name='limit', type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Maximum number of search results to return (default: 20, max: 100)" ), OpenApiParameter( name='offset', type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Pagination offset (default: 0)" ), ], responses={200: UnifiedSearchResponseSerializer}, tags=["Unified Search"], ) def get(self, request, *args, **kwargs): query = (request.query_params.get('q') or '').strip() type_param = request.query_params.get('type') try: limit = min(int(request.query_params.get('limit', 20)), 100) if limit <= 0: limit = 20 except (ValueError, TypeError): limit = 20 try: offset = max(int(request.query_params.get('offset', 0)), 0) except (ValueError, TypeError): offset = 0 allowed_types = {'institution', 'project', 'course', 'post', 'event'} if type_param: requested_types = {t.strip().lower() for t in type_param.split(',') if t.strip().lower() in allowed_types} if not requested_types: requested_types = allowed_types else: requested_types = allowed_types all_results = [] counts = { 'institutions': 0, 'projects': 0, 'courses': 0, 'posts': 0, 'events': 0, } # 1. Search Institutions if 'institution' in requested_types: inst_qs = Institution.objects.all() if query: inst_qs = inst_qs.filter( Q(name__icontains=query) | Q(city__icontains=query) | Q(country__icontains=query) | Q(description__icontains=query) | Q(tags__icontains=query) ).distinct() counts['institutions'] = inst_qs.count() for inst in inst_qs: score = 1.0 if query: q_lower = query.lower() if q_lower in inst.name.lower(): score += 2.0 if inst.city and q_lower in inst.city.lower(): score += 1.0 if inst.country and q_lower in inst.country.lower(): score += 1.0 image_url = None if inst.avatar: try: image_url = request.build_absolute_uri(inst.avatar.url) except Exception: image_url = str(inst.avatar) all_results.append({ 'id': str(inst.id), 'type': 'institution', 'title': inst.name, 'description': (inst.description or '')[:200], 'url_path': f"/institutions/{inst.slug or inst.id}", 'image': image_url, 'badge': f"{inst.country} • {inst.get_type_display() if hasattr(inst, 'get_type_display') else inst.type}", 'metadata': { 'country': inst.country, 'city': inst.city, 'type': inst.type, 'slug': inst.slug, 'verification_status': inst.verification_status, }, 'relevance_score': score, }) # 2. Search Projects if 'project' in requested_types: proj_qs = Project.objects.all() if query: proj_qs = proj_qs.filter( Q(title__icontains=query) | Q(description__icontains=query) | Q(category__icontains=query) ).distinct() counts['projects'] = proj_qs.count() for proj in proj_qs: score = 1.0 if query: q_lower = query.lower() if q_lower in proj.title.lower(): score += 2.0 if proj.category and q_lower in proj.category.lower(): score += 1.0 all_results.append({ 'id': str(proj.id), 'type': 'project', 'title': proj.title, 'description': (proj.description or '')[:200], 'url_path': f"/projects/{proj.slug or proj.id}", 'image': None, 'badge': f"{proj.category or 'Project'} • {proj.get_status_display() if hasattr(proj, 'get_status_display') else proj.status}", 'metadata': { 'category': proj.category, 'status': proj.status, 'slug': proj.slug, 'owner': proj.owner_institution.name if proj.owner_institution else None, 'progress': proj.progress_percentage, }, 'relevance_score': score, }) # 3. Search LMS Courses if 'course' in requested_types: course_qs = Course.objects.filter(is_published=True) if query: course_qs = course_qs.filter( Q(title__icontains=query) | Q(description__icontains=query) | Q(instructor_name__icontains=query) | Q(organization_name__icontains=query) ).distinct() counts['courses'] = course_qs.count() for course in course_qs: score = 1.0 if query: q_lower = query.lower() if q_lower in course.title.lower(): score += 2.0 if course.instructor_name and q_lower in course.instructor_name.lower(): score += 1.0 image_url = None if course.cover_image: try: image_url = request.build_absolute_uri(course.cover_image.url) except Exception: image_url = str(course.cover_image) all_results.append({ 'id': str(course.id), 'type': 'course', 'title': course.title, 'description': (course.description or '')[:200], 'url_path': f"/lms/courses/{course.slug or course.id}", 'image': image_url, 'badge': f"{course.get_category_display() if hasattr(course, 'get_category_display') else course.category} • {course.get_level_display() if hasattr(course, 'get_level_display') else course.level}", 'metadata': { 'category': course.category, 'instructor': course.instructor_name, 'level': course.level, 'language': course.language, 'slug': course.slug, }, 'relevance_score': score, }) # 4. Search CMS Posts if 'post' in requested_types: post_qs = Post.objects.filter(status=Post.Status.PUBLISHED) if query: post_qs = post_qs.filter( Q(title__icontains=query) | Q(excerpt__icontains=query) | Q(content__icontains=query) ).distinct() counts['posts'] = post_qs.count() for post in post_qs: score = 1.0 if query: q_lower = query.lower() if q_lower in post.title.lower(): score += 2.0 image_url = None if post.featured_image: try: image_url = request.build_absolute_uri(post.featured_image.url) except Exception: image_url = str(post.featured_image) all_results.append({ 'id': str(post.id), 'type': 'post', 'title': post.title, 'description': (post.excerpt or post.content or '')[:200], 'url_path': f"/cms/posts/{post.slug or post.id}", 'image': image_url, 'badge': f"{post.get_post_type_display() if hasattr(post, 'get_post_type_display') else post.post_type} • {post.language.upper()}", 'metadata': { 'post_type': post.post_type, 'language': post.language, 'category': post.category.name if post.category else None, 'slug': post.slug, }, 'relevance_score': score, }) # 5. Search Events if 'event' in requested_types: event_qs = Event.objects.filter(is_active=True) if query: event_qs = event_qs.filter( Q(title__icontains=query) | Q(description__icontains=query) | Q(speaker_name__icontains=query) | Q(venue_address__icontains=query) ).distinct() counts['events'] = event_qs.count() for event in event_qs: score = 1.0 if query: q_lower = query.lower() if q_lower in event.title.lower(): score += 2.0 if event.speaker_name and q_lower in event.speaker_name.lower(): score += 1.0 image_url = None if event.cover_image: try: image_url = request.build_absolute_uri(event.cover_image.url) except Exception: image_url = str(event.cover_image) all_results.append({ 'id': str(event.id), 'type': 'event', 'title': event.title, 'description': (event.description or '')[:200], 'url_path': f"/events/{event.slug or event.id}", 'image': image_url, 'badge': f"{event.get_category_display() if hasattr(event, 'get_category_display') else event.category} • {event.event_date}", 'metadata': { 'category': event.category, 'speaker': event.speaker_name, 'date': str(event.event_date), 'location_type': event.location_type, 'slug': event.slug, }, 'relevance_score': score, }) # Sort all results by relevance score descending all_results.sort(key=lambda x: x['relevance_score'], reverse=True) total_results = len(all_results) paginated_results = all_results[offset:offset + limit] response_data = { 'query': query, 'total_results': total_results, 'by_type': counts, 'results': paginated_results, } return Response(response_data, status=status.HTTP_200_OK)