35 changed files with 862 additions and 121 deletions
-
47apps/account/views/auth.py
-
2apps/account/views/notification.py
-
92apps/account/views/user.py
-
2apps/api/views/api_views.py
-
3apps/search/__init__.py
-
8apps/search/apps.py
-
40apps/search/serializers.py
-
3apps/search/tests/__init__.py
-
215apps/search/tests/test_phase10_search.py
-
6apps/search/urls.py
-
331apps/search/views.py
-
4config/settings/base.py
-
3config/urls.py
-
1requirements.txt
-
200schema.yml
-
1test_media/chat/attachments/2026/09/agenda_schedule_2DCh1tj.pdf
-
1test_media/chat/attachments/2026/09/agenda_schedule_AETwwrY.pdf
-
1test_media/chat/attachments/2026/09/agenda_schedule_MQVAfYP.pdf
-
1test_media/chat/attachments/2026/09/agenda_schedule_pfp2CQf.pdf
-
1test_media/chat/attachments/2026/09/agenda_schedule_vnJEK2e.pdf
-
1test_media/institutions/gallery/2026/09/center_hall_3NtVzPY.jpg
-
1test_media/institutions/gallery/2026/09/center_hall_MqRDw4T.jpg
-
1test_media/institutions/gallery/2026/09/center_hall_Nxv03Hd.jpg
-
1test_media/institutions/gallery/2026/09/center_hall_ar9CE0s.jpg
-
1test_media/institutions/gallery/2026/09/center_hall_wuXjM4R.jpg
-
1test_media/projects/docs/2026/09/bilateral_mou_2026_dUrbd5U.pdf
-
1test_media/projects/docs/2026/09/bilateral_mou_2026_efo4psz.pdf
-
1test_media/projects/docs/2026/09/bilateral_mou_2026_hS37xMJ.pdf
-
1test_media/projects/docs/2026/09/bilateral_mou_2026_wDZI9eJ.pdf
-
1test_media/projects/docs/2026/09/bilateral_mou_2026_wcGgoNC.pdf
-
1test_media/users/documents/2026/09/institution_license_AxW4dDo.pdf
-
1test_media/users/documents/2026/09/institution_license_DD1y3j9.pdf
-
1test_media/users/documents/2026/09/institution_license_Sn9iuxZ.pdf
-
1test_media/users/documents/2026/09/institution_license_i70KOoM.pdf
-
1test_media/users/documents/2026/09/institution_license_uH89LoM.pdf
@ -2,8 +2,7 @@ import logging |
|||
|
|||
from django.contrib.auth import get_user_model |
|||
|
|||
from drf_yasg import openapi |
|||
from drf_yasg.utils import swagger_auto_schema |
|||
from drf_spectacular.utils import extend_schema, OpenApiResponse |
|||
from rest_framework import status |
|||
from rest_framework.authtoken.models import Token |
|||
from rest_framework.generics import GenericAPIView |
|||
@ -42,44 +41,14 @@ class ExchangeTokenAPIView(GenericAPIView): |
|||
permission_classes = [AllowAny] |
|||
serializer_class = ExchangeTokenSerializer |
|||
|
|||
@swagger_auto_schema( |
|||
operation_description="Exchange temporary token for user information and authentication token.", |
|||
request_body=ExchangeTokenSerializer, |
|||
@extend_schema( |
|||
summary="Exchange temporary token for user authentication", |
|||
description="Exchange a one-time temporary token (issued by mobile app) for a real auth token and user info.", |
|||
request=ExchangeTokenSerializer, |
|||
responses={ |
|||
status.HTTP_200_OK: openapi.Response( |
|||
description="Token exchanged successfully.", |
|||
examples={ |
|||
"application/json": { |
|||
"success": True, |
|||
"message": "ورود موفق", |
|||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", |
|||
"user": { |
|||
"id": 123, |
|||
"fullname": "علی احمدی", |
|||
"email": "[email protected]", |
|||
"avatar": "https://cdn.example.com/avatar.jpg" |
|||
} |
|||
} |
|||
} |
|||
), |
|||
status.HTTP_400_BAD_REQUEST: openapi.Response( |
|||
description="Invalid request.", |
|||
examples={ |
|||
"application/json": { |
|||
"success": False, |
|||
"message": "توکن ارسال نشده است" |
|||
} |
|||
} |
|||
), |
|||
status.HTTP_404_NOT_FOUND: openapi.Response( |
|||
description="Token not found or expired.", |
|||
examples={ |
|||
"application/json": { |
|||
"success": False, |
|||
"message": "توکن نامعتبر یا منقضی شده است" |
|||
} |
|||
} |
|||
), |
|||
200: OpenApiResponse(description="Token exchanged successfully. Returns auth token and user details."), |
|||
400: OpenApiResponse(description="Invalid request — token not provided or malformed."), |
|||
404: OpenApiResponse(description="Token not found, expired, or user does not exist."), |
|||
} |
|||
) |
|||
def post(self, request, *args, **kwargs): |
|||
|
|||
@ -0,0 +1,3 @@ |
|||
""" |
|||
Unified Search Application |
|||
""" |
|||
@ -0,0 +1,8 @@ |
|||
from django.apps import AppConfig |
|||
from django.utils.translation import gettext_lazy as _ |
|||
|
|||
|
|||
class SearchConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.search' |
|||
verbose_name = _('Unified Search') |
|||
@ -0,0 +1,40 @@ |
|||
from django.db import models |
|||
from rest_framework import serializers |
|||
|
|||
|
|||
class SearchEntityType(models.TextChoices): |
|||
INSTITUTION = 'institution', 'Institution' |
|||
PROJECT = 'project', 'Project' |
|||
COURSE = 'course', 'Course' |
|||
POST = 'post', 'Post' |
|||
EVENT = 'event', 'Event' |
|||
|
|||
|
|||
class SearchResultItemSerializer(serializers.Serializer): |
|||
id = serializers.CharField(help_text="Entity unique identifier") |
|||
type = serializers.ChoiceField( |
|||
choices=SearchEntityType.choices, |
|||
help_text="Type of the entity found" |
|||
) |
|||
title = serializers.CharField(help_text="Title or primary name") |
|||
description = serializers.CharField(allow_blank=True, required=False, help_text="Summary or excerpt") |
|||
url_path = serializers.CharField(help_text="Frontend navigation route") |
|||
image = serializers.CharField(allow_null=True, required=False, help_text="Cover or avatar image URL") |
|||
badge = serializers.CharField(allow_blank=True, required=False, help_text="Contextual badge / label") |
|||
metadata = serializers.DictField(required=False, help_text="Additional metadata attributes") |
|||
relevance_score = serializers.FloatField(required=False, default=1.0, help_text="Relevance ranking score") |
|||
|
|||
|
|||
class SearchTypeCountSerializer(serializers.Serializer): |
|||
institutions = serializers.IntegerField(default=0) |
|||
projects = serializers.IntegerField(default=0) |
|||
courses = serializers.IntegerField(default=0) |
|||
posts = serializers.IntegerField(default=0) |
|||
events = serializers.IntegerField(default=0) |
|||
|
|||
|
|||
class UnifiedSearchResponseSerializer(serializers.Serializer): |
|||
query = serializers.CharField(help_text="Original search query string") |
|||
total_results = serializers.IntegerField(help_text="Total number of matched records across categories") |
|||
by_type = SearchTypeCountSerializer(help_text="Breakdown count of matched records by entity type") |
|||
results = SearchResultItemSerializer(many=True, help_text="List of matched search results ordered by relevance") |
|||
@ -0,0 +1,3 @@ |
|||
""" |
|||
Search app tests package |
|||
""" |
|||
@ -0,0 +1,215 @@ |
|||
import datetime |
|||
from django.test import TestCase |
|||
from django.contrib.auth import get_user_model |
|||
from rest_framework.test import APIClient |
|||
from rest_framework import status |
|||
|
|||
from apps.profiles.models import Institution |
|||
from apps.projects.models import Project |
|||
from apps.lms.models import Course, CourseCategory, CourseLevel, CourseLanguage |
|||
from apps.cms.models import Post, PostCategory |
|||
from apps.events.models import Event, EventCategory, LocationType |
|||
|
|||
User = get_user_model() |
|||
|
|||
|
|||
class Phase10UnifiedSearchAPITests(TestCase): |
|||
""" |
|||
Automated test suite for Phase 10: Unified Multi-Entity Search & Aggregator Engine |
|||
- Multi-entity natural search across Institutions, Projects, LMS Courses, CMS Posts, and Events |
|||
- Type filtering (?type=institution,project,course,post,event) |
|||
- Multilingual keywords (Persian, Arabic, English) |
|||
- Relevance sorting |
|||
- Visibility rules (Draft posts, unpublished courses, inactive events excluded) |
|||
- Pagination (limit, offset) |
|||
""" |
|||
|
|||
def setUp(self): |
|||
self.client = APIClient() |
|||
|
|||
self.user = User.objects.create_user( |
|||
email="[email protected]", |
|||
password="Password123!", |
|||
fullname="Search Test Author", |
|||
) |
|||
|
|||
# 1. Institutions |
|||
self.inst_tehran = Institution.objects.create( |
|||
name="Tehran Islamic Knowledge Center", |
|||
country="Iran", |
|||
city="Tehran", |
|||
type=Institution.InstitutionType.INSTITUTE, |
|||
description="Leading center for Islamic diplomacy and research.", |
|||
tags=["diplomacy", "research", "tehran"], |
|||
verification_status=Institution.VerificationStatus.APPROVED, |
|||
) |
|||
self.inst_london = Institution.objects.create( |
|||
name="London Ahlulbayt Mosque", |
|||
country="United Kingdom", |
|||
city="London", |
|||
type=Institution.InstitutionType.MOSQUE, |
|||
description="Community mosque and interfaith dialogue hub.", |
|||
tags=["interfaith", "london", "community"], |
|||
verification_status=Institution.VerificationStatus.APPROVED, |
|||
) |
|||
|
|||
# 2. Collaborative Projects |
|||
self.proj_manuscript = Project.objects.create( |
|||
title="Islamic Manuscript Digital Preservation", |
|||
owner_institution=self.inst_tehran, |
|||
category="Cultural Diplomacy", |
|||
description="Digitizing ancient Islamic texts and theological manuscripts.", |
|||
status=Project.Status.ACTIVE, |
|||
progress_percentage=45, |
|||
) |
|||
self.proj_youth = Project.objects.create( |
|||
title="European Muslim Youth Leadership Initiative", |
|||
owner_institution=self.inst_london, |
|||
category="Youth Empowerment", |
|||
description="Empowering young community leaders across Europe.", |
|||
status=Project.Status.ACTIVE, |
|||
progress_percentage=60, |
|||
) |
|||
|
|||
# 3. LMS Courses (1 published, 1 unpublished draft) |
|||
self.course_diplomacy = Course.objects.create( |
|||
title="Islamic Cultural Diplomacy & Negotiation", |
|||
category=CourseCategory.CULTURAL_DIPLOMACY, |
|||
language=CourseLanguage.EN, |
|||
level=CourseLevel.INTERMEDIATE, |
|||
instructor_name="Dr. Mohammad Razavi", |
|||
organization_name="Imam Reza International University", |
|||
description="Advanced skills for international dialogue and cultural diplomacy.", |
|||
is_published=True, |
|||
) |
|||
self.course_draft = Course.objects.create( |
|||
title="Secret Draft Course on Diplomacy", |
|||
category=CourseCategory.RAZAVI_TEACHINGS, |
|||
language=CourseLanguage.EN, |
|||
level=CourseLevel.INTRODUCTORY, |
|||
instructor_name="Dr. Hidden", |
|||
description="Not yet published to students.", |
|||
is_published=False, |
|||
) |
|||
|
|||
# 4. CMS Posts (1 published, 1 draft) |
|||
self.cat_news = PostCategory.objects.create(name="International News", language="en") |
|||
self.post_published = Post.objects.create( |
|||
title="Imam Reza Shrine Hosts International Delegation from London", |
|||
category=self.cat_news, |
|||
language="en", |
|||
status=Post.Status.PUBLISHED, |
|||
author=self.user, |
|||
institution=self.inst_tehran, |
|||
excerpt="High-level delegation explores new avenues for educational partnerships.", |
|||
content="Representatives from prominent institutions visited the sacred shrine in Mashhad.", |
|||
) |
|||
self.post_draft = Post.objects.create( |
|||
title="Draft Article on Future Diplomacy Summit", |
|||
category=self.cat_news, |
|||
language="en", |
|||
status=Post.Status.DRAFT, |
|||
author=self.user, |
|||
excerpt="Unpublished internal draft regarding summits.", |
|||
content="Draft notes.", |
|||
) |
|||
|
|||
# 5. Events (1 active, 1 inactive) |
|||
self.event_active = Event.objects.create( |
|||
title="Global Interfaith Dialogue Conference 2026", |
|||
description="A comprehensive conference discussing interfaith diplomacy and coexistence.", |
|||
organizer=self.inst_london, |
|||
created_by=self.user, |
|||
category=EventCategory.CONFERENCE, |
|||
event_date=datetime.date(2026, 10, 15), |
|||
start_time=datetime.time(10, 0), |
|||
speaker_name="Sheikh Jawad Londoni", |
|||
location_type=LocationType.HYBRID, |
|||
venue_address="Ahlulbayt Cultural Center, London", |
|||
is_active=True, |
|||
) |
|||
self.event_inactive = Event.objects.create( |
|||
title="Cancelled Dialogue Workshop", |
|||
description="This event was cancelled.", |
|||
organizer=self.inst_tehran, |
|||
created_by=self.user, |
|||
category=EventCategory.WORKSHOP, |
|||
event_date=datetime.date(2026, 9, 1), |
|||
start_time=datetime.time(14, 0), |
|||
is_active=False, |
|||
) |
|||
|
|||
def test_multi_entity_search_keyword_diplomacy(self): |
|||
"""Querying 'diplomacy' should find institutions, projects, courses, posts, and events.""" |
|||
response = self.client.get("/api/v1/search/?q=diplomacy") |
|||
self.assertEqual(response.status_code, status.HTTP_200_OK) |
|||
data = response.data |
|||
|
|||
self.assertEqual(data["query"], "diplomacy") |
|||
self.assertGreater(data["total_results"], 0) |
|||
|
|||
# Check by_type aggregation breakdown |
|||
self.assertIn("institutions", data["by_type"]) |
|||
self.assertIn("projects", data["by_type"]) |
|||
self.assertIn("courses", data["by_type"]) |
|||
self.assertIn("posts", data["by_type"]) |
|||
self.assertIn("events", data["by_type"]) |
|||
|
|||
# Check returned results contain correct structure |
|||
for item in data["results"]: |
|||
self.assertIn("id", item) |
|||
self.assertIn("type", item) |
|||
self.assertIn("title", item) |
|||
self.assertIn("url_path", item) |
|||
self.assertIn("relevance_score", item) |
|||
|
|||
def test_type_filter_institutions_only(self): |
|||
"""Filtering by ?type=institution should only return institution matches.""" |
|||
response = self.client.get("/api/v1/search/?q=London&type=institution") |
|||
self.assertEqual(response.status_code, status.HTTP_200_OK) |
|||
data = response.data |
|||
|
|||
self.assertEqual(data["total_results"], 1) |
|||
self.assertEqual(data["results"][0]["type"], "institution") |
|||
self.assertEqual(data["results"][0]["title"], "London Ahlulbayt Mosque") |
|||
|
|||
def test_type_filter_multiple_types(self): |
|||
"""Filtering by ?type=course,event should only return course and event matches.""" |
|||
response = self.client.get("/api/v1/search/?q=Dialogue&type=course,event") |
|||
self.assertEqual(response.status_code, status.HTTP_200_OK) |
|||
data = response.data |
|||
|
|||
types_found = {item["type"] for item in data["results"]} |
|||
self.assertTrue(types_found.issubset({"course", "event"})) |
|||
|
|||
def test_visibility_rules_draft_and_inactive_excluded(self): |
|||
"""Draft courses, draft posts, and inactive events should never be returned.""" |
|||
response = self.client.get("/api/v1/search/?q=Draft") |
|||
self.assertEqual(response.status_code, status.HTTP_200_OK) |
|||
data = response.data |
|||
|
|||
titles_found = [item["title"] for item in data["results"]] |
|||
self.assertNotIn("Secret Draft Course on Diplomacy", titles_found) |
|||
self.assertNotIn("Draft Article on Future Diplomacy Summit", titles_found) |
|||
|
|||
response_cancelled = self.client.get("/api/v1/search/?q=Cancelled") |
|||
titles_cancelled = [item["title"] for item in response_cancelled.data["results"]] |
|||
self.assertNotIn("Cancelled Dialogue Workshop", titles_cancelled) |
|||
|
|||
def test_pagination_limit_and_offset(self): |
|||
"""Test limit and offset pagination parameters.""" |
|||
response_all = self.client.get("/api/v1/search/?limit=100") |
|||
total = response_all.data["total_results"] |
|||
|
|||
response_limit = self.client.get("/api/v1/search/?limit=2&offset=0") |
|||
self.assertEqual(len(response_limit.data["results"]), 2) |
|||
self.assertEqual(response_limit.data["total_results"], total) |
|||
|
|||
response_offset = self.client.get("/api/v1/search/?limit=2&offset=2") |
|||
self.assertEqual(len(response_offset.data["results"]), min(2, max(0, total - 2))) |
|||
|
|||
def test_empty_query_returns_all_active_entities(self): |
|||
"""Empty query returns active entities up to limit.""" |
|||
response = self.client.get("/api/v1/search/") |
|||
self.assertEqual(response.status_code, status.HTTP_200_OK) |
|||
self.assertGreater(response.data["total_results"], 0) |
|||
@ -0,0 +1,6 @@ |
|||
from django.urls import path |
|||
from apps.search.views import UnifiedSearchView |
|||
|
|||
urlpatterns = [ |
|||
path('', UnifiedSearchView.as_view(), name='unified_search'), |
|||
] |
|||
@ -0,0 +1,331 @@ |
|||
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) |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Schedule |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Schedule |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Schedule |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Schedule |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Schedule |
|||
@ -0,0 +1 @@ |
|||
mock_image_bytes |
|||
@ -0,0 +1 @@ |
|||
mock_image_bytes |
|||
@ -0,0 +1 @@ |
|||
mock_image_bytes |
|||
@ -0,0 +1 @@ |
|||
mock_image_bytes |
|||
@ -0,0 +1 @@ |
|||
mock_image_bytes |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Bilateral MOU |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Bilateral MOU |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Bilateral MOU |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Bilateral MOU |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Bilateral MOU |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock License Content |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock License Content |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock License Content |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock License Content |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock License Content |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue