diff --git a/pecha_api/verse_of_day/verse_of_day_enums.py b/pecha_api/verse_of_day/verse_of_day_enums.py new file mode 100644 index 000000000..0229956bc --- /dev/null +++ b/pecha_api/verse_of_day/verse_of_day_enums.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class SortOrder(str, Enum): + ASC = "asc" + DESC = "desc" diff --git a/pecha_api/verse_of_day/verse_of_day_repository.py b/pecha_api/verse_of_day/verse_of_day_repository.py index f9c6b82b4..c448bfd7d 100644 --- a/pecha_api/verse_of_day/verse_of_day_repository.py +++ b/pecha_api/verse_of_day/verse_of_day_repository.py @@ -6,6 +6,7 @@ from .verse_of_day_model import VerseOfDay from .verse_metadata_model import VerseMetadata +from .verse_of_day_enums import SortOrder from pecha_api.plans.groups.groups_models import AuthorGroupMetadata @@ -30,21 +31,30 @@ def get_verses_of_day_list( db: Session, group_id: Optional[UUID] = None, filter_date: Optional[date] = None, + search: Optional[str] = None, + sort_order: SortOrder = SortOrder.DESC, skip: int = 0, limit: int = 100 ) -> tuple[List[VerseOfDay], int]: - """Get list of verses with pagination.""" + """Get list of verses with search, sorting, and pagination.""" query = db.query(VerseOfDay).options(joinedload(VerseOfDay.verse_metadata)) - + if group_id is not None: query = query.filter(VerseOfDay.group_id == group_id) - + if filter_date is not None: query = query.filter(VerseOfDay.date == filter_date) - + + if search: + query = query.filter( + VerseOfDay.verse_metadata.any(VerseMetadata.verse.ilike(f"%{search}%")) + ) + total = query.count() - verses = query.order_by(VerseOfDay.date.desc()).offset(skip).limit(limit).all() - + + order_by = VerseOfDay.date.asc() if sort_order == SortOrder.ASC else VerseOfDay.date.desc() + verses = query.order_by(order_by).offset(skip).limit(limit).all() + return verses, total diff --git a/pecha_api/verse_of_day/verse_of_day_service.py b/pecha_api/verse_of_day/verse_of_day_service.py index 1c6ea2a1c..e9844d863 100644 --- a/pecha_api/verse_of_day/verse_of_day_service.py +++ b/pecha_api/verse_of_day/verse_of_day_service.py @@ -31,6 +31,7 @@ GroupInfoDTO ) from .verse_of_day_model import VerseOfDay +from .verse_of_day_enums import SortOrder from ..uploads.S3_utils import generate_presigned_access_url from ..config import get @@ -148,12 +149,14 @@ def get_verses_of_day_list_service( group_id: Optional[UUID] = None, filter_date: Optional[date] = None, lang: Optional[str] = None, + search: Optional[str] = None, + sort_order: SortOrder = SortOrder.DESC, skip: int = 0, limit: int = 100 ) -> VerseOfDayListResponse: - """Get list of verses with pagination.""" + """Get list of verses with search, sorting, and pagination.""" with SessionLocal() as db: - verses, total = get_verses_of_day_list(db, group_id=group_id, filter_date=filter_date, skip=skip, limit=limit) + verses, total = get_verses_of_day_list(db, group_id=group_id, filter_date=filter_date, search=search, sort_order=sort_order, skip=skip, limit=limit) verse_dtos = [] for verse in verses: diff --git a/pecha_api/verse_of_day/verse_of_day_views.py b/pecha_api/verse_of_day/verse_of_day_views.py index 23ba22d9a..0f028f3de 100644 --- a/pecha_api/verse_of_day/verse_of_day_views.py +++ b/pecha_api/verse_of_day/verse_of_day_views.py @@ -6,6 +6,7 @@ from starlette import status from .verse_of_day_response_models import VerseOfDayPublicResponse, VerseOfDayListResponse, CreateVerseOfDayRequest, UpdateVerseOfDayRequest, VerseOfDayDTO +from .verse_of_day_enums import SortOrder from .verse_of_day_service import get_verse_of_day, get_verses_of_day_list_service, get_verse_of_day_by_id_service, get_verse_of_day_today_service, create_verse_of_day_service, update_verse_of_day_service, delete_verse_of_day_service from pecha_api.users.users_service import validate_and_extract_user_details @@ -74,11 +75,13 @@ def cms_get_verse_of_day_endpoint( group_id: Annotated[Optional[UUID], Query(description="Filter by group ID")] = None, date: Annotated[Optional[date], Query(description="Filter by date (YYYY-MM-DD)")] = None, lang: Annotated[Optional[str], Query(description="Filter by language (en, bo, zh, hi, ne, mn). Returns all languages if not specified.")] = None, + search: Annotated[Optional[str], Query(description="Free-text search over verse content (any language)")] = None, + sort_order: Annotated[SortOrder, Query(description="Sort by date: asc (oldest first) or desc (newest first)")] = SortOrder.DESC, skip: Annotated[int, Query(description="Number of records to skip", ge=0)] = 0, limit: Annotated[int, Query(description="Maximum number of records to return", ge=1, le=100)] = 100, ): validate_and_extract_user_details(credentials.credentials) - return get_verses_of_day_list_service(group_id=group_id, filter_date=date, lang=lang, skip=skip, limit=limit) + return get_verses_of_day_list_service(group_id=group_id, filter_date=date, lang=lang, search=search, sort_order=sort_order, skip=skip, limit=limit) @cms_verse_of_day_router.get( diff --git a/tests/plans/public/test_plan_public_service.py b/tests/plans/public/test_plan_public_service.py index 10b8e73fa..8d0f7a299 100644 --- a/tests/plans/public/test_plan_public_service.py +++ b/tests/plans/public/test_plan_public_service.py @@ -1,7 +1,7 @@ import pytest from uuid import uuid4 from types import SimpleNamespace -from unittest.mock import patch, MagicMock, Mock, AsyncMock +from unittest.mock import patch, MagicMock, Mock, AsyncMock, call from datetime import date as DateType, datetime, timedelta, timezone from fastapi import HTTPException from starlette import status diff --git a/tests/verse_of_day/test_verse_of_day_service.py b/tests/verse_of_day/test_verse_of_day_service.py index 39bae8718..38f5803ca 100644 --- a/tests/verse_of_day/test_verse_of_day_service.py +++ b/tests/verse_of_day/test_verse_of_day_service.py @@ -23,6 +23,7 @@ CreateVerseOfDayRequest, UpdateVerseOfDayRequest, ) +from pecha_api.verse_of_day.verse_of_day_enums import SortOrder from fastapi import HTTPException @@ -813,6 +814,8 @@ async def test_get_verses_of_day_list_service_success(sample_verse_list, mock_db mock_db_session.__enter__.return_value, group_id=None, filter_date=None, + search=None, + sort_order=SortOrder.DESC, skip=0, limit=100 ) @@ -833,6 +836,8 @@ async def test_get_verses_of_day_list_service_with_pagination(sample_verse_list, mock_db_session.__enter__.return_value, group_id=None, filter_date=None, + search=None, + sort_order=SortOrder.DESC, skip=5, limit=20 ) @@ -855,6 +860,8 @@ async def test_get_verses_of_day_list_service_with_group_id_filter(sample_verse_ mock_db_session.__enter__.return_value, group_id=group_id, filter_date=None, + search=None, + sort_order=SortOrder.DESC, skip=0, limit=100 ) @@ -876,6 +883,28 @@ async def test_get_verses_of_day_list_service_with_date_filter(sample_verse_list mock_db_session.__enter__.return_value, group_id=None, filter_date=filter_date, + search=None, + sort_order=SortOrder.DESC, + skip=0, + limit=100 + ) + + +@pytest.mark.asyncio +async def test_get_verses_of_day_list_service_with_search_and_sort_order(sample_verse_list, mock_db_session): + """Test that search and sort_order are forwarded to the repository.""" + with patch("pecha_api.verse_of_day.verse_of_day_service.SessionLocal", return_value=mock_db_session), \ + patch("pecha_api.verse_of_day.verse_of_day_service.get_verses_of_day_list", return_value=(sample_verse_list, 2)) as mock_repo: + + result = get_verses_of_day_list_service(search="compassion", sort_order=SortOrder.ASC) + + assert isinstance(result, VerseOfDayListResponse) + mock_repo.assert_called_once_with( + mock_db_session.__enter__.return_value, + group_id=None, + filter_date=None, + search="compassion", + sort_order=SortOrder.ASC, skip=0, limit=100 ) diff --git a/tests/verse_of_day/test_verse_of_day_views.py b/tests/verse_of_day/test_verse_of_day_views.py index 16f29dfd4..0af433ea0 100644 --- a/tests/verse_of_day/test_verse_of_day_views.py +++ b/tests/verse_of_day/test_verse_of_day_views.py @@ -16,6 +16,7 @@ VerseOfDayListResponse, GroupInfoDTO, ) +from pecha_api.verse_of_day.verse_of_day_enums import SortOrder client = TestClient(api) @@ -819,7 +820,7 @@ async def test_cms_get_verse_of_day_list_success(sample_verse_list_response): assert len(data["verses"]) == 1 mock_validate.assert_called_once_with("valid-token") - mock_service.assert_called_once_with(group_id=None, filter_date=None, lang=None, skip=0, limit=100) + mock_service.assert_called_once_with(group_id=None, filter_date=None, lang=None, search=None, sort_order=SortOrder.DESC, skip=0, limit=100) @pytest.mark.asyncio @@ -838,7 +839,7 @@ async def test_cms_get_verse_of_day_list_with_pagination(sample_verse_list_respo assert response.status_code == status.HTTP_200_OK mock_validate.assert_called_once_with("valid-token") - mock_service.assert_called_once_with(group_id=None, filter_date=None, lang=None, skip=10, limit=20) + mock_service.assert_called_once_with(group_id=None, filter_date=None, lang=None, search=None, sort_order=SortOrder.DESC, skip=10, limit=20) @pytest.mark.asyncio @@ -859,7 +860,41 @@ async def test_cms_get_verse_of_day_list_with_filters(sample_verse_list_response assert response.status_code == status.HTTP_200_OK mock_validate.assert_called_once_with("valid-token") - mock_service.assert_called_once_with(group_id=group_id, filter_date=filter_date, lang="en", skip=0, limit=100) + mock_service.assert_called_once_with(group_id=group_id, filter_date=filter_date, lang="en", search=None, sort_order=SortOrder.DESC, skip=0, limit=100) + + +@pytest.mark.asyncio +async def test_cms_get_verse_of_day_list_with_search_and_sort_order(sample_verse_list_response): + """Test retrieval with search and ascending sort order.""" + mock_user = MagicMock() + mock_user.email = "test@example.com" + + with patch("pecha_api.verse_of_day.verse_of_day_views.validate_and_extract_user_details", return_value=mock_user) as mock_validate, \ + patch("pecha_api.verse_of_day.verse_of_day_views.get_verses_of_day_list_service", return_value=sample_verse_list_response) as mock_service: + + response = client.get( + "/cms/verse-of-day?search=compassion&sort_order=asc", + headers={"Authorization": "Bearer valid-token"} + ) + + assert response.status_code == status.HTTP_200_OK + mock_validate.assert_called_once_with("valid-token") + mock_service.assert_called_once_with(group_id=None, filter_date=None, lang=None, search="compassion", sort_order=SortOrder.ASC, skip=0, limit=100) + + +@pytest.mark.asyncio +async def test_cms_get_verse_of_day_list_invalid_sort_order(): + """Test that an invalid sort_order value is rejected.""" + mock_user = MagicMock() + mock_user.email = "test@example.com" + + with patch("pecha_api.verse_of_day.verse_of_day_views.validate_and_extract_user_details", return_value=mock_user): + response = client.get( + "/cms/verse-of-day?sort_order=sideways", + headers={"Authorization": "Bearer valid-token"} + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.mark.asyncio