You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

331 lines
13 KiB

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)