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
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)
|