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
-
202schema.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
@ -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="author.search@astan.org", |
||||
|
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