Browse Source

feat: introduce search app and add new account and api views

main
sina_sajjadi 3 weeks ago
parent
commit
b492d86cdf
  1. 47
      apps/account/views/auth.py
  2. 2
      apps/account/views/notification.py
  3. 96
      apps/account/views/user.py
  4. 2
      apps/api/views/api_views.py
  5. 3
      apps/search/__init__.py
  6. 8
      apps/search/apps.py
  7. 40
      apps/search/serializers.py
  8. 3
      apps/search/tests/__init__.py
  9. 215
      apps/search/tests/test_phase10_search.py
  10. 6
      apps/search/urls.py
  11. 331
      apps/search/views.py
  12. 4
      config/settings/base.py
  13. 3
      config/urls.py
  14. 1
      requirements.txt
  15. 202
      schema.yml
  16. 1
      test_media/chat/attachments/2026/09/agenda_schedule_2DCh1tj.pdf
  17. 1
      test_media/chat/attachments/2026/09/agenda_schedule_AETwwrY.pdf
  18. 1
      test_media/chat/attachments/2026/09/agenda_schedule_MQVAfYP.pdf
  19. 1
      test_media/chat/attachments/2026/09/agenda_schedule_pfp2CQf.pdf
  20. 1
      test_media/chat/attachments/2026/09/agenda_schedule_vnJEK2e.pdf
  21. 1
      test_media/institutions/gallery/2026/09/center_hall_3NtVzPY.jpg
  22. 1
      test_media/institutions/gallery/2026/09/center_hall_MqRDw4T.jpg
  23. 1
      test_media/institutions/gallery/2026/09/center_hall_Nxv03Hd.jpg
  24. 1
      test_media/institutions/gallery/2026/09/center_hall_ar9CE0s.jpg
  25. 1
      test_media/institutions/gallery/2026/09/center_hall_wuXjM4R.jpg
  26. 1
      test_media/projects/docs/2026/09/bilateral_mou_2026_dUrbd5U.pdf
  27. 1
      test_media/projects/docs/2026/09/bilateral_mou_2026_efo4psz.pdf
  28. 1
      test_media/projects/docs/2026/09/bilateral_mou_2026_hS37xMJ.pdf
  29. 1
      test_media/projects/docs/2026/09/bilateral_mou_2026_wDZI9eJ.pdf
  30. 1
      test_media/projects/docs/2026/09/bilateral_mou_2026_wcGgoNC.pdf
  31. 1
      test_media/users/documents/2026/09/institution_license_AxW4dDo.pdf
  32. 1
      test_media/users/documents/2026/09/institution_license_DD1y3j9.pdf
  33. 1
      test_media/users/documents/2026/09/institution_license_Sn9iuxZ.pdf
  34. 1
      test_media/users/documents/2026/09/institution_license_i70KOoM.pdf
  35. 1
      test_media/users/documents/2026/09/institution_license_uH89LoM.pdf

47
apps/account/views/auth.py

@ -2,8 +2,7 @@ import logging
from django.contrib.auth import get_user_model 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 import status
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
@ -42,44 +41,14 @@ class ExchangeTokenAPIView(GenericAPIView):
permission_classes = [AllowAny] permission_classes = [AllowAny]
serializer_class = ExchangeTokenSerializer 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={ 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): def post(self, request, *args, **kwargs):

2
apps/account/views/notification.py

@ -1,8 +1,6 @@
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet

96
apps/account/views/user.py

@ -17,8 +17,6 @@ from django.utils import timezone
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from phonenumbers import parse, region_code_for_number from phonenumbers import parse, region_code_for_number
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiResponse from drf_spectacular.utils import extend_schema, OpenApiResponse
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@ -56,20 +54,11 @@ class UserGuestView(CreateAPIView):
authentication_classes = [] authentication_classes = []
serializer_class = UserGuestSerializer serializer_class = UserGuestSerializer
@swagger_auto_schema(
operation_description="Create a guest user account with device information",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"device_id": openapi.Schema(type=openapi.TYPE_STRING, default="c9f0c1f4f5cee3d7"),
"fcm": openapi.Schema(type=openapi.TYPE_STRING, default=""),
"device_os": openapi.Schema(type=openapi.TYPE_STRING, default="android"),
"lat": openapi.Schema(type=openapi.TYPE_STRING, default="56"),
"lon": openapi.Schema(type=openapi.TYPE_STRING, default="44"),
"timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"),
},
required=["device_id"],
),
@extend_schema(
summary="Create guest user account",
description="Create a guest user account with device information.",
request=UserGuestSerializer,
responses={200: OpenApiResponse(description="Returns auth token for the guest user.")},
) )
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
logger.info(f'GuestAuthView--> {request.data}') logger.info(f'GuestAuthView--> {request.data}')
@ -151,16 +140,11 @@ class WebUserGuestView(CreateAPIView):
authentication_classes = [] authentication_classes = []
serializer_class = WebUserGuestSerializer serializer_class = WebUserGuestSerializer
@swagger_auto_schema(
operation_description="Create a guest user account for web users using IP and user agent",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"),
"user_agent": openapi.Schema(type=openapi.TYPE_STRING, default="Mozilla/5.0..."),
},
required=[], # No required fields - we'll extract from request
),
@extend_schema(
summary="Create web guest user account",
description="Create a guest user account for web users using IP and user agent.",
request=WebUserGuestSerializer,
responses={200: OpenApiResponse(description="Returns auth token for the guest user.")},
) )
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
logger.info(f'WebGuestAuthView--> IP: {self.get_client_ip()}, User-Agent: {self.get_user_agent()}') logger.info(f'WebGuestAuthView--> IP: {self.get_client_ip()}, User-Agent: {self.get_user_agent()}')
@ -252,9 +236,11 @@ class UserRegisterView(CreateAPIView):
serializer_class = UserRegisterSerializer serializer_class = UserRegisterSerializer
@swagger_auto_schema(
operation_description=doc_register(),
request_body=UserRegisterSerializer,
@extend_schema(
summary="Register new user",
description=doc_register(),
request=UserRegisterSerializer,
responses={202: OpenApiResponse(description="OTP code sent to user email.")},
) )
def post(self, request): def post(self, request):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
@ -284,9 +270,11 @@ class UserVerifyView(CreateAPIView):
authentication_classes = [] authentication_classes = []
serializer_class = UserVerifySerializer serializer_class = UserVerifySerializer
@swagger_auto_schema(
operation_description=doc_verify(),
request_body=UserVerifySerializer,
@extend_schema(
summary="Verify OTP and activate user",
description=doc_verify(),
request=UserVerifySerializer,
responses={201: OpenApiResponse(description="Returns token and user info.")},
) )
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
print(f'-UserVerifyView-> {request.data}') print(f'-UserVerifyView-> {request.data}')
@ -416,9 +404,11 @@ class WebUserRegisterView(CreateAPIView):
authentication_classes = [] authentication_classes = []
serializer_class = WebUserRegisterSerializer serializer_class = WebUserRegisterSerializer
@swagger_auto_schema(
operation_description="Web registration with password and confirmation",
request_body=WebUserRegisterSerializer,
@extend_schema(
summary="Register web user with password",
description="Web registration with password and confirmation.",
request=WebUserRegisterSerializer,
responses={202: OpenApiResponse(description="OTP code sent to user email.")},
) )
def post(self, request): def post(self, request):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
@ -452,9 +442,11 @@ class UserLoginView(CreateAPIView):
authentication_classes = [] authentication_classes = []
serializer_class = UserLoginSerializer serializer_class = UserLoginSerializer
@swagger_auto_schema(
operation_description=doc_login(),
request_body=UserLoginSerializer,
@extend_schema(
summary="User login",
description=doc_login(),
request=UserLoginSerializer,
responses={201: OpenApiResponse(description="Returns token and user info.")},
) )
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
return super().post(request, *args, **kwargs) return super().post(request, *args, **kwargs)
@ -554,10 +546,12 @@ class UserRecoverPassword(CreateAPIView):
authentication_classes = [] authentication_classes = []
serializer_class = UserRecoverPasswordSerializer serializer_class = UserRecoverPasswordSerializer
@swagger_auto_schema(
operation_description=doc_recover(),
request_body=UserRecoverPasswordSerializer,
)
@extend_schema(
summary="Request password recovery",
description=doc_recover(),
request=UserRecoverPasswordSerializer,
responses={202: OpenApiResponse(description="Recovery code sent to email.")},
)
def post(self, request): def post(self, request):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -590,10 +584,12 @@ class UserResetPassword(CreateAPIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
authentication_classes = [TokenAuthentication] authentication_classes = [TokenAuthentication]
@swagger_auto_schema(
operation_description=doc_reset(),
request_body=UserResetPasswordSerializer,
)
@extend_schema(
summary="Reset user password",
description=doc_reset(),
request=UserResetPasswordSerializer,
responses={200: OpenApiResponse(description="Password changed successfully.")},
)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
# Get the logged-in user # Get the logged-in user
user = request.user user = request.user
@ -667,9 +663,11 @@ class AdminLoginView(CreateAPIView):
authentication_classes = [] authentication_classes = []
serializer_class = UserLoginSerializer serializer_class = UserLoginSerializer
@swagger_auto_schema(
operation_description="Login specifically for Admin Panel users",
request_body=UserLoginSerializer,
@extend_schema(
summary="Admin panel login",
description="Login specifically for Admin Panel users. Returns auth token on success.",
request=UserLoginSerializer,
responses={201: OpenApiResponse(description="Returns token and admin user info.")},
) )
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
return self.create(request, *args, **kwargs) return self.create(request, *args, **kwargs)

2
apps/api/views/api_views.py

@ -4,8 +4,6 @@ from rest_framework.generics import CreateAPIView, GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from django.utils import timezone from django.utils import timezone
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiResponse, inline_serializer from drf_spectacular.utils import extend_schema, OpenApiResponse, inline_serializer
from rest_framework import serializers from rest_framework import serializers

3
apps/search/__init__.py

@ -0,0 +1,3 @@
"""
Unified Search Application
"""

8
apps/search/apps.py

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

40
apps/search/serializers.py

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

3
apps/search/tests/__init__.py

@ -0,0 +1,3 @@
"""
Search app tests package
"""

215
apps/search/tests/test_phase10_search.py

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

6
apps/search/urls.py

@ -0,0 +1,6 @@
from django.urls import path
from apps.search.views import UnifiedSearchView
urlpatterns = [
path('', UnifiedSearchView.as_view(), name='unified_search'),
]

331
apps/search/views.py

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

4
config/settings/base.py

@ -43,6 +43,7 @@ LOCAL_APPS = [
'apps.donations.apps.DonationsConfig', 'apps.donations.apps.DonationsConfig',
'apps.diplomacy.apps.DiplomacyConfig', 'apps.diplomacy.apps.DiplomacyConfig',
'apps.analytics.apps.AnalyticsConfig', 'apps.analytics.apps.AnalyticsConfig',
'apps.search.apps.SearchConfig',
'apps.api.apps.ApiConfig', 'apps.api.apps.ApiConfig',
'dynamic_preferences', 'dynamic_preferences',
] ]
@ -53,7 +54,6 @@ THIRD_PARTY_APPS = [
'rest_framework_simplejwt', 'rest_framework_simplejwt',
'drf_spectacular', 'drf_spectacular',
'drf_spectacular_sidecar', 'drf_spectacular_sidecar',
'drf_yasg',
'phonenumber_field', 'phonenumber_field',
'corsheaders', 'corsheaders',
'django_filters', 'django_filters',
@ -257,6 +257,8 @@ SPECTACULAR_SETTINGS = {
'DonationCampaignPriorityEnum': 'apps.donations.models.donation.CampaignPriority', 'DonationCampaignPriorityEnum': 'apps.donations.models.donation.CampaignPriority',
'PaymentStatusEnum': 'apps.donations.models.donation.PaymentStatus', 'PaymentStatusEnum': 'apps.donations.models.donation.PaymentStatus',
'DiplomacyReportStatusEnum': 'apps.diplomacy.models.report.ReportStatus', 'DiplomacyReportStatusEnum': 'apps.diplomacy.models.report.ReportStatus',
'InstitutionTypeEnum': 'apps.profiles.models.institution.Institution.InstitutionType',
'SearchEntityTypeEnum': 'apps.search.serializers.SearchEntityType',
}, },
} }

3
config/urls.py

@ -70,6 +70,9 @@ api_v1_patterns = [
# Executive Analytics Dashboard # Executive Analytics Dashboard
path('analytics/', include('apps.analytics.urls')), path('analytics/', include('apps.analytics.urls')),
# Unified Multi-Entity Natural Search
path('search/', include('apps.search.urls')),
path('', include('apps.api.urls')), path('', include('apps.api.urls')),
path('account/', include('apps.account.urls')), path('account/', include('apps.account.urls')),
path('settings/', include('dynamic_preferences.urls')), path('settings/', include('dynamic_preferences.urls')),

1
requirements.txt

@ -5,7 +5,6 @@ djangorestframework-simplejwt>=5.3.1
django-environ>=0.11.2 django-environ>=0.11.2
django-cors-headers>=4.3.0 django-cors-headers>=4.3.0
django-filter>=23.0 django-filter>=23.0
drf-yasg>=1.21.7
drf-spectacular>=0.27.0 drf-spectacular>=0.27.0
drf-spectacular-sidecar>=2024.7.1 drf-spectacular-sidecar>=2024.7.1

202
schema.yml

@ -4925,6 +4925,52 @@ paths:
description: Task deleted successfully description: Task deleted successfully
'403': '403':
description: Permission denied description: Permission denied
/api/v1/search/:
get:
operationId: v1_search_retrieve
description: Search simultaneously across institutions, collaborative projects,
LMS academy courses, CMS articles/news, and international events with relevance
ranking and facet filtering.
summary: Unified Multi-Entity Search
parameters:
- in: query
name: limit
schema:
type: integer
description: 'Maximum number of search results to return (default: 20, max:
100)'
- in: query
name: offset
schema:
type: integer
description: 'Pagination offset (default: 0)'
- in: query
name: q
schema:
type: string
description: Search query string / keywords (Persian, Arabic, English, Urdu,
etc.)
- in: query
name: type
schema:
type: string
description: 'Comma-separated entity types to include: institution, project,
course, post, event'
tags:
- Unified Search
security:
- jwtAuth: []
- tokenAuth: []
- cookieAuth: []
- jwtAuth: []
- {}
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UnifiedSearchResponse'
description: ''
/api/v1/settings/about-us/: /api/v1/settings/about-us/:
get: get:
operationId: v1_settings_about_us_retrieve operationId: v1_settings_about_us_retrieve
@ -5878,7 +5924,7 @@ components:
pattern: ^[-\w]+$ pattern: ^[-\w]+$
type: type:
allOf: allOf:
- $ref: '#/components/schemas/TypeEnum'
- $ref: '#/components/schemas/InstitutionTypeEnum'
readOnly: true readOnly: true
title: Institution Type title: Institution Type
type_display: type_display:
@ -7343,7 +7389,7 @@ components:
maxLength: 0 maxLength: 0
type: type:
allOf: allOf:
- $ref: '#/components/schemas/TypeEnum'
- $ref: '#/components/schemas/InstitutionTypeEnum'
title: Institution Type title: Institution Type
city: city:
type: string type: string
@ -7759,7 +7805,7 @@ components:
maxLength: 255 maxLength: 255
type: type:
allOf: allOf:
- $ref: '#/components/schemas/TypeEnum'
- $ref: '#/components/schemas/InstitutionTypeEnum'
title: Institution Type title: Institution Type
country: country:
type: string type: string
@ -7853,7 +7899,7 @@ components:
pattern: ^[-\w]+$ pattern: ^[-\w]+$
type: type:
allOf: allOf:
- $ref: '#/components/schemas/TypeEnum'
- $ref: '#/components/schemas/InstitutionTypeEnum'
title: Institution Type title: Institution Type
type_display: type_display:
type: string type: string
@ -8012,7 +8058,7 @@ components:
pattern: ^[-\w]+$ pattern: ^[-\w]+$
type: type:
allOf: allOf:
- $ref: '#/components/schemas/TypeEnum'
- $ref: '#/components/schemas/InstitutionTypeEnum'
readOnly: true readOnly: true
title: Institution Type title: Institution Type
type_display: type_display:
@ -8183,7 +8229,7 @@ components:
maxLength: 0 maxLength: 0
type: type:
allOf: allOf:
- $ref: '#/components/schemas/TypeEnum'
- $ref: '#/components/schemas/InstitutionTypeEnum'
title: Institution Type title: Institution Type
avatar: avatar:
type: string type: string
@ -8310,6 +8356,24 @@ components:
required: required:
- title - title
- year - year
InstitutionTypeEnum:
enum:
- mosque
- hussainiya
- cultural_center
- library
- institute
- charity
- other
type: string
description: |-
* `mosque` - Mosque
* `hussainiya` - Hussainiya / Islamic Center
* `cultural_center` - Cultural Center
* `library` - Islamic Library
* `institute` - Seminary & Institute
* `charity` - Charity & Foundation
* `other` - Other Organization
InstitutionsAnalytics: InstitutionsAnalytics:
type: object type: object
properties: properties:
@ -10328,7 +10392,7 @@ components:
maxLength: 255 maxLength: 255
type: type:
allOf: allOf:
- $ref: '#/components/schemas/TypeEnum'
- $ref: '#/components/schemas/InstitutionTypeEnum'
title: Institution Type title: Institution Type
country: country:
type: string type: string
@ -11060,7 +11124,7 @@ components:
pattern: ^[-\w]+$ pattern: ^[-\w]+$
type: type:
allOf: allOf:
- $ref: '#/components/schemas/TypeEnum'
- $ref: '#/components/schemas/InstitutionTypeEnum'
readOnly: true readOnly: true
title: Institution Type title: Institution Type
type_display: type_display:
@ -11520,7 +11584,7 @@ components:
pattern: ^[-\w]+$ pattern: ^[-\w]+$
type: type:
allOf: allOf:
- $ref: '#/components/schemas/TypeEnum'
- $ref: '#/components/schemas/InstitutionTypeEnum'
readOnly: true readOnly: true
title: Institution Type title: Institution Type
type_display: type_display:
@ -12123,6 +12187,85 @@ components:
description: |- description: |-
* `direct` - Direct Message * `direct` - Direct Message
* `group` - Group Conversation * `group` - Group Conversation
SearchEntityTypeEnum:
enum:
- institution
- project
- course
- post
- event
type: string
description: |-
* `institution` - Institution
* `project` - Project
* `course` - Course
* `post` - Post
* `event` - Event
SearchResultItem:
type: object
properties:
id:
type: string
description: Entity unique identifier
type:
allOf:
- $ref: '#/components/schemas/SearchEntityTypeEnum'
description: |-
Type of the entity found
* `institution` - Institution
* `project` - Project
* `course` - Course
* `post` - Post
* `event` - Event
title:
type: string
description: Title or primary name
description:
type: string
description: Summary or excerpt
url_path:
type: string
description: Frontend navigation route
image:
type: string
nullable: true
description: Cover or avatar image URL
badge:
type: string
description: Contextual badge / label
metadata:
type: object
additionalProperties: {}
description: Additional metadata attributes
relevance_score:
type: number
format: double
default: 1.0
description: Relevance ranking score
required:
- id
- title
- type
- url_path
SearchTypeCount:
type: object
properties:
institutions:
type: integer
default: 0
projects:
type: integer
default: 0
courses:
type: integer
default: 0
posts:
type: integer
default: 0
events:
type: integer
default: 0
SendNotificationRequest: SendNotificationRequest:
type: object type: object
properties: properties:
@ -12634,24 +12777,29 @@ components:
minLength: 1 minLength: 1
required: required:
- token - token
TypeEnum:
enum:
- mosque
- hussainiya
- cultural_center
- library
- institute
- charity
- other
type: string
description: |-
* `mosque` - Mosque
* `hussainiya` - Hussainiya / Islamic Center
* `cultural_center` - Cultural Center
* `library` - Islamic Library
* `institute` - Seminary & Institute
* `charity` - Charity & Foundation
* `other` - Other Organization
UnifiedSearchResponse:
type: object
properties:
query:
type: string
description: Original search query string
total_results:
type: integer
description: Total number of matched records across categories
by_type:
allOf:
- $ref: '#/components/schemas/SearchTypeCount'
description: Breakdown count of matched records by entity type
results:
type: array
items:
$ref: '#/components/schemas/SearchResultItem'
description: List of matched search results ordered by relevance
required:
- by_type
- query
- results
- total_results
UserFCM: UserFCM:
type: object type: object
properties: properties:

1
test_media/chat/attachments/2026/09/agenda_schedule_2DCh1tj.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Schedule

1
test_media/chat/attachments/2026/09/agenda_schedule_AETwwrY.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Schedule

1
test_media/chat/attachments/2026/09/agenda_schedule_MQVAfYP.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Schedule

1
test_media/chat/attachments/2026/09/agenda_schedule_pfp2CQf.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Schedule

1
test_media/chat/attachments/2026/09/agenda_schedule_vnJEK2e.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Schedule

1
test_media/institutions/gallery/2026/09/center_hall_3NtVzPY.jpg

@ -0,0 +1 @@
mock_image_bytes

1
test_media/institutions/gallery/2026/09/center_hall_MqRDw4T.jpg

@ -0,0 +1 @@
mock_image_bytes

1
test_media/institutions/gallery/2026/09/center_hall_Nxv03Hd.jpg

@ -0,0 +1 @@
mock_image_bytes

1
test_media/institutions/gallery/2026/09/center_hall_ar9CE0s.jpg

@ -0,0 +1 @@
mock_image_bytes

1
test_media/institutions/gallery/2026/09/center_hall_wuXjM4R.jpg

@ -0,0 +1 @@
mock_image_bytes

1
test_media/projects/docs/2026/09/bilateral_mou_2026_dUrbd5U.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Bilateral MOU

1
test_media/projects/docs/2026/09/bilateral_mou_2026_efo4psz.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Bilateral MOU

1
test_media/projects/docs/2026/09/bilateral_mou_2026_hS37xMJ.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Bilateral MOU

1
test_media/projects/docs/2026/09/bilateral_mou_2026_wDZI9eJ.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Bilateral MOU

1
test_media/projects/docs/2026/09/bilateral_mou_2026_wcGgoNC.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Bilateral MOU

1
test_media/users/documents/2026/09/institution_license_AxW4dDo.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock License Content

1
test_media/users/documents/2026/09/institution_license_DD1y3j9.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock License Content

1
test_media/users/documents/2026/09/institution_license_Sn9iuxZ.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock License Content

1
test_media/users/documents/2026/09/institution_license_i70KOoM.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock License Content

1
test_media/users/documents/2026/09/institution_license_uH89LoM.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock License Content
Loading…
Cancel
Save