From b492d86cdfbe53f29859303837916ea11a4d020a Mon Sep 17 00:00:00 2001 From: sina_sajjadi Date: Wed, 16 Sep 2026 13:33:07 +0330 Subject: [PATCH] feat: introduce search app and add new account and api views --- apps/account/views/auth.py | 47 +-- apps/account/views/notification.py | 2 - apps/account/views/user.py | 96 +++-- apps/api/views/api_views.py | 2 - apps/search/__init__.py | 3 + apps/search/apps.py | 8 + apps/search/serializers.py | 40 +++ apps/search/tests/__init__.py | 3 + apps/search/tests/test_phase10_search.py | 215 ++++++++++++ apps/search/urls.py | 6 + apps/search/views.py | 331 ++++++++++++++++++ config/settings/base.py | 4 +- config/urls.py | 3 + requirements.txt | 1 - schema.yml | 202 +++++++++-- .../2026/09/agenda_schedule_2DCh1tj.pdf | 1 + .../2026/09/agenda_schedule_AETwwrY.pdf | 1 + .../2026/09/agenda_schedule_MQVAfYP.pdf | 1 + .../2026/09/agenda_schedule_pfp2CQf.pdf | 1 + .../2026/09/agenda_schedule_vnJEK2e.pdf | 1 + .../gallery/2026/09/center_hall_3NtVzPY.jpg | 1 + .../gallery/2026/09/center_hall_MqRDw4T.jpg | 1 + .../gallery/2026/09/center_hall_Nxv03Hd.jpg | 1 + .../gallery/2026/09/center_hall_ar9CE0s.jpg | 1 + .../gallery/2026/09/center_hall_wuXjM4R.jpg | 1 + .../2026/09/bilateral_mou_2026_dUrbd5U.pdf | 1 + .../2026/09/bilateral_mou_2026_efo4psz.pdf | 1 + .../2026/09/bilateral_mou_2026_hS37xMJ.pdf | 1 + .../2026/09/bilateral_mou_2026_wDZI9eJ.pdf | 1 + .../2026/09/bilateral_mou_2026_wcGgoNC.pdf | 1 + .../2026/09/institution_license_AxW4dDo.pdf | 1 + .../2026/09/institution_license_DD1y3j9.pdf | 1 + .../2026/09/institution_license_Sn9iuxZ.pdf | 1 + .../2026/09/institution_license_i70KOoM.pdf | 1 + .../2026/09/institution_license_uH89LoM.pdf | 1 + 35 files changed, 862 insertions(+), 121 deletions(-) create mode 100644 apps/search/__init__.py create mode 100644 apps/search/apps.py create mode 100644 apps/search/serializers.py create mode 100644 apps/search/tests/__init__.py create mode 100644 apps/search/tests/test_phase10_search.py create mode 100644 apps/search/urls.py create mode 100644 apps/search/views.py create mode 100644 test_media/chat/attachments/2026/09/agenda_schedule_2DCh1tj.pdf create mode 100644 test_media/chat/attachments/2026/09/agenda_schedule_AETwwrY.pdf create mode 100644 test_media/chat/attachments/2026/09/agenda_schedule_MQVAfYP.pdf create mode 100644 test_media/chat/attachments/2026/09/agenda_schedule_pfp2CQf.pdf create mode 100644 test_media/chat/attachments/2026/09/agenda_schedule_vnJEK2e.pdf create mode 100644 test_media/institutions/gallery/2026/09/center_hall_3NtVzPY.jpg create mode 100644 test_media/institutions/gallery/2026/09/center_hall_MqRDw4T.jpg create mode 100644 test_media/institutions/gallery/2026/09/center_hall_Nxv03Hd.jpg create mode 100644 test_media/institutions/gallery/2026/09/center_hall_ar9CE0s.jpg create mode 100644 test_media/institutions/gallery/2026/09/center_hall_wuXjM4R.jpg create mode 100644 test_media/projects/docs/2026/09/bilateral_mou_2026_dUrbd5U.pdf create mode 100644 test_media/projects/docs/2026/09/bilateral_mou_2026_efo4psz.pdf create mode 100644 test_media/projects/docs/2026/09/bilateral_mou_2026_hS37xMJ.pdf create mode 100644 test_media/projects/docs/2026/09/bilateral_mou_2026_wDZI9eJ.pdf create mode 100644 test_media/projects/docs/2026/09/bilateral_mou_2026_wcGgoNC.pdf create mode 100644 test_media/users/documents/2026/09/institution_license_AxW4dDo.pdf create mode 100644 test_media/users/documents/2026/09/institution_license_DD1y3j9.pdf create mode 100644 test_media/users/documents/2026/09/institution_license_Sn9iuxZ.pdf create mode 100644 test_media/users/documents/2026/09/institution_license_i70KOoM.pdf create mode 100644 test_media/users/documents/2026/09/institution_license_uH89LoM.pdf diff --git a/apps/account/views/auth.py b/apps/account/views/auth.py index c1baf66..f83caac 100644 --- a/apps/account/views/auth.py +++ b/apps/account/views/auth.py @@ -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": "user@example.com", - "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): diff --git a/apps/account/views/notification.py b/apps/account/views/notification.py index 0981ef0..c152999 100644 --- a/apps/account/views/notification.py +++ b/apps/account/views/notification.py @@ -1,8 +1,6 @@ from rest_framework import generics, status from rest_framework.response import Response 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.viewsets import ModelViewSet diff --git a/apps/account/views/user.py b/apps/account/views/user.py index ad012c6..5ac7836 100644 --- a/apps/account/views/user.py +++ b/apps/account/views/user.py @@ -17,8 +17,6 @@ from django.utils import timezone from rest_framework.authentication import TokenAuthentication from django.contrib.auth import authenticate 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 rest_framework.exceptions import ValidationError @@ -56,20 +54,11 @@ class UserGuestView(CreateAPIView): authentication_classes = [] 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): logger.info(f'GuestAuthView--> {request.data}') @@ -151,16 +140,11 @@ class WebUserGuestView(CreateAPIView): authentication_classes = [] 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): 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 - @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): serializer = self.get_serializer(data=request.data) @@ -284,9 +270,11 @@ class UserVerifyView(CreateAPIView): authentication_classes = [] 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): print(f'-UserVerifyView-> {request.data}') @@ -416,9 +404,11 @@ class WebUserRegisterView(CreateAPIView): authentication_classes = [] 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): serializer = self.get_serializer(data=request.data) @@ -452,9 +442,11 @@ class UserLoginView(CreateAPIView): authentication_classes = [] 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): return super().post(request, *args, **kwargs) @@ -554,10 +546,12 @@ class UserRecoverPassword(CreateAPIView): authentication_classes = [] 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): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -590,10 +584,12 @@ class UserResetPassword(CreateAPIView): permission_classes = [IsAuthenticated] 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): # Get the logged-in user user = request.user @@ -667,9 +663,11 @@ class AdminLoginView(CreateAPIView): authentication_classes = [] 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): return self.create(request, *args, **kwargs) diff --git a/apps/api/views/api_views.py b/apps/api/views/api_views.py index 06c5a28..712bb71 100644 --- a/apps/api/views/api_views.py +++ b/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.permissions import AllowAny 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 rest_framework import serializers diff --git a/apps/search/__init__.py b/apps/search/__init__.py new file mode 100644 index 0000000..8474da1 --- /dev/null +++ b/apps/search/__init__.py @@ -0,0 +1,3 @@ +""" +Unified Search Application +""" diff --git a/apps/search/apps.py b/apps/search/apps.py new file mode 100644 index 0000000..e7a1486 --- /dev/null +++ b/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') diff --git a/apps/search/serializers.py b/apps/search/serializers.py new file mode 100644 index 0000000..3eee304 --- /dev/null +++ b/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") diff --git a/apps/search/tests/__init__.py b/apps/search/tests/__init__.py new file mode 100644 index 0000000..123499a --- /dev/null +++ b/apps/search/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Search app tests package +""" diff --git a/apps/search/tests/test_phase10_search.py b/apps/search/tests/test_phase10_search.py new file mode 100644 index 0000000..d2c83c6 --- /dev/null +++ b/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="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) diff --git a/apps/search/urls.py b/apps/search/urls.py new file mode 100644 index 0000000..d08e49d --- /dev/null +++ b/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'), +] diff --git a/apps/search/views.py b/apps/search/views.py new file mode 100644 index 0000000..20b6104 --- /dev/null +++ b/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) diff --git a/config/settings/base.py b/config/settings/base.py index 053fd1c..9565d12 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -43,6 +43,7 @@ LOCAL_APPS = [ 'apps.donations.apps.DonationsConfig', 'apps.diplomacy.apps.DiplomacyConfig', 'apps.analytics.apps.AnalyticsConfig', + 'apps.search.apps.SearchConfig', 'apps.api.apps.ApiConfig', 'dynamic_preferences', ] @@ -53,7 +54,6 @@ THIRD_PARTY_APPS = [ 'rest_framework_simplejwt', 'drf_spectacular', 'drf_spectacular_sidecar', - 'drf_yasg', 'phonenumber_field', 'corsheaders', 'django_filters', @@ -257,6 +257,8 @@ SPECTACULAR_SETTINGS = { 'DonationCampaignPriorityEnum': 'apps.donations.models.donation.CampaignPriority', 'PaymentStatusEnum': 'apps.donations.models.donation.PaymentStatus', 'DiplomacyReportStatusEnum': 'apps.diplomacy.models.report.ReportStatus', + 'InstitutionTypeEnum': 'apps.profiles.models.institution.Institution.InstitutionType', + 'SearchEntityTypeEnum': 'apps.search.serializers.SearchEntityType', }, } diff --git a/config/urls.py b/config/urls.py index 2a0a29c..cfbbb50 100644 --- a/config/urls.py +++ b/config/urls.py @@ -70,6 +70,9 @@ api_v1_patterns = [ # Executive Analytics Dashboard path('analytics/', include('apps.analytics.urls')), + # Unified Multi-Entity Natural Search + path('search/', include('apps.search.urls')), + path('', include('apps.api.urls')), path('account/', include('apps.account.urls')), path('settings/', include('dynamic_preferences.urls')), diff --git a/requirements.txt b/requirements.txt index 4bb96cc..99816a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,6 @@ djangorestframework-simplejwt>=5.3.1 django-environ>=0.11.2 django-cors-headers>=4.3.0 django-filter>=23.0 -drf-yasg>=1.21.7 drf-spectacular>=0.27.0 drf-spectacular-sidecar>=2024.7.1 diff --git a/schema.yml b/schema.yml index f01cbc7..fdc179c 100644 --- a/schema.yml +++ b/schema.yml @@ -4925,6 +4925,52 @@ paths: description: Task deleted successfully '403': 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/: get: operationId: v1_settings_about_us_retrieve @@ -5878,7 +5924,7 @@ components: pattern: ^[-\w]+$ type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/InstitutionTypeEnum' readOnly: true title: Institution Type type_display: @@ -7343,7 +7389,7 @@ components: maxLength: 0 type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/InstitutionTypeEnum' title: Institution Type city: type: string @@ -7759,7 +7805,7 @@ components: maxLength: 255 type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/InstitutionTypeEnum' title: Institution Type country: type: string @@ -7853,7 +7899,7 @@ components: pattern: ^[-\w]+$ type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/InstitutionTypeEnum' title: Institution Type type_display: type: string @@ -8012,7 +8058,7 @@ components: pattern: ^[-\w]+$ type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/InstitutionTypeEnum' readOnly: true title: Institution Type type_display: @@ -8183,7 +8229,7 @@ components: maxLength: 0 type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/InstitutionTypeEnum' title: Institution Type avatar: type: string @@ -8310,6 +8356,24 @@ components: required: - title - 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: type: object properties: @@ -10328,7 +10392,7 @@ components: maxLength: 255 type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/InstitutionTypeEnum' title: Institution Type country: type: string @@ -11060,7 +11124,7 @@ components: pattern: ^[-\w]+$ type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/InstitutionTypeEnum' readOnly: true title: Institution Type type_display: @@ -11520,7 +11584,7 @@ components: pattern: ^[-\w]+$ type: allOf: - - $ref: '#/components/schemas/TypeEnum' + - $ref: '#/components/schemas/InstitutionTypeEnum' readOnly: true title: Institution Type type_display: @@ -12123,6 +12187,85 @@ components: description: |- * `direct` - Direct Message * `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: type: object properties: @@ -12634,24 +12777,29 @@ components: minLength: 1 required: - 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: type: object properties: diff --git a/test_media/chat/attachments/2026/09/agenda_schedule_2DCh1tj.pdf b/test_media/chat/attachments/2026/09/agenda_schedule_2DCh1tj.pdf new file mode 100644 index 0000000..2207a29 --- /dev/null +++ b/test_media/chat/attachments/2026/09/agenda_schedule_2DCh1tj.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Schedule \ No newline at end of file diff --git a/test_media/chat/attachments/2026/09/agenda_schedule_AETwwrY.pdf b/test_media/chat/attachments/2026/09/agenda_schedule_AETwwrY.pdf new file mode 100644 index 0000000..2207a29 --- /dev/null +++ b/test_media/chat/attachments/2026/09/agenda_schedule_AETwwrY.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Schedule \ No newline at end of file diff --git a/test_media/chat/attachments/2026/09/agenda_schedule_MQVAfYP.pdf b/test_media/chat/attachments/2026/09/agenda_schedule_MQVAfYP.pdf new file mode 100644 index 0000000..2207a29 --- /dev/null +++ b/test_media/chat/attachments/2026/09/agenda_schedule_MQVAfYP.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Schedule \ No newline at end of file diff --git a/test_media/chat/attachments/2026/09/agenda_schedule_pfp2CQf.pdf b/test_media/chat/attachments/2026/09/agenda_schedule_pfp2CQf.pdf new file mode 100644 index 0000000..2207a29 --- /dev/null +++ b/test_media/chat/attachments/2026/09/agenda_schedule_pfp2CQf.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Schedule \ No newline at end of file diff --git a/test_media/chat/attachments/2026/09/agenda_schedule_vnJEK2e.pdf b/test_media/chat/attachments/2026/09/agenda_schedule_vnJEK2e.pdf new file mode 100644 index 0000000..2207a29 --- /dev/null +++ b/test_media/chat/attachments/2026/09/agenda_schedule_vnJEK2e.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Schedule \ No newline at end of file diff --git a/test_media/institutions/gallery/2026/09/center_hall_3NtVzPY.jpg b/test_media/institutions/gallery/2026/09/center_hall_3NtVzPY.jpg new file mode 100644 index 0000000..8bab6ea --- /dev/null +++ b/test_media/institutions/gallery/2026/09/center_hall_3NtVzPY.jpg @@ -0,0 +1 @@ +mock_image_bytes \ No newline at end of file diff --git a/test_media/institutions/gallery/2026/09/center_hall_MqRDw4T.jpg b/test_media/institutions/gallery/2026/09/center_hall_MqRDw4T.jpg new file mode 100644 index 0000000..8bab6ea --- /dev/null +++ b/test_media/institutions/gallery/2026/09/center_hall_MqRDw4T.jpg @@ -0,0 +1 @@ +mock_image_bytes \ No newline at end of file diff --git a/test_media/institutions/gallery/2026/09/center_hall_Nxv03Hd.jpg b/test_media/institutions/gallery/2026/09/center_hall_Nxv03Hd.jpg new file mode 100644 index 0000000..8bab6ea --- /dev/null +++ b/test_media/institutions/gallery/2026/09/center_hall_Nxv03Hd.jpg @@ -0,0 +1 @@ +mock_image_bytes \ No newline at end of file diff --git a/test_media/institutions/gallery/2026/09/center_hall_ar9CE0s.jpg b/test_media/institutions/gallery/2026/09/center_hall_ar9CE0s.jpg new file mode 100644 index 0000000..8bab6ea --- /dev/null +++ b/test_media/institutions/gallery/2026/09/center_hall_ar9CE0s.jpg @@ -0,0 +1 @@ +mock_image_bytes \ No newline at end of file diff --git a/test_media/institutions/gallery/2026/09/center_hall_wuXjM4R.jpg b/test_media/institutions/gallery/2026/09/center_hall_wuXjM4R.jpg new file mode 100644 index 0000000..8bab6ea --- /dev/null +++ b/test_media/institutions/gallery/2026/09/center_hall_wuXjM4R.jpg @@ -0,0 +1 @@ +mock_image_bytes \ No newline at end of file diff --git a/test_media/projects/docs/2026/09/bilateral_mou_2026_dUrbd5U.pdf b/test_media/projects/docs/2026/09/bilateral_mou_2026_dUrbd5U.pdf new file mode 100644 index 0000000..976a621 --- /dev/null +++ b/test_media/projects/docs/2026/09/bilateral_mou_2026_dUrbd5U.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Bilateral MOU \ No newline at end of file diff --git a/test_media/projects/docs/2026/09/bilateral_mou_2026_efo4psz.pdf b/test_media/projects/docs/2026/09/bilateral_mou_2026_efo4psz.pdf new file mode 100644 index 0000000..976a621 --- /dev/null +++ b/test_media/projects/docs/2026/09/bilateral_mou_2026_efo4psz.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Bilateral MOU \ No newline at end of file diff --git a/test_media/projects/docs/2026/09/bilateral_mou_2026_hS37xMJ.pdf b/test_media/projects/docs/2026/09/bilateral_mou_2026_hS37xMJ.pdf new file mode 100644 index 0000000..976a621 --- /dev/null +++ b/test_media/projects/docs/2026/09/bilateral_mou_2026_hS37xMJ.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Bilateral MOU \ No newline at end of file diff --git a/test_media/projects/docs/2026/09/bilateral_mou_2026_wDZI9eJ.pdf b/test_media/projects/docs/2026/09/bilateral_mou_2026_wDZI9eJ.pdf new file mode 100644 index 0000000..976a621 --- /dev/null +++ b/test_media/projects/docs/2026/09/bilateral_mou_2026_wDZI9eJ.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Bilateral MOU \ No newline at end of file diff --git a/test_media/projects/docs/2026/09/bilateral_mou_2026_wcGgoNC.pdf b/test_media/projects/docs/2026/09/bilateral_mou_2026_wcGgoNC.pdf new file mode 100644 index 0000000..976a621 --- /dev/null +++ b/test_media/projects/docs/2026/09/bilateral_mou_2026_wcGgoNC.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Bilateral MOU \ No newline at end of file diff --git a/test_media/users/documents/2026/09/institution_license_AxW4dDo.pdf b/test_media/users/documents/2026/09/institution_license_AxW4dDo.pdf new file mode 100644 index 0000000..b08e1d2 --- /dev/null +++ b/test_media/users/documents/2026/09/institution_license_AxW4dDo.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock License Content \ No newline at end of file diff --git a/test_media/users/documents/2026/09/institution_license_DD1y3j9.pdf b/test_media/users/documents/2026/09/institution_license_DD1y3j9.pdf new file mode 100644 index 0000000..b08e1d2 --- /dev/null +++ b/test_media/users/documents/2026/09/institution_license_DD1y3j9.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock License Content \ No newline at end of file diff --git a/test_media/users/documents/2026/09/institution_license_Sn9iuxZ.pdf b/test_media/users/documents/2026/09/institution_license_Sn9iuxZ.pdf new file mode 100644 index 0000000..b08e1d2 --- /dev/null +++ b/test_media/users/documents/2026/09/institution_license_Sn9iuxZ.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock License Content \ No newline at end of file diff --git a/test_media/users/documents/2026/09/institution_license_i70KOoM.pdf b/test_media/users/documents/2026/09/institution_license_i70KOoM.pdf new file mode 100644 index 0000000..b08e1d2 --- /dev/null +++ b/test_media/users/documents/2026/09/institution_license_i70KOoM.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock License Content \ No newline at end of file diff --git a/test_media/users/documents/2026/09/institution_license_uH89LoM.pdf b/test_media/users/documents/2026/09/institution_license_uH89LoM.pdf new file mode 100644 index 0000000..b08e1d2 --- /dev/null +++ b/test_media/users/documents/2026/09/institution_license_uH89LoM.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock License Content \ No newline at end of file