From df38cf07b3a2ed9c79d4122653b693ab5a4e6727 Mon Sep 17 00:00:00 2001
From: TenzDelek <122612557+TenzDelek@users.noreply.github.com>
Date: Wed, 27 May 2026 09:36:47 +0530
Subject: [PATCH 01/29] Segment cleanup (#631)
* remove dead import
* remove the traslation and comentary
* root_text on segment leve
* remove root_text legacy
* remove segment_info
* get segment detail
* remove legacy get by id
* remove the segment detail service
* remove put and post partial
* cleanup
* fix segment fetch
* drop sheet concept
* drop text uploader
* remove config
* remove mapping
* remove all
* remvoe group
* Revert "remvoe group"
This reverts commit 72925d1298da8598cc5c504eaaf3603608cbf047.
* Revert "remove all"
This reverts commit c24962ae7e625c8340a678d23e32ac2b3e6c58ac.
* test
---
documentation/developer/api_overview.md | 2 +-
documentation/developer/architecture.md | 4 -
.../segments/openpecha_segment_service.py | 7 +
pecha_api/app.py | 12 -
pecha_api/cache/cache_enums.py | 9 -
pecha_api/config.py | 4 -
pecha_api/error_contants.py | 2 -
pecha_api/recitations/recitations_services.py | 9 +-
pecha_api/share/share_service.py | 18 +-
pecha_api/sheets/__init__.py | 0
pecha_api/sheets/sheets_enum.py | 9 -
pecha_api/sheets/sheets_models.py | 0
pecha_api/sheets/sheets_repository.py | 0
pecha_api/sheets/sheets_response_models.py | 80 -
pecha_api/sheets/sheets_service.py | 620 -----
pecha_api/sheets/sheets_views.py | 101 -
.../collections/collection_model.py | 12 -
.../collections/collection_service.py | 276 ---
.../collections/collections_repository.py | 101 -
.../collections/uploader_collections_views.py | 15 -
pecha_api/text_uploader/constants.py | 35 -
.../text_uploader/mapping/mapping_model.py | 8 -
.../mapping/mapping_repository.py | 26 -
.../text_uploader/mapping/mapping_services.py | 11 -
pecha_api/text_uploader/pipeline.py | 70 -
.../text_uploader/segments/segment_model.py | 15 -
.../segments/segment_respository.py | 135 -
.../text_uploader/segments/segment_service.py | 103 -
.../table_of_content/toc_repository.py | 13 -
.../table_of_content/toc_service.py | 57 -
.../text_metadata/text_group_repository.py | 153 --
.../text_metadata/text_metadata_model.py | 37 -
.../text_metadata/text_metadata_service.py | 170 --
.../text_metadata/text_metadata_views.py | 16 -
.../text_uploader_response_model.py | 11 -
.../text_uploader/text_uploader_views.py | 21 -
pecha_api/texts/mappings/__init__.py | 0
.../texts/mappings/mappings_repository.py | 30 -
.../mappings/mappings_response_models.py | 17 -
pecha_api/texts/mappings/mappings_service.py | 272 ---
pecha_api/texts/mappings/mappings_views.py | 27 -
.../texts/segments/segments_cache_service.py | 84 +-
pecha_api/texts/segments/segments_models.py | 28 -
.../segments/segments_openpecha_service.py | 120 +
.../segments/segments_openpecha_views.py | 39 +-
.../texts/segments/segments_repository.py | 50 +-
.../segments/segments_response_models.py | 78 +-
pecha_api/texts/segments/segments_service.py | 204 +-
pecha_api/texts/segments/segments_utils.py | 108 +-
pecha_api/texts/segments/segments_views.py | 87 -
pecha_api/texts/texts_cache_service.py | 76 +-
pecha_api/texts/texts_enums.py | 10 +
pecha_api/texts/texts_models.py | 58 -
pecha_api/texts/texts_repository.py | 23 -
pecha_api/texts/texts_service.py | 48 +-
tests/collection/test_collection_views.py | 47 -
.../recitations/test_recitations_services.py | 8 +-
tests/segments/test_segments_cache_service.py | 183 --
.../test_segments_openpecha_service.py | 288 ++-
.../segments/test_segments_openpecha_views.py | 97 +-
tests/segments/test_segments_service.py | 707 +-----
tests/segments/test_segments_utils.py | 472 ----
tests/segments/test_segments_views.py | 341 ---
tests/share/test_share_service.py | 82 +-
tests/sheets/test_sheets_service.py | 2176 -----------------
tests/sheets/test_sheets_views.py | 149 --
tests/text_uploader/__init__.py | 6 -
tests/text_uploader/collections/__init__.py | 6 -
.../collections/test_collection_service.py | 701 ------
.../mapping/test_mapping_service.py | 30 -
.../mapping/test_mapping_services.py | 31 -
tests/text_uploader/segments/__init__.py | 6 -
.../segments/test_segment_model.py | 27 -
.../segments/test_segment_repository.py | 218 --
.../segments/test_segment_service.py | 221 --
tests/text_uploader/test_pipeline.py | 158 --
.../text_uploader/test_text_uploader_views.py | 181 --
tests/text_uploader/texts/__init__.py | 6 -
.../texts/test_text_metadata_service.py | 573 -----
tests/text_uploader/texts/test_toc_service.py | 105 -
tests/texts/mappings/__init__.py | 0
tests/texts/mappings/test_delete_mappings.py | 157 --
tests/texts/mappings/test_mapping_updates.py | 164 --
tests/texts/mappings/test_mappings_service.py | 199 --
tests/texts/mappings/test_mappings_views.py | 142 --
tests/texts/test_text_cache_service.py | 173 --
tests/texts/test_text_repository.py | 261 --
tests/texts/test_texts_service.py | 200 +-
tests/texts/test_texts_views.py | 292 ---
89 files changed, 630 insertions(+), 11298 deletions(-)
delete mode 100644 pecha_api/sheets/__init__.py
delete mode 100644 pecha_api/sheets/sheets_enum.py
delete mode 100644 pecha_api/sheets/sheets_models.py
delete mode 100644 pecha_api/sheets/sheets_repository.py
delete mode 100644 pecha_api/sheets/sheets_response_models.py
delete mode 100644 pecha_api/sheets/sheets_service.py
delete mode 100644 pecha_api/sheets/sheets_views.py
delete mode 100644 pecha_api/text_uploader/collections/collection_model.py
delete mode 100644 pecha_api/text_uploader/collections/collection_service.py
delete mode 100644 pecha_api/text_uploader/collections/collections_repository.py
delete mode 100644 pecha_api/text_uploader/collections/uploader_collections_views.py
delete mode 100644 pecha_api/text_uploader/constants.py
delete mode 100644 pecha_api/text_uploader/mapping/mapping_model.py
delete mode 100644 pecha_api/text_uploader/mapping/mapping_repository.py
delete mode 100644 pecha_api/text_uploader/mapping/mapping_services.py
delete mode 100644 pecha_api/text_uploader/pipeline.py
delete mode 100644 pecha_api/text_uploader/segments/segment_model.py
delete mode 100644 pecha_api/text_uploader/segments/segment_respository.py
delete mode 100644 pecha_api/text_uploader/segments/segment_service.py
delete mode 100644 pecha_api/text_uploader/table_of_content/toc_repository.py
delete mode 100644 pecha_api/text_uploader/table_of_content/toc_service.py
delete mode 100644 pecha_api/text_uploader/text_metadata/text_group_repository.py
delete mode 100644 pecha_api/text_uploader/text_metadata/text_metadata_model.py
delete mode 100644 pecha_api/text_uploader/text_metadata/text_metadata_service.py
delete mode 100644 pecha_api/text_uploader/text_metadata/text_metadata_views.py
delete mode 100644 pecha_api/text_uploader/text_uploader_response_model.py
delete mode 100644 pecha_api/text_uploader/text_uploader_views.py
delete mode 100644 pecha_api/texts/mappings/__init__.py
delete mode 100644 pecha_api/texts/mappings/mappings_repository.py
delete mode 100644 pecha_api/texts/mappings/mappings_response_models.py
delete mode 100644 pecha_api/texts/mappings/mappings_service.py
delete mode 100644 pecha_api/texts/mappings/mappings_views.py
delete mode 100644 pecha_api/texts/segments/segments_views.py
delete mode 100644 tests/segments/test_segments_cache_service.py
delete mode 100644 tests/segments/test_segments_views.py
delete mode 100644 tests/sheets/test_sheets_service.py
delete mode 100644 tests/sheets/test_sheets_views.py
delete mode 100644 tests/text_uploader/__init__.py
delete mode 100644 tests/text_uploader/collections/__init__.py
delete mode 100644 tests/text_uploader/collections/test_collection_service.py
delete mode 100644 tests/text_uploader/mapping/test_mapping_service.py
delete mode 100644 tests/text_uploader/mapping/test_mapping_services.py
delete mode 100644 tests/text_uploader/segments/__init__.py
delete mode 100644 tests/text_uploader/segments/test_segment_model.py
delete mode 100644 tests/text_uploader/segments/test_segment_repository.py
delete mode 100644 tests/text_uploader/segments/test_segment_service.py
delete mode 100644 tests/text_uploader/test_pipeline.py
delete mode 100644 tests/text_uploader/test_text_uploader_views.py
delete mode 100644 tests/text_uploader/texts/__init__.py
delete mode 100644 tests/text_uploader/texts/test_text_metadata_service.py
delete mode 100644 tests/text_uploader/texts/test_toc_service.py
delete mode 100644 tests/texts/mappings/__init__.py
delete mode 100644 tests/texts/mappings/test_delete_mappings.py
delete mode 100644 tests/texts/mappings/test_mapping_updates.py
delete mode 100644 tests/texts/mappings/test_mappings_service.py
delete mode 100644 tests/texts/mappings/test_mappings_views.py
delete mode 100644 tests/texts/test_text_repository.py
diff --git a/documentation/developer/api_overview.md b/documentation/developer/api_overview.md
index f89a58aae..73a1f7961 100644
--- a/documentation/developer/api_overview.md
+++ b/documentation/developer/api_overview.md
@@ -11,7 +11,7 @@ The API root path is `/api/v1` (see `pecha_api/app.py`).
Routers are included in `pecha_api/app.py`:
- Auth: `pecha_api/auth/auth_views.py`
-- Core: texts, groups, segments, mappings, topics, users, collections, terms, sheets
+- Core: texts, groups, segments, topics, users, collections, terms
- Search: `pecha_api/search/search_views.py`
- Plans: public, users, CMS, items, tasks, featured
- Share: `pecha_api/share/share_views.py`
diff --git a/documentation/developer/architecture.md b/documentation/developer/architecture.md
index c951e2fd2..a858c612d 100644
--- a/documentation/developer/architecture.md
+++ b/documentation/developer/architecture.md
@@ -22,7 +22,6 @@ Routers included in `pecha_api/app.py`:
- `texts`: `pecha_api/texts/texts_views.py`
- `groups`: `pecha_api/texts/groups/groups_views.py`
- `segments`: `pecha_api/texts/segments/segments_views.py`
-- `mappings`: `pecha_api/texts/mappings/mappings_views.py`
- `topics`: `pecha_api/topics/topics_views.py`
- `users`: `pecha_api/users/users_views.py`
- `share`: `pecha_api/share/share_views.py`
@@ -39,9 +38,6 @@ Routers included in `pecha_api/app.py`:
- `recitations`: `pecha_api/recitations/recitations_view.py`
- `user follows`: `pecha_api/user_follows/user_follow_views.py`
- `user recitations`: `pecha_api/plans/users/recitation/user_recitations_views.py`
-- `text uploader`: `pecha_api/text_uploader/text_uploader_views.py`
-- `text metadata`: `pecha_api/text_uploader/text_metadata/text_metadata_views.py`
-- `uploader collections`: `pecha_api/text_uploader/collections/uploader_collections_views.py`
- `cataloger`: `pecha_api/cataloger/cataloger_views.py`
### Dependencies
diff --git a/openpecha_api/segments/openpecha_segment_service.py b/openpecha_api/segments/openpecha_segment_service.py
index 7ea540344..edea57bd4 100644
--- a/openpecha_api/segments/openpecha_segment_service.py
+++ b/openpecha_api/segments/openpecha_segment_service.py
@@ -36,3 +36,10 @@ async def fetch_segment_content(segment_id: str) -> Optional[str]:
if isinstance(value, str):
return value
return None
+
+async def fetch_segment_details(segment_id:str) :
+ client=get_authenticated_open_pecha_client()
+ http_client=client.get_async_httpx_client()
+ response=await http_client.get(f"/v2/segments/{segment_id}")
+ response.raise_for_status()
+ return response.json()
diff --git a/pecha_api/app.py b/pecha_api/app.py
index 33ad99ae2..19cb940c1 100644
--- a/pecha_api/app.py
+++ b/pecha_api/app.py
@@ -6,14 +6,11 @@
from pecha_api.db.mongo_database import lifespan
from pecha_api.auth import auth_views
-from pecha_api.sheets import sheets_views
from pecha_api.collections import collections_views
from pecha_api.terms import terms_views
from pecha_api.texts import texts_views
from pecha_api.topics import topics_views
from pecha_api.users import users_views
-from pecha_api.texts.mappings import mappings_views
-from pecha_api.texts.segments import segments_views
from pecha_api.texts.groups import groups_views
from pecha_api.share import share_views
from pecha_api.search import search_views
@@ -34,10 +31,7 @@
from pecha_api.recitations import recitations_view
from pecha_api.user_follows import user_follow_views
from pecha_api.plans.users.recitation import user_recitations_views
-from pecha_api.text_uploader import text_uploader_views
from pecha_api.cataloger import cataloger_views
-from pecha_api.text_uploader.text_metadata import text_metadata_views
-from pecha_api.text_uploader.collections import uploader_collections_views
from pecha_api.collections import collections_openpecha_views
from pecha_api.texts import texts_openpecha_views
from pecha_api.texts.segments import segments_openpecha_views
@@ -52,15 +46,12 @@
lifespan=lifespan
)
api.include_router(auth_views.auth_router)
-api.include_router(sheets_views.sheets_router)
api.include_router(collections_views.collections_router)
api.include_router(terms_views.terms_router)
api.include_router(texts_views.text_router)
api.include_router(groups_views.group_router)
-api.include_router(segments_views.segment_router)
api.include_router(topics_views.topics_router)
api.include_router(users_views.user_router)
-api.include_router(mappings_views.mapping_router)
api.include_router(search_views.search_router)
api.include_router(share_views.share_router)
api.include_router(plan_auth_views.plan_auth_router)
@@ -80,10 +71,7 @@
api.include_router(recitations_view.recitation_router)
api.include_router(user_follow_views.user_follow_router)
api.include_router(user_recitations_views.user_recitation_router)
-api.include_router(text_uploader_views.text_uploader_router)
api.include_router(cataloger_views.cataloger_router)
-api.include_router(text_metadata_views.text_metadata_router)
-api.include_router(uploader_collections_views.text_uploader_collections_router)
api.include_router(routines_views.routines_router)
api.include_router(collections_openpecha_views.collections_v2_router)
api.include_router(texts_openpecha_views.texts_v2_router)
diff --git a/pecha_api/cache/cache_enums.py b/pecha_api/cache/cache_enums.py
index 152086bf2..860bbf51f 100644
--- a/pecha_api/cache/cache_enums.py
+++ b/pecha_api/cache/cache_enums.py
@@ -8,19 +8,10 @@ class CacheType(Enum):
TEXT_TABLE_OF_CONTENTS = "text_table_of_contents"
DETAIL_TEXT_TABLE_OF_CONTENT = "detail_text_table_of_content"
- SEGMENT_DETAIL = "segment_detail"
SEGMENTS_DETAILS = "segments_details"
- SEGMENT_INFO = "segment_info"
- SEGMENT_ROOT_TEXT = "segment_root_text"
- SEGMENT_TRANSLATIONS = "segment_translations"
- SEGMENT_COMMENTARIES = "segment_commentaries"
GROUP_DETAIL = "group_detail"
- SHEETS = "sheets"
- SHEET_DETAIL = "sheet_detail"
- SHEET_TABLE_OF_CONTENT = "sheet_table_of_content"
-
USER_INFO = "user_info"
TOPICS = "topics"
diff --git a/pecha_api/config.py b/pecha_api/config.py
index 9f8206439..7490f7d2a 100644
--- a/pecha_api/config.py
+++ b/pecha_api/config.py
@@ -72,8 +72,6 @@
### text uploader script configuration
APPLICATION = "webuddhist",
- ACCESS_TOKEN="",
- COLLECTION_LANGUAGES = ["bo", "en", "zh"],
#pecha api configuration
EXTERNAL_PECHA_API_URL="",
@@ -83,8 +81,6 @@
EXTERNAL_TITLE_SEARCH_API_URL="",
- SQS_TIMEOUT=1800,
-
)
diff --git a/pecha_api/error_contants.py b/pecha_api/error_contants.py
index 51335bb11..0700ce21b 100644
--- a/pecha_api/error_contants.py
+++ b/pecha_api/error_contants.py
@@ -3,9 +3,7 @@ class ErrorConstants:
TEXT_NOT_FOUND_MESSAGE = "Text not found"
TOKEN_ERROR_MESSAGE = "Invalid or no token found"
TEXT_OR_TERM_NOT_FOUND_MESSAGE= "Text ID or Term ID is required"
- SEGMENT_MAPPING_ERROR_MESSAGE="Segment mapping update Failed"
SEGMENT_NOT_FOUND_MESSAGE = 'Segment not found'
- SAME_TEXT_MAPPING_ERROR_MESSAGE = "Mapping within same text not allowed"
TABLE_OF_CONTENT_NOT_FOUND_MESSAGE = "Table of content not found"
CONTENT_ID_NOT_FOUND_MESSAGE="Content ID is required"
GROUP_NOT_FOUND_MESSAGE="Group not found"
diff --git a/pecha_api/recitations/recitations_services.py b/pecha_api/recitations/recitations_services.py
index db862efb3..0ee422004 100644
--- a/pecha_api/recitations/recitations_services.py
+++ b/pecha_api/recitations/recitations_services.py
@@ -1,11 +1,9 @@
-from pecha_api.recitations.recitations_enum import LanguageCode,RecitationListTextType
+from pecha_api.recitations.recitations_enum import RecitationListTextType
from pecha_api.texts.texts_enums import TextType
from typing import List, Dict, Union,Optional
-from pecha_api.collections.collections_repository import get_all_collections_by_parent, get_collection_id_by_slug
-from pecha_api.collections.collections_service import get_collection
+from pecha_api.collections.collections_repository import get_collection_id_by_slug
from pecha_api.recitations.recitations_repository import apply_search_recitation_title_filter, get_text_images_by_text_ids
from pecha_api.recitations.recitations_response_models import RecitationDTO, RecitationsResponse
-from pecha_api.texts.texts_repository import get_all_texts_by_collection
from pecha_api.texts.texts_service import get_root_text_by_collection_id
from fastapi import HTTPException
from starlette import status
@@ -15,7 +13,8 @@
from pecha_api.texts.texts_utils import TextUtils
from pecha_api.texts.texts_response_models import TextDTO, TableOfContent
from pecha_api.texts.texts_repository import get_contents_by_id, get_all_texts_by_group_id
-from pecha_api.texts.segments.segments_service import get_segment_by_id, get_related_mapped_segments, get_segment_details_by_id, get_related_mapped_segments_batch, get_segments_details_by_ids
+from pecha_api.texts.segments.segments_service import get_segments_details_by_ids
+from pecha_api.texts.segments.segments_repository import get_related_mapped_segments_batch
from pecha_api.texts.segments.segments_utils import SegmentUtils
from pecha_api.texts.segments.segments_response_models import SegmentTranslation, SegmentTransliteration, SegmentAdaptation, SegmentRecitation
from pecha_api.texts.segments.segments_response_models import SegmentDTO
diff --git a/pecha_api/share/share_service.py b/pecha_api/share/share_service.py
index 64c961e4c..bc66ead26 100644
--- a/pecha_api/share/share_service.py
+++ b/pecha_api/share/share_service.py
@@ -1,11 +1,10 @@
from fastapi import HTTPException
import io
from pecha_api.error_contants import ErrorConstants
-from pecha_api.texts.segments.segments_utils import SegmentUtils
from starlette.responses import StreamingResponse
from pecha_api.texts.texts_utils import TextUtils
from .pecha_text_image_generator import generate_segment_image
-from pecha_api.texts.segments.segments_service import get_segment_details_by_id
+from pecha_api.texts.segments.segments_openpecha_service import get_openpecha_segment_details_by_id
from pecha_api.config import get
import anyio
@@ -65,16 +64,13 @@ async def _generate_segment_content_image_(share_request: ShareRequest):
main_content_text = get("SITE_NAME")
reference_text = get("SITE_NAME")
language = share_request.language
- # If segment_id is provided, get the segment details
if share_request.segment_id is not None:
- await SegmentUtils.validate_segment_exists(segment_id=share_request.segment_id)
- segment = await get_segment_details_by_id(segment_id=share_request.segment_id)
- main_content_text = segment.content
-
- text_id = segment.text_id
- text_detail = await TextUtils.get_text_detail_by_id(text_id=text_id)
- reference_text = text_detail.title
- language = text_detail.language
+ segment_details = await get_openpecha_segment_details_by_id(
+ segment_id=share_request.segment_id,
+ )
+ main_content_text = segment_details.content
+ reference_text = segment_details.text.title
+ language = segment_details.text.language
elif share_request.text_id is not None:
text_detail = await TextUtils.get_text_detail_by_id(text_id=share_request.text_id)
main_content_text = text_detail.title
diff --git a/pecha_api/sheets/__init__.py b/pecha_api/sheets/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/pecha_api/sheets/sheets_enum.py b/pecha_api/sheets/sheets_enum.py
deleted file mode 100644
index 0d527ad7e..000000000
--- a/pecha_api/sheets/sheets_enum.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from enum import Enum
-
-class SortOrder(Enum):
- ASC = "asc"
- DESC = "desc"
-
-class SortBy(Enum):
- CREATED_DATE = "created_date"
- PUBLISHED_DATE = "published_date"
diff --git a/pecha_api/sheets/sheets_models.py b/pecha_api/sheets/sheets_models.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/pecha_api/sheets/sheets_repository.py b/pecha_api/sheets/sheets_repository.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/pecha_api/sheets/sheets_response_models.py b/pecha_api/sheets/sheets_response_models.py
deleted file mode 100644
index 1ec5a2e6b..000000000
--- a/pecha_api/sheets/sheets_response_models.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from __future__ import annotations
-
-from typing import List, Optional
-
-from pydantic import BaseModel
-
-from pecha_api.texts.segments.segments_models import SegmentType
-
-class Source(BaseModel):
- position: int
- type: SegmentType
- content: str
-
-class CreateSheetRequest(BaseModel):
- title: Optional[str] = ""
- source: List[Source]
- is_published: bool = False
-
-class SheetSegment(BaseModel):
- segment_id: str
- segment_number: int
- content: Optional[str] = None
- key: Optional[str] = None
- text_title: Optional[str] = None
- language: Optional[str] = None
- type: SegmentType
-
-class SheetSection(BaseModel):
- title: Optional[str] = None
- section_number: int
- parent_id: Optional[str] = None
- segments: List[SheetSegment]
-
-
-class Publisher(BaseModel):
- name: str
- username: str
- email: str
- avatar_url: Optional[str] = None
-
-class SheetDetailDTO(BaseModel):
- id: str
- sheet_title: str
- created_date: str
- publisher: Publisher
- content: Optional[SheetSection] = None
- views: int = 0
- is_published: bool
- skip: int
- limit: int
- total: int
-
-
-class SheetDTO(BaseModel):
- id: str
- title: str
- summary: str
- published_date: str
- time_passed: str
- views: int
- is_published: bool
- likes: Optional[List[str]] = []
- publisher: Publisher
- language: Optional[str] = None
-
-class SheetDTOResponse(BaseModel):
- sheets: List[SheetDTO]
- skip: int
- limit: int
- total: int
-
-class SheetIdRequest(BaseModel):
- id: str
-
-class SheetImageResponse(BaseModel):
- url: str
- key: str
-
-class SheetIdResponse(BaseModel):
- sheet_id: str
\ No newline at end of file
diff --git a/pecha_api/sheets/sheets_service.py b/pecha_api/sheets/sheets_service.py
deleted file mode 100644
index 77d852a75..000000000
--- a/pecha_api/sheets/sheets_service.py
+++ /dev/null
@@ -1,620 +0,0 @@
-import os
-import uuid
-import re
-from typing import Optional, Dict, List
-import hashlib
-from fastapi import UploadFile, HTTPException, status
-
-
-from pecha_api.texts.texts_models import Text
-from pecha_api.error_contants import ErrorConstants
-from pecha_api.config import get
-from .sheets_response_models import CreateSheetRequest, SheetImageResponse, Publisher
-from ..uploads.S3_utils import upload_bytes, generate_presigned_access_url
-from pecha_api.image_utils import ImageUtils
-from pecha_api.utils import Utils
-from pecha_api.texts.texts_utils import TextUtils
-from pecha_api.texts.texts_cache_service import update_text_details_cache
-
-from pecha_api.users.users_models import Users
-
-from .sheets_enum import (
- SortBy,
- SortOrder
-)
-
-from pecha_api.users.users_service import (
- validate_user_exists,
- get_user_info
-)
-from pecha_api.users.user_response_models import UserInfoResponse
-
-from pecha_api.texts.groups.groups_response_models import (
- CreateGroupRequest,
- GroupDTO
-)
-from pecha_api.texts.groups.groups_enums import GroupType
-from pecha_api.texts.groups.groups_service import (
- create_new_group,
- delete_group_by_group_id
-)
-
-from pecha_api.texts.texts_response_models import (
- CreateTextRequest,
- UpdateTextRequest
-)
-from pecha_api.texts.texts_service import (
- create_new_text,
- create_table_of_content,
- remove_table_of_content_by_text_id,
- update_text_details,
- delete_text_by_text_id,
- get_sheet,
- get_table_of_content_by_sheet_id
-)
-
-from pecha_api.users.users_service import (
- validate_and_extract_user_details,
- fetch_user_by_email
-)
-from pecha_api.texts.texts_enums import TextType
-from pecha_api.texts.texts_response_models import (
- TextDTO,
- TableOfContent,
- TableOfContentType,
- Section,
- TextSegment
-)
-from pecha_api.texts.mappings.mappings_repository import get_sheet_first_content_by_ids
-
-from pecha_api.texts.segments.segments_models import SegmentType
-from pecha_api.texts.segments.segments_response_models import (
- CreateSegment,
- CreateSegmentRequest,
- SegmentResponse,
- SegmentDTO
-)
-from pecha_api.texts.segments.segments_service import (
- create_new_segment,
- remove_segments_by_text_id,
- get_segments_details_by_ids
-)
-
-from pecha_api.sheets.sheets_response_models import (
- SheetIdResponse,
- SheetDetailDTO,
- Publisher,
- SheetSection,
- SheetSegment,
- SheetDTOResponse,
- SheetDTO
-)
-from pecha_api.texts.texts_cache_service import (
- delete_text_details_by_id_cache,
- delete_table_of_content_by_sheet_id_cache
-)
-from pecha_api.texts.segments.segments_cache_service import (
- delete_segments_details_by_ids_cache
-)
-
-from pecha_api.cache.cache_enums import CacheType
-from pecha_api.cache.cache_repository import update_cache
-from pecha_api.utils import Utils
-import logging
-
-DEFAULT_SHEET_SECTION_NUMBER = 1
-
-def _strip_html_tags_(html_content: str) -> str:
- #Remove HTML tags from content and return clean text.
- clean = re.compile('<.*?>')
- return re.sub(clean, '', html_content).strip()
-
-async def _generate_sheet_summary_(sheet_id: str) -> str:
- # Generate a summary from the first content segment limited to max_words.
- try:
- # Get sheet table of content
- sheet_table_of_content: Optional[TableOfContent] = await get_table_of_content_by_sheet_id(
- sheet_id=sheet_id
- )
-
- if not sheet_table_of_content or not sheet_table_of_content.sections:
- return ""
-
- # Get all segment IDs from the table of content
- segment_ids = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_table_of_content.sections)
-
- if not segment_ids:
- return ""
-
- content_segment = await _get_sheet_first_content_details_by_type_(segment_ids=segment_ids, segment_type=SegmentType.CONTENT)
-
- if content_segment: # not NONE
- content = content_segment.content
- return clean_text(content)
- # Return empty string if no content segment found
- return ""
-
- except Exception:
- # Return empty string if any error occurs during summary generation
- return ""
-
-def clean_text(content: str) -> str:
- max_words = 30
- clean_text = _strip_html_tags_(content)
- if clean_text:
- # Split into words and limit to max_words
- words = clean_text.split()
- if len(words) <= max_words:
- return " ".join(words)
- else:
- return " ".join(words[:max_words]) + "..."
- else:
- return ""
-
-async def fetch_sheets(
- token: Optional[str] = None,
- language: Optional[str] = None,
- email: Optional[str] = None,
- sort_by: Optional[SortBy] = None,
- sort_order: Optional[SortOrder] = None,
- skip: int = 0,
- limit: int = 10
-) -> SheetDTOResponse:
-
- if email is None:
- # Case 1: Community page - show all published sheets filtered by language
- sheets = await get_sheet(
- is_published=True,
- sort_by=sort_by,
- sort_order=sort_order,
- skip=skip,
- limit=limit
- )
- else:
- sheets = await _fetch_user_sheets_(
- token = token,
- email = email,
- sort_by = sort_by,
- sort_order = sort_order,
- skip = skip,
- limit = limit
- )
-
- total = await Text.get_published_sheets_count_from_db(email=email)
- sheets: SheetDTOResponse = await _generate_sheet_dto_response_(sheets = sheets, total = total, skip = skip, limit = limit)
-
- return sheets
-
-
-async def get_sheet_by_id(sheet_id: str, skip: int, limit: int) -> SheetDetailDTO:
- sheet_details: TextDTO = await TextUtils.get_text_details_by_id(text_id=sheet_id)
- user_details: UserInfoResponse = fetch_user_by_email(email=sheet_details.published_by)
- sheet_table_of_content: Optional[TableOfContent] = await get_table_of_content_by_sheet_id(
- sheet_id=sheet_id
- )
- if sheet_table_of_content is None:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE)
-
- sections = sheet_table_of_content.sections if sheet_table_of_content else []
- sheet_dto: SheetDetailDTO = await _generate_sheet_detail_dto_(
- sheet_details=sheet_details,
- user_details=user_details,
- sheet_sections=sections,
- skip=skip,
- limit=limit
- )
- return sheet_dto
-
-async def _generate_sheet_detail_dto_(
- sheet_details: TextDTO,
- user_details: UserInfoResponse,
- sheet_sections: List[Section],
- views: int,
- skip: int,
- limit: int
-) -> SheetDetailDTO:
- publisher = Publisher(
- name=f"{user_details.firstname} {user_details.lastname}",
- username=user_details.username,
- email=user_details.email,
- avatar_url=user_details.avatar_url
- )
- segment_ids = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_sections)
- segments_dict: Dict[str, SegmentDTO] = await get_segments_details_by_ids(segment_ids=segment_ids)
-
- sheet_section: Optional[SheetSection] = None
- if sheet_sections:
- sheet_section = await _generate_sheet_section_(
- segments=sheet_sections[0].segments,
- segments_dict=segments_dict
- )
- return SheetDetailDTO(
- id=sheet_details.id,
- sheet_title=sheet_details.title,
- created_date=sheet_details.created_date,
- publisher=publisher,
- content=sheet_section,
- is_published=sheet_details.is_published,
- views=views,
- skip=skip,
- limit=limit,
- total=len(sheet_sections),
- )
-
-
-async def create_new_sheet(create_sheet_request: CreateSheetRequest, token: str) -> SheetIdResponse:
- group_id = await _create_sheet_group_(token=token)
- text_id = await _create_sheet_text_(
- title=create_sheet_request.title,
- token=token,
- group_id=group_id
- )
- sheet_segments: Dict[str, str] = await _process_and_upload_sheet_segments(
- create_sheet_request=create_sheet_request,
- text_id=text_id,
- token=token
- )
- await _generate_and_upload_sheet_table_of_content(
- create_sheet_request=create_sheet_request,
- text_id=text_id,
- segment_dict=sheet_segments,
- token=token
- )
- return SheetIdResponse(sheet_id=text_id)
-
-async def update_sheet_by_id(
- sheet_id: str,
- update_sheet_request: CreateSheetRequest,
- token: str
- ) -> SheetIdResponse:
-
- is_valid_user = validate_user_exists(token=token)
- if not is_valid_user:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE)
-
- await delete_text_details_by_id_cache(text_id=sheet_id, cache_type=CacheType.TEXT_DETAIL)
- sheet_table_of_content: Optional[TableOfContent] = await _delete_sheet_table_of_content_cache_(sheet_id=sheet_id)
-
- await _delete_sheet_segments_cache_(sheet_table_of_content=sheet_table_of_content)
-
- await remove_segments_by_text_id(text_id=sheet_id)
-
- await remove_table_of_content_by_text_id(text_id=sheet_id)
-
- await _update_text_details_(sheet_id=sheet_id, update_sheet_request=update_sheet_request)
-
- sheet_segments: Dict[str, str] = await _process_and_upload_sheet_segments(
- create_sheet_request=update_sheet_request,
- text_id=sheet_id,
- token=token
- )
- await _generate_and_upload_sheet_table_of_content(
- create_sheet_request=update_sheet_request,
- text_id=sheet_id,
- segment_dict=sheet_segments,
- token=token
- )
- sheet_details: TextDTO = await TextUtils.get_text_details_by_id(text_id=sheet_id)
-
- # Update cache with new sheet data after successful update
- await update_text_details_cache(text_id=sheet_id, updated_text_data=sheet_details, cache_type=CacheType.SHEET_DETAIL)
-
- return SheetIdResponse(sheet_id=sheet_id)
-
-async def _delete_sheet_table_of_content_cache_(sheet_id: str) -> Optional[TableOfContent]:
- sheet_table_of_content: Optional[TableOfContent] = await get_table_of_content_by_sheet_id(sheet_id=sheet_id)
- if sheet_table_of_content is not None:
- await delete_table_of_content_by_sheet_id_cache(sheet_id=sheet_id, cache_type=CacheType.SHEET_TABLE_OF_CONTENT)
- return sheet_table_of_content
-
-async def _delete_sheet_segments_cache_(sheet_table_of_content: Optional[TableOfContent]):
- sections = sheet_table_of_content.sections if sheet_table_of_content else []
- segment_ids = _get_all_segment_ids_in_table_of_content_(sheet_sections=sections)
- await delete_segments_details_by_ids_cache(segment_ids=segment_ids, cache_type=CacheType.SEGMENTS_DETAILS)
-
-async def delete_sheet_by_id(sheet_id: str, token: str):
- is_valid_user = validate_user_exists(token=token)
- if not is_valid_user:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE)
-
-
- sheet_details: TextDTO = await TextUtils.get_text_details_by_id(text_id=sheet_id)
-
- user_details: UserInfoResponse = await get_user_info(token=token)
-
- if user_details.email != sheet_details.published_by:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=ErrorConstants.FORBIDDEN_ERROR_MESSAGE
- )
-
- await delete_group_by_group_id(group_id=sheet_details.group_id)
- await remove_segments_by_text_id(text_id=sheet_id)
- await remove_table_of_content_by_text_id(text_id=sheet_id)
- await delete_text_by_text_id(text_id=sheet_id)
-
- # delete_sheet_by_id_cache(
- # sheet_id=sheet_id
- # )
-
-def upload_sheet_image_request(sheet_id: Optional[str], file: UploadFile) -> SheetImageResponse:
- # Validate and compress the uploaded image
- image_utils = ImageUtils()
- compressed_image = image_utils.validate_and_compress_image(file=file, content_type=file.content_type)
- file_name, ext = os.path.splitext(file.filename)
- unique_id = str(uuid.uuid4())
-
- # If no id is provided, use a random UUID as the folder name
- path = "images/sheet_images"
- image_path_full = f"{path}/{sheet_id}/{unique_id}" if sheet_id is not None else f"{path}/{unique_id}"
- sheet_image_name = f"{image_path_full}/{file_name}{ext}"
- upload_key = upload_bytes(
- bucket_name=get("AWS_BUCKET_NAME"),
- s3_key=sheet_image_name,
- file=compressed_image,
- content_type=file.content_type
- )
- presigned_url = generate_presigned_access_url(
- bucket_name=get("AWS_BUCKET_NAME"),
- s3_key=upload_key
- )
-
- return SheetImageResponse(url=presigned_url, key=upload_key)
-
-
-async def _generate_sheet_detail_dto_(
- sheet_details: TextDTO,
- user_details: UserInfoResponse,
- sheet_sections: List[Section],
- skip: int,
- limit: int
-) -> SheetDetailDTO:
- publisher = Publisher(
- name=f"{user_details.firstname} {user_details.lastname}",
- username=user_details.username,
- email=user_details.email,
- avatar_url=user_details.avatar_url
- )
- segment_ids = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_sections)
- segments_dict: Dict[str, SegmentDTO] = await get_segments_details_by_ids(segment_ids=segment_ids)
-
- sheet_section: Optional[SheetSection] = None
- if sheet_sections:
- sheet_section = await _generate_sheet_section_(
- segments=sheet_sections[0].segments,
- segments_dict=segments_dict
- )
-
- return SheetDetailDTO(
- id=sheet_details.id,
- sheet_title=sheet_details.title,
- created_date=sheet_details.created_date,
- is_published=sheet_details.is_published,
- publisher=publisher,
- content=sheet_section,
- skip=skip,
- limit=limit,
- total=len(sheet_sections),
- )
-
-async def _fetch_user_sheets_(token: str, email: str, sort_by: SortBy, sort_order: SortOrder, skip: int, limit: int):
- if token == "None":
- _is_sheet_published_ = True
- else:
- current_user: Users = validate_and_extract_user_details(token=token)
- _is_sheet_published_ = None if current_user.email == email else True
- sheets = await get_sheet(
- published_by=email,
- is_published=_is_sheet_published_,
- sort_by=sort_by,
- sort_order=sort_order,
- skip=skip,
- limit=limit
- )
- return sheets
-
-async def _generate_sheet_dto_response_(sheets, total, skip: int, limit: int) -> SheetDTOResponse:
- sheets_dto = [
- SheetDTO(
- id = str(sheet.id),
- title = sheet.title,
- summary = await _generate_sheet_summary_(str(sheet.id)),
- published_date = sheet.published_date,
- time_passed = Utils.time_passed(published_time=sheet.published_date, language=sheet.language),
- views = sheet.views,
- is_published = sheet.is_published,
- likes = sheet.likes or [],
- publisher = _create_publisher_object_(published_by=sheet.published_by),
- language = sheet.language
- )
- for sheet in sheets
- ]
- return SheetDTOResponse(
- sheets = sheets_dto,
- total = total,
- skip = skip,
- limit = limit
- )
-
-def _create_publisher_object_(published_by: str) -> Publisher:
-
- user_profile: UserInfoResponse = fetch_user_by_email(email=published_by)
-
- return Publisher(
- name=f"{user_profile.firstname} {user_profile.lastname}".strip() or user_profile.username,
- username=user_profile.username,
- email=user_profile.email,
- avatar_url=user_profile.avatar_url
- )
-
-async def _generate_sheet_section_(segments: List[TextSegment], segments_dict: Dict[str, SegmentDTO]) -> SheetSection:
- sheet_segments = []
- for segment in segments:
- segment_details: SegmentDTO = segments_dict[segment.segment_id]
- if segment_details.type == SegmentType.SOURCE:
- segment_text_details: TextDTO = await TextUtils.get_text_details_by_id(text_id=segment_details.text_id)
- sheet_segments.append(
- SheetSegment(
- segment_id=segment.segment_id,
- segment_number=segment.segment_number,
- content=segment_details.content,
- language=segment_text_details.language,
- text_title=segment_text_details.title,
- type=segment_details.type
- )
- )
- else:
- s3_key = None
- presigned_url = None
- if segment_details.type == SegmentType.IMAGE:
- s3_key = segment_details.content
- presigned_url = generate_presigned_access_url(
- bucket_name=get("AWS_BUCKET_NAME"),
- s3_key=segment_details.content
- )
- sheet_segments.append(
- SheetSegment(
- segment_id=segment.segment_id,
- segment_number=segment.segment_number,
- content=presigned_url if presigned_url else segment_details.content,
- type=segment_details.type,
- key=s3_key
- )
- )
-
- return SheetSection(
- section_number=DEFAULT_SHEET_SECTION_NUMBER,
- segments=sheet_segments
- )
-
-async def _get_sheet_first_content_details_by_type_(segment_ids: List[str], segment_type: SegmentType) -> Optional[str]:
-
- # Get detailed firt segment information of type segment_type
- segments_detail = await get_sheet_first_content_by_ids(segment_ids=segment_ids, segment_type=segment_type)
- return segments_detail
-
-def _get_all_segment_ids_in_table_of_content_(sheet_sections: Section) -> List[str]:
- segment_ids = []
- for section in sheet_sections:
- for segment in section.segments:
- segment_ids.append(segment.segment_id)
- return segment_ids
-
-async def _update_text_details_(sheet_id: str, update_sheet_request: CreateSheetRequest):
- update_text_request = UpdateTextRequest(
- title=update_sheet_request.title,
- is_published=update_sheet_request.is_published
- )
- await update_text_details(text_id=sheet_id, update_text_request=update_text_request)
-
-
-async def _generate_and_upload_sheet_table_of_content(
- create_sheet_request: CreateSheetRequest,
- text_id: str,
- segment_dict: Dict[str, str],
- token: str
-):
- sheet_table_of_content = _generate_sheet_table_of_content_(
- create_sheet_request=create_sheet_request,
- text_id=text_id,
- segment_dict=segment_dict
- )
- await create_table_of_content(
- table_of_content_request=sheet_table_of_content,
- token=token
- )
- return sheet_table_of_content
-
-async def _process_and_upload_sheet_segments(
- create_sheet_request: CreateSheetRequest,
- text_id: str,
- token: str
-) -> Dict[str, str]:
- create_segment_request_payload: CreateSegmentRequest = _generate_segment_creation_request_payload_(
- create_sheet_request=create_sheet_request,
- text_id=text_id
- )
- new_segments: SegmentResponse = await create_new_segment(
- create_segment_request=create_segment_request_payload,
- token=token
- )
- segment_dict: Dict[str, str] = _generate_segment_dictionary_(new_segments=new_segments)
- return segment_dict
-
-def _generate_sheet_table_of_content_(create_sheet_request: CreateSheetRequest, text_id: str, segment_dict: Dict[str, str]) -> TableOfContent:
- section = Section(
- id=str(uuid.uuid4()),
- section_number=1,
- segments=[],
- created_date=Utils.get_utc_date_time(),
- updated_date=Utils.get_utc_date_time()
- )
- for source in create_sheet_request.source:
- if source.type == SegmentType.SOURCE:
- segment = TextSegment(
- segment_number = source.position,
- segment_id = source.content
- )
- else:
- content_hash_value = hashlib.sha256(source.content.encode()).hexdigest()
- segment = TextSegment(
- segment_number = source.position,
- segment_id = segment_dict[content_hash_value]
- )
- section.segments.append(segment)
-
- table_of_content = TableOfContent(
- text_id=text_id,
- type=TableOfContentType.SHEET,
- sections=[section]
- )
-
- return table_of_content
-
-
-def _generate_segment_dictionary_(new_segments: SegmentResponse) -> Dict[str, str]:
- segment_dict = {}
- for segment in new_segments.segments:
- if segment.type != SegmentType.SOURCE:
- content_hash_value = hashlib.sha256(segment.content.encode()).hexdigest()
- segment_dict[content_hash_value] = segment.id
- return segment_dict
-
-def _generate_segment_creation_request_payload_(create_sheet_request: CreateSheetRequest, text_id: str) -> CreateSegmentRequest:
- create_segment_request = CreateSegmentRequest(
- text_id=text_id,
- segments=[]
- )
- for source in create_sheet_request.source:
- if source.type == SegmentType.SOURCE:
- continue
- create_segment_request.segments.append(
- CreateSegment(
- content=source.content,
- type=source.type
- )
- )
- return create_segment_request
-
-async def _create_sheet_text_(title: str, token: str, group_id: str) -> str:
- user_details = validate_and_extract_user_details(token=token)
- if title is None or title == "":
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorConstants.SHEET_TITLE_REQUIRED_MESSAGE)
- create_text_request = CreateTextRequest(
- title=title,
- group_id=group_id,
- language=None,
- published_by=user_details.email,
- type=TextType.SHEET
- )
- new_text: TextDTO = await create_new_text(create_text_request=create_text_request, token=token)
- return new_text.id
-
-
-async def _create_sheet_group_(token: str) -> str:
- create_group_request = CreateGroupRequest(
- type=GroupType.SHEET
- )
- new_group: GroupDTO = await create_new_group(create_group_request=create_group_request, token=token)
-
- return new_group.id
diff --git a/pecha_api/sheets/sheets_views.py b/pecha_api/sheets/sheets_views.py
deleted file mode 100644
index 1f3e34011..000000000
--- a/pecha_api/sheets/sheets_views.py
+++ /dev/null
@@ -1,101 +0,0 @@
-from __future__ import annotations
-from fastapi import APIRouter, Query
-from fastapi.security import HTTPBearer
-from starlette import status
-
-from typing import Optional
-from fastapi import APIRouter, Depends, UploadFile, File
-from fastapi.security import HTTPAuthorizationCredentials
-from typing import Annotated
-
-from .sheets_enum import (
- SortBy,
- SortOrder
-)
-from .sheets_service import (
- create_new_sheet,
- upload_sheet_image_request,
- fetch_sheets,
- get_sheet_by_id,
- update_sheet_by_id,
- delete_sheet_by_id
-)
-
-from pecha_api.sheets.sheets_response_models import SheetIdResponse
-
-from .sheets_response_models import (
- CreateSheetRequest,
- SheetDetailDTO,
- SheetDTOResponse
-)
-
-oauth2_scheme = HTTPBearer()
-sheets_router = APIRouter(
- prefix="/sheets",
- tags=["Sheets"]
-)
-
-@sheets_router.get("", status_code=status.HTTP_200_OK)
-async def get_sheets(
- authentication_credential: Annotated[Optional[HTTPAuthorizationCredentials], Depends(HTTPBearer(auto_error=False))],
- language: Optional[str] = Query(default=None),
- email: Optional[str] = Query(default=None),
- sort_by: Optional[SortBy] = Query(default=None),
- sort_order: Optional[SortOrder] = Query(default=None),
- skip: int = Query(default=0),
- limit: int = Query(default=10)
-) -> SheetDTOResponse:
- return await fetch_sheets(
- token=authentication_credential.credentials if authentication_credential else None,
- language=language,
- email=email,
- sort_by=sort_by,
- sort_order=sort_order,
- skip=skip,
- limit=limit
- )
-
-@sheets_router.get("/{sheet_id}", status_code=status.HTTP_200_OK)
-async def get_sheet(
- sheet_id: str,
- skip: int = Query(default=0),
- limit: int = Query(default=10)
-) -> SheetDetailDTO:
- return await get_sheet_by_id(sheet_id=sheet_id, skip=skip, limit=limit)
-
-@sheets_router.post("", status_code=status.HTTP_201_CREATED)
-async def create_sheet(
- create_sheet_request: CreateSheetRequest,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)],
-) -> SheetIdResponse:
- return await create_new_sheet(
- create_sheet_request=create_sheet_request,
- token=authentication_credential.credentials
- )
-
-
-@sheets_router.put("/{sheet_id}", status_code=status.HTTP_200_OK)
-async def update_sheet(
- sheet_id: str,
- update_sheet_request: CreateSheetRequest,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)],
-) -> SheetIdResponse:
- return await update_sheet_by_id(
- sheet_id=sheet_id,
- update_sheet_request=update_sheet_request,
- token=authentication_credential.credentials
- )
-
-@sheets_router.delete("/{sheet_id}", status_code=status.HTTP_204_NO_CONTENT)
-async def delete_sheet(
- sheet_id: str,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)],
-):
- await delete_sheet_by_id(
- sheet_id=sheet_id,
- token=authentication_credential.credentials
- )
-
-@sheets_router.post("/upload", status_code=status.HTTP_201_CREATED)
-def upload_sheet_image(sheet_id: Optional[str] = None, file: UploadFile = File(...)):
- return upload_sheet_image_request(sheet_id=sheet_id, file=file)
diff --git a/pecha_api/text_uploader/collections/collection_model.py b/pecha_api/text_uploader/collections/collection_model.py
deleted file mode 100644
index 3e181c6c6..000000000
--- a/pecha_api/text_uploader/collections/collection_model.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from pydantic import BaseModel
-from typing import Optional, Dict
-
-class CollectionPayload(BaseModel):
- pecha_collection_id: Optional[str] = None
- slug: str
- titles: Dict[str, str]
- descriptions: Dict[str, str]
- parent_id: Optional[str]
-
-
-
\ No newline at end of file
diff --git a/pecha_api/text_uploader/collections/collection_service.py b/pecha_api/text_uploader/collections/collection_service.py
deleted file mode 100644
index 48f49930d..000000000
--- a/pecha_api/text_uploader/collections/collection_service.py
+++ /dev/null
@@ -1,276 +0,0 @@
-from typing import Any
-from pecha_api.text_uploader.collections.collections_repository import get_collections, post_collections
-from pecha_api.text_uploader.constants import COLLECTION_LANGUAGES
-from pecha_api.text_uploader.collections.collection_model import CollectionPayload
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-
-from pecha_api.text_uploader.collections.collections_repository import get_collection_by_pecha_collection_id
-
-import logging
-
-class CollectionService:
-
- async def upload_collections(self, text_upload_request: TextUploadRequest, token: str):
-
- await self.build_recursive_multilingual_payloads(
- destination_url=text_upload_request.destination_url,
- openpecha_api_url=text_upload_request.openpecha_api_url,
- access_token=token
- )
-
-
- async def get_collections_service(self, openpecha_api_url: str, parent_id: str | None = None):
- return await get_collections(
- openpecha_api_url=openpecha_api_url,
- languages=COLLECTION_LANGUAGES,
- parent_id=parent_id
- )
-
- async def build_recursive_multilingual_payloads(
- self,
- destination_url: str,
- openpecha_api_url: str,
- access_token: str,
- remote_parent_id: str | None = None,
- local_parent_id: str | None = None
- ) -> list[dict[str, Any]]:
-
- # Fetch collections for this level from the remote (OpenPecha) API.
- # This `remote_parent_id` is **only** for traversing the source tree.
- collections_by_language = await self.get_collections_service(
- openpecha_api_url=openpecha_api_url,
- parent_id=remote_parent_id
- )
-
- # Build the multilingual payload for the current level. This returns one
- # combined payload per `pecha_collection_id`, where `titles` and
- # `descriptions` contain entries for all languages.
- multilingual_payloads = self.build_multilingual_payload(
- collections_by_language
- )
-
- # First, upload collections at this level to the destination (webuddhist)
- # backend, capturing the *destination* IDs so they can be used as
- # `parent_id` for the next level.
- for payload in multilingual_payloads:
- # Determine parent_id: if local_parent_id is provided, use it.
- # Otherwise, look up the parent in the CSV using the parent's pecha_collection_id
- parent_id_to_use = local_parent_id
-
- # If we don't have a local_parent_id but this collection has a parent,
- # try to find the parent's destination ID from the CSV log
- if parent_id_to_use is None:
- parent_pecha_id = payload.get("parent_id")
- if parent_pecha_id:
- # Normalize parent pecha_collection_id if it's in dict format
- if isinstance(parent_pecha_id, dict) and "$oid" in parent_pecha_id:
- parent_pecha_id = str(parent_pecha_id["$oid"])
- elif parent_pecha_id is not None:
- parent_pecha_id = str(parent_pecha_id)
-
- # Look up parent's destination ID from CSV
- # if parent_pecha_id:
- # parent_id_to_use = get_parent_id_by_pecha_collection_id(parent_pecha_id)
- # if payload.get("slug") is None:
- # continue
- collection_model = CollectionPayload(
- pecha_collection_id=payload.get("pecha_collection_id"),
- slug=payload.get("slug"),
- titles=payload.get("titles"),
- descriptions=payload.get("descriptions"),
- # Use the resolved parent_id (from parameter or CSV lookup)
- parent_id=parent_id_to_use,
- )
-
-
- existing_collection_id = await get_collection_by_pecha_collection_id(pecha_collection_id=payload.get("pecha_collection_id"), destination_url=destination_url)
- # Upload to webuddhist backend. We send the full multilingual
- # payload body, and use "en" as the request language context.
- response_data = {}
- if not existing_collection_id:
- response_data = await post_collections(destination_url=destination_url, language="en", collection_model=collection_model, access_token=access_token)
- logging.info(f" '{payload.get('slug')!r}' uploaded successfully")
- else:
- logging.warning(f" '{payload.get('slug')!r}' ALREADY EXIST, skipping")
-
- # Extract the newly created destination collection ID so it can be
- # used as the parent for this node's children.
-
- raw_local_id = (
- response_data.get("id")
- or response_data.get("_id")
- or response_data.get("collection_id")
- )
- if isinstance(raw_local_id, dict) and "$oid" in raw_local_id:
- payload["local_id"] = str(raw_local_id["$oid"])
- elif raw_local_id is not None:
- payload["local_id"] = str(raw_local_id)
- else:
- payload["local_id"] = None
-
-
- # For each payload that has children, fetch and attach them recursively.
- for payload in multilingual_payloads:
- has_sub_child = payload.get("has_sub_child") or payload.get("has_child")
-
- if not has_sub_child:
- payload["children"] = []
- continue
-
- # Normalise the **remote/source** ID that should be used as
- # `remote_parent_id` for the next level when talking to OpenPecha.
- raw_remote_id = payload.get("pecha_collection_id")
- next_remote_parent_id: str | None
- if isinstance(raw_remote_id, dict) and "$oid" in raw_remote_id:
- next_remote_parent_id = str(raw_remote_id["$oid"])
- elif raw_remote_id is not None:
- next_remote_parent_id = str(raw_remote_id)
- else:
- next_remote_parent_id = None
-
- # If we don't have a usable remote parent id, we can't descend
- # further in the source API.
- if next_remote_parent_id is None:
- payload["children"] = []
- continue
-
- # The *local* parent_id for children is the ID we just created in
- # the destination backend for this node.
- next_local_parent_id: str | None = payload.get("local_id")
-
- # Recurse asynchronously for the next level.
- payload["children"] = await self.build_recursive_multilingual_payloads(
- destination_url=destination_url,
- openpecha_api_url=openpecha_api_url,
- access_token=access_token,
- remote_parent_id=next_remote_parent_id,
- local_parent_id=next_local_parent_id,
- )
-
- return multilingual_payloads
-
- def build_multilingual_payload(
- self, collections_by_language: list[dict[str, Any]]
- ) -> list[dict[str, Any]]:
- """
- Create a payload that combines collections with the same ID across
- multiple languages into a **single multilingual document per ID**.
-
- Input (from `get_collections`):
-
- [
- {
- "language": "en",
- "collections": [
- {
- "_id": {"$oid": "..."},
- "slug": "Liturgy",
- "title": "Liturgy",
- "description": "Prayers and rituals",
- "parent_id": null,
- "has_sub_child": true
- },
- ...
- ],
- },
- {
- "language": "bo",
- "collections": [
- {
- "_id": {"$oid": "..."},
- "slug": "Liturgy",
- "title": "ཁ་འདོན།",
- "description": "ཆོ་ག་དང་འདོན་ཆ།",
- "parent_id": null,
- "has_sub_child": true
- },
- ...
- ],
- },
- ]
-
- Output (one combined payload per `pecha_collection_id`):
-
- {
- "pecha_collection_id": "sfsfsf-sfsdf",
- "slug": "Madhyamaka",
- "titles": {
- "en": "Madhyamaka",
- "bo": "དབུ་མ།"
- },
- "descriptions": {
- "en": "Madhyamaka treatises",
- "bo": "དབུ་མའི་གཞུང་སྣ་ཚོགས།"
- },
- "parent_id": null,
- "has_sub_child": true | false
- }
- """
-
- combined: dict[str, dict[str, Any]] = {}
-
- for entry in collections_by_language:
- language = entry["language"]
- for collection in entry["collections"]:
- # --- ID handling -------------------------------------------------
- # Prefer `id`, fall back to `_id` if necessary.
- raw_id = collection.get("id", collection.get("_id"))
-
- # Normalise ID to a string key, and also store this as
- # `pecha_collection_id` so that the final payload field is a
- # simple string (as in the desired output example).
- if isinstance(raw_id, dict) and "$oid" in raw_id:
- id_key = str(raw_id["$oid"])
- else:
- id_key = str(raw_id)
-
- # --- Parent / child flags ---------------------------------------
- parent_value = collection.get("parent_id", collection.get("parent"))
- has_sub_child_value = collection.get(
- "has_sub_child", collection.get("has_child")
- )
-
- if id_key not in combined:
- combined[id_key] = {
- "pecha_collection_id": id_key,
- "slug": collection.get("slug"),
- "titles": {},
- # Always initialise description keys for all configured languages.
- "descriptions": {lang: "" for lang in COLLECTION_LANGUAGES},
- "parent_id": parent_value,
- "has_sub_child": has_sub_child_value,
- }
-
- # --- Titles / descriptions --------------------------------------
- title_value = collection.get("title")
- if title_value is None:
- title_value = collection.get("titles", {}).get(language)
-
- description_value = collection.get("description")
- if description_value is None:
- description_value = collection.get("descriptions", {}).get(
- language
- )
-
- if title_value is not None:
- combined[id_key]["titles"][language] = title_value
-
- # Always use the English title as the slug so that all
- # per-language payloads sharing this collection ID have a
- # consistent, human-readable slug.
- if language == "en":
- combined[id_key]["slug"] = title_value
-
- if description_value is not None:
- # Ensure the descriptions dictionary exists (it should from
- # initialisation, but we guard just in case).
- if "descriptions" not in combined[id_key]:
- combined[id_key]["descriptions"] = {}
- combined[id_key]["descriptions"][language] = description_value
-
- # Return the combined multilingual nodes directly – one payload per
- # `pecha_collection_id`, with `titles` and `descriptions` merged across
- # languages that share the same ID.
- return list(combined.values())
-
-
diff --git a/pecha_api/text_uploader/collections/collections_repository.py b/pecha_api/text_uploader/collections/collections_repository.py
deleted file mode 100644
index 91da64175..000000000
--- a/pecha_api/text_uploader/collections/collections_repository.py
+++ /dev/null
@@ -1,101 +0,0 @@
-from typing import Any
-import asyncio
-
-import requests
-
-from typing import Optional
-
-from pecha_api.text_uploader.constants import APPLICATION, DestinationURL, ACCESS_TOKEN
-from pecha_api.text_uploader.collections.collection_model import CollectionPayload
-from pecha_api.collections.collections_repository import create_collection
-
-
-async def get_collections(
- openpecha_api_url: str, languages: list[str], parent_id: str | None = None
-) -> list[dict[str, Any]]:
- """
- Fetch the list of collections (categories) from the remote API for
- a list of languages.
-
- The remote API expects `application` and `language` as query parameters,
- e.g. `...?application=webuddhist&language=en`.
-
- Returns a list in the form:
-
- [
- {
- "language": "en",
- "collections": [ ... raw API items ... ],
- },
- {
- "language": "bo",
- "collections": [ ... raw API items ... ],
- },
- ]
- """
- all_collections: list[dict[str, Any]] = []
- categories_url = f"{openpecha_api_url}/v2/categories/"
-
- for language in languages:
- params = {
- "application": APPLICATION,
- "language": language,
- "parent_id": parent_id,
- }
-
- # `requests` is synchronous; run it in a thread so we can still await it.
- response = await asyncio.to_thread(
- requests.get,
- categories_url,
- params=params,
- )
- response.raise_for_status()
-
- all_collections.append(
- {
- "language": language,
- "collections": response.json(),
- }
- )
-
- return all_collections
-
-async def get_collection_by_pecha_collection_id(pecha_collection_id: str, destination_url: str) -> Optional[str]:
- text_metadata_url = f"{destination_url}/text-uploader/collections/{pecha_collection_id}"
- response = await asyncio.to_thread(requests.get, text_metadata_url)
- response.raise_for_status()
- return response.json()
-
-
-async def post_collections(destination_url: str, language: str, collection_model: CollectionPayload, access_token: str) -> dict[str, Any]:
- url = f"{destination_url}/collections"
- headers = {
- "Authorization": f"Bearer {access_token}",
- "Content-Type": "application/json",
- }
- params = {"language": language}
- payload = collection_model.model_dump()
-
- # `requests` is synchronous; run it in a thread so we can still await it.
- response = await asyncio.to_thread(
- requests.post,
- url,
- headers=headers,
- params=params,
- json=payload,
- )
-
- if not response.ok:
- print(
- f"POST /collections failed "
- f"(language={language}) status={response.status_code} "
- f"body={response.text}"
- )
-
- return response.json()
-
-
-
-async def upload_collection(CollectionPayload) -> dict[str, Any]:
- return await create_collection(create_collection_request=CollectionPayload)
-
diff --git a/pecha_api/text_uploader/collections/uploader_collections_views.py b/pecha_api/text_uploader/collections/uploader_collections_views.py
deleted file mode 100644
index ad5920e7b..000000000
--- a/pecha_api/text_uploader/collections/uploader_collections_views.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from fastapi import APIRouter
-from starlette import status
-from typing import Optional
-
-from pecha_api.collections.collections_service import get_collection_by_pecha_collection_id_service
-text_uploader_collections_router = APIRouter(
- prefix="/text-uploader/collections",
- tags=["Text Uploader"]
-)
-
-@text_uploader_collections_router.get("/{pecha_collection_id}", status_code=status.HTTP_200_OK)
-async def get_collection_by_pecha_collection_id(pecha_collection_id: str) -> Optional[str]:
- return await get_collection_by_pecha_collection_id_service(
- pecha_collection_id=pecha_collection_id
- )
\ No newline at end of file
diff --git a/pecha_api/text_uploader/constants.py b/pecha_api/text_uploader/constants.py
deleted file mode 100644
index 877267221..000000000
--- a/pecha_api/text_uploader/constants.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from enum import Enum
-
-
-
-APPLICATION = "webuddhist"
-
-ACCESS_TOKEN = "1234567890"
-
-COLLECTION_LANGUAGES = ["bo", "en", "zh"]
-
-class TextType(Enum):
- COMMENTARY = "commentary"
- ROOT = "root"
- TRANSLATION = "translation"
- TRANSLATION_SOURCE = "translation_source"
- NONE = None
-
-VERSION_TEXT_TYPE = [TextType.TRANSLATION.value, TextType.ROOT.value, TextType.TRANSLATION_SOURCE.value, TextType.NONE.value, TextType.ROOT.value]
-
-
-class OpenPechaAPIURL(Enum):
- DEVELOPMENT = "https://api-l25bgmwqoa-uc.a.run.app"
- PRODUCTION = "https://api-aq25662yyq-uc.a.run.app"
- STAGING = "https://api-kwgjscy6gq-uc.a.run.app"
-
-class SQSURL(Enum):
- DEVELOPMENT = "https://sqs-uploader-service.onrender.com"
-
-
-class DestinationURL(Enum):
- PRODUCTION = "https://api.webuddhist.com/api/v1"
- STAGING = "https://webuddhist-tst-backend.onrender.com/api/v1"
- DEVELOPMENT = "https://webuddhist-dev-backend.onrender.com/api/v1"
- LOCAL = "http://localhost:8000/api/v1"
-
diff --git a/pecha_api/text_uploader/mapping/mapping_model.py b/pecha_api/text_uploader/mapping/mapping_model.py
deleted file mode 100644
index 6b6225356..000000000
--- a/pecha_api/text_uploader/mapping/mapping_model.py
+++ /dev/null
@@ -1,8 +0,0 @@
-from pydantic import BaseModel
-from typing import List
-
-class TriggerMappingPayload(BaseModel):
- text_ids: List[str]
- source: str
- destination: str
-
diff --git a/pecha_api/text_uploader/mapping/mapping_repository.py b/pecha_api/text_uploader/mapping/mapping_repository.py
deleted file mode 100644
index ea2a6b241..000000000
--- a/pecha_api/text_uploader/mapping/mapping_repository.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from pecha_api.text_uploader.constants import SQSURL
-from pecha_api.config import get_int
-from pecha_api.text_uploader.mapping.mapping_model import TriggerMappingPayload
-import httpx
-from fastapi import HTTPException
-
-async def trigger_mapping_repo(text_ids: list[str], source: str, destination: str):
-
- url = f"{SQSURL.DEVELOPMENT.value}/job/text-ids"
- headers = {
- "Content-Type": "application/json",
- }
- timeout = get_int("SQS_TIMEOUT")
-
- payload = TriggerMappingPayload(text_ids=text_ids, source=source, destination=destination).model_dump()
- print("payload>>>>>>>>>>>>>>>>", payload)
- async with httpx.AsyncClient(timeout=timeout) as client:
- response = await client.post(
- url,
- json=payload,
- headers=headers
- )
-
- if not response.is_success:
- raise HTTPException(status_code=response.status_code, detail=response.text)
- return response.json()
\ No newline at end of file
diff --git a/pecha_api/text_uploader/mapping/mapping_services.py b/pecha_api/text_uploader/mapping/mapping_services.py
deleted file mode 100644
index de62d2406..000000000
--- a/pecha_api/text_uploader/mapping/mapping_services.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from pecha_api.text_uploader.mapping.mapping_repository import trigger_mapping_repo
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-class MappingService:
- def __init__(self):
- self.all_text = {}
-
- async def trigger_mapping(self, text_ids: dict[str, str], text_upload_request: TextUploadRequest):
- text_ids_list = list(text_ids.values())
- source = text_upload_request.openpecha_api_url
- destination = text_upload_request.destination_url
- await trigger_mapping_repo(text_ids_list, source, destination)
\ No newline at end of file
diff --git a/pecha_api/text_uploader/pipeline.py b/pecha_api/text_uploader/pipeline.py
deleted file mode 100644
index 0d959a56e..000000000
--- a/pecha_api/text_uploader/pipeline.py
+++ /dev/null
@@ -1,70 +0,0 @@
-import asyncio
-from fastapi import HTTPException
-from starlette import status
-from pecha_api.error_contants import ErrorConstants
-
-
-from pecha_api.text_uploader.collections.collection_service import CollectionService
-from pecha_api.text_uploader.text_metadata.text_metadata_service import TextMetadataService
-from pecha_api.text_uploader.segments.segment_service import SegmentService
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-from pecha_api.text_uploader.table_of_content.toc_service import TocService
-from pecha_api.text_uploader.mapping.mapping_services import MappingService
-from pecha_api.users.users_service import verify_admin_access
-from pecha_api.text_uploader.constants import DestinationURL, OpenPechaAPIURL
-
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadResponse
-
-
-async def pipeline(text_upload_request: TextUploadRequest, token: str)-> TextUploadResponse:
-
- destination_url_enum = text_upload_request.destination_url
- openpecha_api_url_enum = text_upload_request.openpecha_api_url
- destination_url = DestinationURL[destination_url_enum].value
- openpecha_api_url = OpenPechaAPIURL[openpecha_api_url_enum].value
-
- text_upload_request_payload = TextUploadRequest(
- destination_url=destination_url,
- openpecha_api_url=openpecha_api_url,
- text_id=text_upload_request.text_id,
- )
-
- is_admin = verify_admin_access(token=token)
- if not is_admin:
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE)
-
- # # collection upload
- collection = CollectionService()
- await collection.upload_collections(text_upload_request=text_upload_request_payload, token=token)
-
- # text metadata upload
- text_metadata = TextMetadataService()
- instance_ids_response = await text_metadata.upload_text_metadata_service(text_upload_request=text_upload_request_payload, token=token)
-
- new_texts = instance_ids_response.new_text
- all_text = instance_ids_response.all_text
-
- new_uploaded_texts_count = len(new_texts)
-
- if new_uploaded_texts_count > 0:
- # segment upload
- segment = SegmentService()
- await segment.upload_segments(text_upload_request=text_upload_request_payload,text_ids = new_texts, token=token)
-
- # table of content upload
- toc = TocService()
- await toc.upload_toc(text_ids=new_texts, text_upload_request=text_upload_request_payload, token=token)
-
-
-
- #mapping upload
- if text_upload_request.destination_url != DestinationURL.LOCAL.name:
- mapping = MappingService()
- await mapping.trigger_mapping(text_ids=all_text, text_upload_request=text_upload_request)
-
-
- if new_uploaded_texts_count > 0:
-
- return TextUploadResponse(message=new_texts)
- else:
- return TextUploadResponse(message="All texts are already uploaded")
\ No newline at end of file
diff --git a/pecha_api/text_uploader/segments/segment_model.py b/pecha_api/text_uploader/segments/segment_model.py
deleted file mode 100644
index aaec55ac7..000000000
--- a/pecha_api/text_uploader/segments/segment_model.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from pydantic import BaseModel
-from typing import Any, Optional
-
-class Segment(BaseModel):
- content: str
- type: str
-
-class SegmentModel(BaseModel):
- text_id: str
- segments: list[Segment]
-
-class ManifestationModel(BaseModel):
- job_id: Optional[str] = None
- status: Optional[str] = None
- message: Optional[str] = None
\ No newline at end of file
diff --git a/pecha_api/text_uploader/segments/segment_respository.py b/pecha_api/text_uploader/segments/segment_respository.py
deleted file mode 100644
index c9d86aed1..000000000
--- a/pecha_api/text_uploader/segments/segment_respository.py
+++ /dev/null
@@ -1,135 +0,0 @@
-from typing import Any, List
-
-import asyncio
-import requests
-
-
-async def get_segments_annotation(pecha_text_id: str, openpecha_api_url: str) -> list[dict[str, Any]]:
- """
- Fetch the list of segment annotations for a given Pecha text (instance).
- """
- instances_url = f"{openpecha_api_url}/v2/instances/{pecha_text_id}"
- params = {
- "annotation": "true",
- "content": "true"
- }
-
- # `requests` is synchronous; run it in a thread so callers can still await.
- response = await asyncio.to_thread(
- requests.get,
- instances_url,
- params=params,
- )
- response.raise_for_status()
-
- # The API is expected to return JSON, usually a list of instances/segments.
- return response.json()
-
-async def get_segments_id_by_annotation_id(annotation_id: str, openpecha_api_url: str) -> list[dict[str, Any]]:
- url = f"{openpecha_api_url}/v2/annotations/{annotation_id}"
- response = await asyncio.to_thread(requests.get, url)
- response.raise_for_status()
- return response.json()
-
-async def get_segments_by_id(annotation_id: str, openpecha_api_url: str) -> dict[str, Any]:
- """
- Fetch a single segmentation annotation by its ID.
- """
- url = (
- f"{openpecha_api_url}/v2/annotations/{annotation_id}"
- )
-
- response = await asyncio.to_thread(
- requests.get,
- url,
- )
- response.raise_for_status()
-
- return response.json()
-
-
-async def get_segment_content(
- segment_id: List[str], pecha_text_id: str, openpecha_api_url: str
-) -> list[dict[str, Any]]:
-
- url = (
- f"{openpecha_api_url}"
- f"/v2/instances/{pecha_text_id}/segment-content"
- )
- payload = {
- "segment_ids": segment_id
- }
-
- try:
- response = await asyncio.to_thread(
- requests.post,
- url,
- json=payload,
- timeout=100000,
- )
- response.raise_for_status()
- return response.json()
- except requests.exceptions.HTTPError as e:
- print(f"HTTP Error in get_segment_content: {e}")
- print(f"Status Code: {response.status_code}")
- print(f"Response: {response.text}")
- raise
- except requests.exceptions.RequestException as e:
- print(f"Request Error in get_segment_content: {e}")
- raise
- except Exception as e:
- print(f"Unexpected Error in get_segment_content: {e}")
- raise
-
-
-async def post_segments(
- segments_payload: dict[str, Any],
- destination_url: str,
- token: str,
-) -> dict[str, Any]:
-
- url = f"{destination_url}/segments"
- headers = {
- "Authorization": f"Bearer {token}",
- "Content-Type": "application/json",
- }
- # payload: dict[str, Any] = {
- # "text_id": segments_payload["text_id"],
- # "segments": segments_payload["segments"],
- # }
-
- response = await asyncio.to_thread(
- requests.post,
- url,
- headers=headers,
- json=segments_payload,
- )
-
- if not response.ok:
- print(
- "POST /segments failed"
- f"(text_id={segments_payload['text_id']}) status={response.status_code} "
- f"body={response.text}"
- )
- response.raise_for_status()
-
- return response.json()
-
-
-
-async def get_segments_by_id(annotation_id: str, openpecha_api_url: str) -> dict[str, Any]:
- """
- Fetch a single segmentation annotation by its ID.
- """
- url = (
- f"{openpecha_api_url}/v2/annotations/{annotation_id}"
- )
-
- response = await asyncio.to_thread(
- requests.get,
- url,
- )
- response.raise_for_status()
-
- return response.json()
-
diff --git a/pecha_api/text_uploader/segments/segment_service.py b/pecha_api/text_uploader/segments/segment_service.py
deleted file mode 100644
index f1c92d58b..000000000
--- a/pecha_api/text_uploader/segments/segment_service.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import logging
-from typing import Any, List
-from pecha_api.text_uploader.segments.segment_respository import (
- get_segments_annotation,
- post_segments,
- get_segments_id_by_annotation_id,
- get_segments_by_id
-)
-
-from pecha_api.texts.segments.segments_repository import get_segments_by_text_id
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-
-logging.basicConfig(level=logging.INFO)
-
-class SegmentService:
-
- async def upload_segments(self, text_upload_request: TextUploadRequest, text_ids: dict[str, Any], token: str):
-
- try:
- for text_id, pecha_text_id in text_ids.items():
-
- if await self.is_text_segments_uploaded(text_id):
- print(f"Segments for text_id {pecha_text_id} already uploaded. Skipping...")
- continue
-
- instance = await self.get_segments_annotation_by_pecha_text_id(text_upload_request, pecha_text_id)
- annotation_ids = self.get_annotation_ids(instance)
- annotation_sengments = await get_segments_id_by_annotation_id(annotation_ids[0], text_upload_request.openpecha_api_url)
- segments_ids = [segment["id"] for segment in annotation_sengments["data"]]
- # Process segment_ids in batches to manage batch size
-
- # segments_contents = await self.make_batch_segments_content(segments_ids, pecha_text_id)
- segments_contents = self.parse_segments_content(annotation_sengments["data"], instance['content'])
-
- await self.upload_bulk_segments(text_id, segments_contents,text_upload_request, token)
- except Exception as e:
- print("Error: ", e)
-
-
- def parse_segments_content(self, segments_annotation: List[dict[str, Any]], base_text: str) -> List[dict[str, Any]]:
- segments_content = []
- for segment in segments_annotation:
- segment_content = base_text[segment["span"]["start"]:segment["span"]["end"]]
- segments_content.append({
- "segment_id": segment["id"],
- "content": segment_content
- })
- return segments_content
-
-
- async def upload_bulk_segments(self, text_id: str, segments_content: List[dict[str, Any]], text_upload_request, token: str) -> List[dict[str, Any]]:
- """
- Create and post segments in batches to avoid payload size limits.
- """
- batch_size = 400 # Adjust batch size as needed
- total_segments = len(segments_content)
-
- print(f"\nPosting {total_segments} segments in batches of {batch_size}...\n")
-
- for i in range(0, total_segments, batch_size):
- batch = segments_content[i:i + batch_size]
- batch_number = (i // batch_size) + 1
- total_batches = (total_segments + batch_size - 1) // batch_size
-
- payload = {
- "text_id": text_id,
- "segments": [
- {
- "pecha_segment_id": segment["segment_id"],
- "content": segment["content"],
- "type": "source",
- } for segment in batch
- ]
- }
-
- logging.info(f"Posting batch {batch_number}/{total_batches} ({len(batch)} segments)...\n")
- await post_segments(payload, text_upload_request.destination_url, token)
-
- async def get_segments_annotation_by_pecha_text_id(
- self, text_upload_request: TextUploadRequest, pecha_text_id: str
- ) -> dict[str, Any]:
- return await get_segments_annotation(pecha_text_id, text_upload_request.openpecha_api_url)
-
-
- def get_annotation_ids(self, instance: dict[str, Any]) -> list[str]:
- annotations = instance["annotations"] or []
- segmentation_ids: list[str] = []
- for annotation in annotations:
- if annotation["type"] == "segmentation":
- segmentation_ids.append(annotation["annotation_id"])
- return segmentation_ids
-
-
- async def is_text_segments_uploaded(self, text_id: str) -> bool:
- segments = await get_segments_by_text_id(text_id)
- return len(segments) > 0
-
-
- async def get_segments_by_id_list(self, annotation_ids: str, text_upload_request: TextUploadRequest) -> List[dict[str, Any]]:
-
- segments = await get_segments_by_id(annotation_ids, text_upload_request.openpecha_api_url)
- return segments
-
diff --git a/pecha_api/text_uploader/table_of_content/toc_repository.py b/pecha_api/text_uploader/table_of_content/toc_repository.py
deleted file mode 100644
index 5dad43460..000000000
--- a/pecha_api/text_uploader/table_of_content/toc_repository.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from typing import Any
-import asyncio
-import requests
-
-async def post_toc(toc_payload: dict[str, Any], destination_url: str, token: str):
- url = f"{destination_url}/texts/table-of-content"
- headers = {
- "Authorization": f"Bearer {token}",
- "Content-Type": "application/json",
- }
- response = await asyncio.to_thread(requests.post, url, headers=headers, json=toc_payload)
- response.raise_for_status()
- return response.json()
\ No newline at end of file
diff --git a/pecha_api/text_uploader/table_of_content/toc_service.py b/pecha_api/text_uploader/table_of_content/toc_service.py
deleted file mode 100644
index 248b44282..000000000
--- a/pecha_api/text_uploader/table_of_content/toc_service.py
+++ /dev/null
@@ -1,57 +0,0 @@
-from typing import Any
-import uuid
-import logging
-
-from pecha_api.text_uploader.segments.segment_service import SegmentService
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-
-from pecha_api.text_uploader.table_of_content.toc_repository import post_toc
-
-
-logging.basicConfig(level=logging.INFO)
-
-class TocService:
- def __init__(self):
- self.segment_service = SegmentService()
-
- async def upload_toc(self, text_ids: dict[str, Any], text_upload_request: TextUploadRequest, token: str):
-
- for text_id, pecha_text_id in text_ids.items():
- # Check if TOC already uploaded for this pecha_text_id
-
- instance = await self.segment_service.get_segments_annotation_by_pecha_text_id(text_upload_request, pecha_text_id)
- annotation_ids = self.segment_service.get_annotation_ids(instance)
- annotation_segments = await self.segment_service.get_segments_by_id_list(annotation_ids[0], text_upload_request)
- ordered_segments = await self.order_segments_by_annotation_span(annotation_segments)
- toc_payload = self.create_toc_payload(ordered_segments, text_id)
-
-
- await post_toc(toc_payload, text_upload_request.destination_url, token)
- logging.info(f'Table of Content uploaded successfully for text_id: {text_id}')
-
-
- async def order_segments_by_annotation_span(self, annotation_segments: dict[str, Any]):
-
- segments_data = annotation_segments.get("data", [])
- sorted_segments = sorted(segments_data, key=lambda x: x["span"]["start"])
- result = [
- {"segment_id": segment["id"], "segment_number": idx + 1}
- for idx, segment in enumerate(sorted_segments)
- ]
- return result
-
- def create_toc_payload(self, ordered_segments: list[dict[str, Any]], text_id: str):
- section_id = str(uuid.uuid4())
- payload = {
- "text_id": text_id,
- "type": "text",
- "sections": [
- {
- "id": section_id,
- "title": "1",
- "section_number": 1,
- "segments": ordered_segments
- }
- ]
- }
- return payload
diff --git a/pecha_api/text_uploader/text_metadata/text_group_repository.py b/pecha_api/text_uploader/text_metadata/text_group_repository.py
deleted file mode 100644
index 1e5174f98..000000000
--- a/pecha_api/text_uploader/text_metadata/text_group_repository.py
+++ /dev/null
@@ -1,153 +0,0 @@
-from typing import Any, List
-
-import asyncio
-import requests
-
-from pecha_api.text_uploader.constants import OpenPechaAPIURL, DestinationURL, ACCESS_TOKEN
-from pecha_api.text_uploader.text_metadata.text_metadata_model import TextGroupPayload
-from pecha_api.text_uploader.text_metadata.text_metadata_model import CriticalInstanceResponse
-
-
-CONTENT_TYPE = "application/json"
-
-async def get_texts(openpecha_api_url: str, type: str | None = None, limit: int | None = None, offset: int | None = None) -> list[dict[str, Any]]:
- texts_url = f"{openpecha_api_url}/v2/texts"
-
- # `requests` is synchronous; run it in a thread so callers can still await.
- params = {
- "type": type,
- "limit": 100,
- "offset": 800,
- }
- response = await asyncio.to_thread(requests.get, texts_url, params=params)
- response.raise_for_status()
-
- return response.json()
-
-
-async def get_texts_by_category(category_id: str, openpecha_api_url: str) -> list[dict[str, Any]]:
- texts_url = f"{openpecha_api_url}/v2/categories/{category_id}/texts"
- params = {
- "instance_type": 'critical',
- "limit": 100,
- "offset": 0,
- }
- response = await asyncio.to_thread(requests.get, texts_url, params=params)
- response.raise_for_status()
- return response.json()
-
-
-async def get_related_texts(text_id: str, openpecha_api_url: str) -> list[dict[str, Any]]:
- related_texts_url = f"{openpecha_api_url}/v2/instances/{text_id}/related"
- response = await asyncio.to_thread(requests.get, related_texts_url)
- response.raise_for_status()
- return response.json()
-
-async def get_text_instances(text_id: str, type: str, openpecha_api_url: str) -> list[dict[str, Any]]:
- instances_url = f"{openpecha_api_url}/v2/texts/{text_id}/instances"
- params = {
- "type": type,
- }
- response = await asyncio.to_thread(requests.get, instances_url, params=params)
- response.raise_for_status()
- return response.json()
-
-
-async def post_group(type: str, destination_url: str, token: str) -> dict[str, Any]:
- """
- Create a text group in the destination (webuddhist) backend.
- """
- url = f"{destination_url}/groups"
- headers = {
- "Authorization": f"Bearer {token}",
- "Content-Type": CONTENT_TYPE,
- }
- payload = {
- "type": type,
- }
-
- response = await asyncio.to_thread(
- requests.post,
- url,
- headers=headers,
- json=payload,
- )
-
- if not response.ok:
- # Print full details so we can see why the backend returned 4xx/5xx.
- print(
- f"POST /groups failed "
- f"(status={response.status_code}) "
- f"body={response.text}"
- )
- response.raise_for_status()
-
- return response.json()
-
-async def get_critical_instances(text_id: str, openpecha_api_url: str) -> CriticalInstanceResponse:
- critical_instances_url = f"{openpecha_api_url}/v2/texts/{text_id}/instances"
- params = {"instance_type": "critical"}
-
- response = await asyncio.to_thread(
- requests.get,
- critical_instances_url,
- params=params,
- )
- response.raise_for_status()
- critical_instances_list = response.json()
- return CriticalInstanceResponse(critical_instances=critical_instances_list)
-
-
-async def post_text(text_payload: TextGroupPayload, token: str, destination_url: str) -> dict[str, Any]:
-
- url = f"{destination_url}/texts"
- headers = {
- "Authorization": f"Bearer {token}",
- "Content-Type": CONTENT_TYPE,
- }
- payload = text_payload.model_dump()
-
- response = await asyncio.to_thread(
- requests.post,
- url,
- headers=headers,
- json=payload,
- )
-
- if not response.ok:
- print(
- f"POST /texts failed "
- f"(status={response.status_code}) "
- f"body={response.text}"
- )
- response.raise_for_status()
-
- return response.json()
-
-
-async def get_text_related_by_work(text_id: str, openpecha_api_url: str) -> list[dict[str, Any]]:
- related_texts_url = f"{openpecha_api_url}/v2/texts/{text_id}/related-by-work"
- response = await asyncio.to_thread(requests.get, related_texts_url)
- response.raise_for_status()
- data = response.json()
- return data
-
-async def get_text_metadata(text_id: str, openpecha_api_url: str) -> list[dict[str, Any]]:
- text_metadata_url = f"{openpecha_api_url}/v2/texts/{text_id}"
- response = await asyncio.to_thread(requests.get, text_metadata_url)
- response.raise_for_status()
- return response.json()
-
-
-async def get_texts_by_pecha_text_ids(pecha_text_ids: List[str], destination_url: str) -> list[dict[str, Any]]:
- url = f"{destination_url}/text-uploader/list"
- headers = {
- "Content-Type": CONTENT_TYPE,
- }
- instance_ids = list(pecha_text_ids)
- payload = {
- "pecha_text_ids": instance_ids
- }
- response = await asyncio.to_thread(requests.post, url, headers=headers, json=payload)
- response.raise_for_status()
- return response.json()
\ No newline at end of file
diff --git a/pecha_api/text_uploader/text_metadata/text_metadata_model.py b/pecha_api/text_uploader/text_metadata/text_metadata_model.py
deleted file mode 100644
index 76c26d9d5..000000000
--- a/pecha_api/text_uploader/text_metadata/text_metadata_model.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from pydantic import BaseModel
-from typing import Optional, List, Dict,Any
-
-
-class TextGroupPayload(BaseModel):
-
- pecha_text_id: Optional[str] = None
- title: str | None = None
- language: Optional[str] = None
- isPublished: bool = False
- group_id: Optional[str] = None
- published_by: Optional[str] = None
- type: str
- categories: Optional[List[str]] = None
- views: Optional[int] = 0
- source_link: Optional[str] = None
- ranking: Optional[int] = None
- license: Optional[str] = None
-
-class CriticalInstance(BaseModel):
- id: str
- bdrc: Optional[str] = None
- wiki: Optional[str] = None
- type: str
- source: str
- colophon: Optional[str] = None
- incipit_title: Optional[Any] = None
- alt_incipit_titles: Optional[List[Any]] = None
- biblography_annotation: Optional[str] = None
-
-class CriticalInstanceResponse(BaseModel):
- critical_instances: List[CriticalInstance]
-
-class TextInstanceIds(BaseModel):
- new_text: Dict[str, str]
- all_text: Dict[str, str]
-
diff --git a/pecha_api/text_uploader/text_metadata/text_metadata_service.py b/pecha_api/text_uploader/text_metadata/text_metadata_service.py
deleted file mode 100644
index 1fc983b31..000000000
--- a/pecha_api/text_uploader/text_metadata/text_metadata_service.py
+++ /dev/null
@@ -1,170 +0,0 @@
-from typing import Any, List
-import logging
-
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-from pecha_api.text_uploader.text_metadata.text_metadata_model import CriticalInstanceResponse, TextInstanceIds
-from pecha_api.text_uploader.text_metadata.text_group_repository import (
- post_group,
- get_critical_instances,
- post_text,
- get_text_related_by_work,
- get_text_metadata
-)
-from pecha_api.text_uploader.collections.collections_repository import get_collection_by_pecha_collection_id
-from pecha_api.text_uploader.text_metadata.text_metadata_model import TextGroupPayload
-from pecha_api.text_uploader.text_metadata.text_group_repository import get_texts_by_pecha_text_ids
-
-
-logging.basicConfig(level=logging.INFO)
-
-class TextMetadataService:
- def __init__(self):
- self.version_group_id: str | None = None
- self.commentary_group_id: str | None = None
- self.text_ids: list[str] = []
- self.category: dict[str, str] = {}
- self.all_instance_ids: dict[str, str] = {}
-
-
- async def upload_text_metadata_service(self, text_upload_request: TextUploadRequest, token: str):
- text_id = text_upload_request.text_id
- text = await get_text_metadata(text_id, text_upload_request.openpecha_api_url)
- related_text_ids = []
- commentary_text_ids = []
- work_translation_group = {}
-
- text_related_by_work_response = await get_text_related_by_work(text_id, text_upload_request.openpecha_api_url)
- for key in text_related_by_work_response.keys():
- if text_related_by_work_response[key]["relation"] not in ['commentary', 'sibling_commentary']:
- work_translation_group[key] = text_related_by_work_response[key]["expression_ids"]
- expression_ids = text_related_by_work_response[key]["expression_ids"]
- related_text_ids.extend(expression_ids)
- else:
- commentary_ids = text_related_by_work_response[key]["expression_ids"]
- commentary_text_ids.extend(commentary_ids)
- if text["type"] not in ['commentary', 'sibling_commentary']:
- related_text_ids.append(text_id)
- else:
- commentary_text_ids.append(text_id)
- new_texts = await self.get_text_meta_data_service(text_ids=related_text_ids, type="translation", text_upload_request=text_upload_request, token=token)
- new_commentary_texts = await self.get_text_meta_data_service(text_ids=commentary_text_ids, type="commentary", text_upload_request=text_upload_request, token=token)
- new_texts.update(new_commentary_texts)
-
- result = TextInstanceIds(new_text=new_texts, all_text=self.all_instance_ids)
-
- # Reset
- self.version_group_id = None
- self.commentary_group_id = None
- self.all_instance_ids = {}
- return result
-
- async def get_text_meta_data_service(self, text_ids: List[str], type: str,text_upload_request: TextUploadRequest, token: str):
-
- if len(text_ids) == 0:
- return {}
-
- uploaded_texts, uploaded_text_ids, instances = await self.get_uploaded_texts(text_ids, text_upload_request.openpecha_api_url, text_upload_request.destination_url)
-
- if len(uploaded_texts) > 0 and type == "translation":
- group_id = uploaded_texts[0]['group_id']
- self.version_group_id = group_id
-
- new_texts = {}
- for text_id in text_ids:
- text_metadata = await get_text_metadata(text_id, text_upload_request.openpecha_api_url)
- language = text_metadata['language']
- title = text_metadata['title'][language]
-
- if text_id in uploaded_text_ids:
- logging.info(f"Skipping already uploaded text: {title}")
- continue
-
- if type == "translation":
- if not self.version_group_id:
- group_response = await post_group('text', text_upload_request.destination_url, token)
- self.version_group_id = group_response["id"]
- logging.info(f"Created new group {group_response['id']} for translation")
-
- payload = await self.create_textmetada_payload(text_id, text_metadata, type="version", text_upload_request=text_upload_request)
-
- elif type == "commentary":
- group_response = await post_group('commentary', text_upload_request.destination_url, token)
- self.commentary_group_id = group_response["id"]
- logging.info(f"Created new group {group_response['id']} for commentary")
-
- payload = await self.create_textmetada_payload(text_id, text_metadata, type="commentary", text_upload_request=text_upload_request)
-
- text_response = await post_text(payload, token, text_upload_request.destination_url)
- response_text_id = text_response["id"]
- new_texts[response_text_id] = instances[text_id]
-
- logging.info(f"Created new text {text_response['title']}")
-
- # get all instance ids
- self.all_instance_ids.update(instances)
-
- return new_texts
-
-
-
-
-
- async def create_textmetada_payload(self, text_id: str, text_metadata: dict[str, Any], type: str, text_upload_request: TextUploadRequest):
- language = text_metadata['language']
- title = text_metadata['title'][language]
- instance = await self.get_text_critical_instance(text_id, text_upload_request.openpecha_api_url)
-
- if type == "version":
- category_id = text_metadata.get("category_id", "")
- wb_category_id = await self.get_wb_collection_id(pecha_collection_id=category_id, destination_url=text_upload_request.destination_url)
- category_ids = [wb_category_id]
- group_id = self.version_group_id
- elif type == "commentary":
- category_ids = [self.version_group_id]
- group_id = self.commentary_group_id
-
- return TextGroupPayload(
- pecha_text_id=instance.critical_instances[0].id,
- title=title,
- language=language,
- isPublished=text_metadata.get("isPublished", True),
- group_id=group_id,
- published_by="",
- type=type,
- categories=category_ids,
- views=text_metadata.get("views", 0),
- source_link=instance.critical_instances[0].source,
- ranking=text_metadata.get("ranking"),
- license=text_metadata.get("license"),
- )
-
-
-
- async def get_text_critical_instance(self, text_id: str, openpecha_api_url: str) -> CriticalInstanceResponse:
- critical_instances_response = await get_critical_instances(text_id, openpecha_api_url)
- critical_instances_list = critical_instances_response.critical_instances
- return CriticalInstanceResponse(critical_instances=critical_instances_list)
-
- async def get_uploaded_texts(self, text_ids: List[str], openpecha_api_url: str, destination_url: str):
- instances = {}
- expressions = {}
- for text_id in text_ids:
- text_critical_instance = await self.get_text_critical_instance(text_id, openpecha_api_url)
- instance_id = text_critical_instance.critical_instances[0].id
- instances[text_id] = instance_id
- expressions[instance_id] = text_id
-
- instance_ids = instances.values()
- texts = await get_texts_by_pecha_text_ids(pecha_text_ids=instance_ids, destination_url=destination_url)
- pecha_text_ids = [text['pecha_text_id'] for text in texts]
- uploaded_text_ids = [expressions[id] for id in instance_ids if id in pecha_text_ids]
-
- return texts, uploaded_text_ids, instances
-
- async def get_wb_collection_id(self, pecha_collection_id: str, destination_url: str) -> str:
- wb_collection_id = await get_collection_by_pecha_collection_id(pecha_collection_id=pecha_collection_id, destination_url=destination_url)
- if wb_collection_id is None:
- raise ValueError(f"Collection with pecha_collection_id {pecha_collection_id} not found")
- return wb_collection_id
-
-
diff --git a/pecha_api/text_uploader/text_metadata/text_metadata_views.py b/pecha_api/text_uploader/text_metadata/text_metadata_views.py
deleted file mode 100644
index 47d61a9d7..000000000
--- a/pecha_api/text_uploader/text_metadata/text_metadata_views.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from fastapi import APIRouter
-from starlette import status
-from typing import Optional, List
-
-from pecha_api.texts.texts_response_models import TextDTO
-from pecha_api.texts.texts_response_models import TextsByPechaTextIdsRequest
-from pecha_api.texts.texts_service import get_text_by_pecha_text_ids_service
-
-text_metadata_router = APIRouter(
- prefix="/text-uploader",
- tags=["Text Uploader"]
-)
-
-@text_metadata_router.post("/list", status_code=status.HTTP_200_OK)
-async def get_text_by_pecha_text_ids(texts_by_pecha_text_ids_request: TextsByPechaTextIdsRequest) -> Optional[List[TextDTO]]:
- return await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=texts_by_pecha_text_ids_request)
\ No newline at end of file
diff --git a/pecha_api/text_uploader/text_uploader_response_model.py b/pecha_api/text_uploader/text_uploader_response_model.py
deleted file mode 100644
index f9b6120af..000000000
--- a/pecha_api/text_uploader/text_uploader_response_model.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from pydantic import BaseModel
-
-
-class TextUploadRequest(BaseModel):
- destination_url: str
- openpecha_api_url: str
- text_id: str
-
-
-class TextUploadResponse(BaseModel):
- message: dict[str, str] | str
diff --git a/pecha_api/text_uploader/text_uploader_views.py b/pecha_api/text_uploader/text_uploader_views.py
deleted file mode 100644
index 06eea2975..000000000
--- a/pecha_api/text_uploader/text_uploader_views.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from fastapi import APIRouter, Depends
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
-from typing import Annotated
-
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-from pecha_api.text_uploader.pipeline import pipeline
-
-oauth2_scheme = HTTPBearer()
-
-text_uploader_router = APIRouter(
- prefix="/text-uploader",
- tags=["Text Uploader"]
-)
-
-@text_uploader_router.post("")
-async def upload_text(
- text_upload_request: TextUploadRequest,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)]
- ):
- response = await pipeline(text_upload_request=text_upload_request, token=authentication_credential.credentials)
- return response
\ No newline at end of file
diff --git a/pecha_api/texts/mappings/__init__.py b/pecha_api/texts/mappings/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/pecha_api/texts/mappings/mappings_repository.py b/pecha_api/texts/mappings/mappings_repository.py
deleted file mode 100644
index 2215f6498..000000000
--- a/pecha_api/texts/mappings/mappings_repository.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import uuid
-from typing import List, Optional
-from ..segments.segments_models import Mapping, Segment
-from ..segments.segments_enum import SegmentType
-
-async def update_mapping(segment_id: uuid.UUID, text_id: str, mappings: List[Mapping]) -> Optional[Segment]:
- result = await Segment.get_segment_by_id_and_text_id(segment_id=segment_id, text_id=text_id)
- if result:
- result.mapping = mappings
- await result.save()
- return result
- return None
-
-async def get_segments_by_ids(segment_ids: List[str]) -> List[Segment]:
- return await Segment.get_segments_by_ids(segment_ids=segment_ids)
-
-async def update_mappings(segments: List[Segment]) -> List[Segment]:
- # Update all segments in bulk
- for segment in segments:
- await segment.save()
- return segments
-
-async def get_sheet_first_content_by_ids(segment_ids: List[str], segment_type: SegmentType) -> Optional[Segment]:
-
- # NEW: Efficient - filters at database level
- segment = await Segment.get_first_segment_by_ids_and_type(
- segment_ids=segment_ids,
- segment_type=segment_type
- )
- return segment
diff --git a/pecha_api/texts/mappings/mappings_response_models.py b/pecha_api/texts/mappings/mappings_response_models.py
deleted file mode 100644
index 6064528bb..000000000
--- a/pecha_api/texts/mappings/mappings_response_models.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from typing import List
-from pydantic import BaseModel
-
-
-class MappingsModel(BaseModel):
- parent_text_id: str
- segments: List[str]
-
-
-class TextMapping(BaseModel):
- text_id: str
- segment_id: str
- mappings: List[MappingsModel]
-
-
-class TextMappingRequest(BaseModel):
- text_mappings: List[TextMapping]
diff --git a/pecha_api/texts/mappings/mappings_service.py b/pecha_api/texts/mappings/mappings_service.py
deleted file mode 100644
index ec64b148c..000000000
--- a/pecha_api/texts/mappings/mappings_service.py
+++ /dev/null
@@ -1,272 +0,0 @@
-import asyncio
-from typing import List, Dict, Tuple
-from fastapi import HTTPException
-from starlette import status
-
-from pecha_api.error_contants import ErrorConstants
-from pecha_api.texts.segments.segments_repository import get_segments_by_pecha_segment_ids
-from pecha_api.texts.texts_repository import get_texts_by_pecha_text_ids
-from .mappings_repository import (
- update_mappings,
- get_segments_by_ids
-)
-from .mappings_response_models import (
- TextMappingRequest,
- MappingsModel,
- TextMapping
-)
-from ..segments.segments_models import Segment, Mapping
-from ..segments.segments_response_models import (
- SegmentResponse,
- SegmentDTO,
- MappingResponse
-)
-from ..segments.segments_utils import SegmentUtils
-from ..texts_utils import TextUtils
-from ...users.users_service import verify_admin_access
-
-
-# Mappings Service
-# ===============
-
-async def update_segment_mapping(text_mapping_request: TextMappingRequest, token: str) -> SegmentResponse:
- # Verify admin access
- is_admin: bool = verify_admin_access(token=token)
- if not is_admin:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=ErrorConstants.ADMIN_ERROR_MESSAGE
- )
- text_id_dict, segment_id_dict = await _get_text_and_segment_ids(text_mapping_request=text_mapping_request)
- for tm in text_mapping_request.text_mappings:
- tm.text_id = text_id_dict[tm.text_id]
- tm.segment_id = segment_id_dict[tm.segment_id]
- for map in tm.mappings:
- map.parent_text_id = text_id_dict[map.parent_text_id]
- map.segments = [segment_id_dict[segment] for segment in map.segments]
-
- # Validate mapping request
- await _validate_mapping_request(text_mapping_request=text_mapping_request)
-
- # Process segments
- segment_dict: Dict[str, List[Mapping]] = _get_segments_from_text_mapping(
- text_mappings=text_mapping_request.text_mappings
- )
- segment_ids: List[str] = list(segment_dict.keys())
-
- # Fetch and validate segments
- segments: List[Segment] = await get_segments_by_ids(segment_ids=segment_ids)
- if not segments:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="No valid segments found to update"
- )
-
- # Update segments
- segments_to_update: List[Segment] = await _construct_update_segments(
- segments=segments,
- update_segment_dict=segment_dict
- )
- updated_segments: List[Segment] = await update_mappings(segments=segments_to_update)
- if updated_segments:
- # Convert all segments to SegmentDTO
- segment_dtos = [
- SegmentDTO(
- id=str(segment.id),
- pecha_segment_id=segment.pecha_segment_id,
- text_id=segment.text_id,
- content=segment.content,
- type=segment.type,
- mapping=[MappingResponse(**mapping.model_dump()) for mapping in segment.mapping]
- ) for segment in updated_segments
- ]
- return SegmentResponse(segments=segment_dtos)
- else:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorConstants.SEGMENT_MAPPING_ERROR_MESSAGE
- )
-
-async def delete_segment_mapping(text_mapping_request: TextMappingRequest,token: str):
- # Verify admin access
- is_admin: bool = verify_admin_access(token=token)
- if not is_admin:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=ErrorConstants.ADMIN_ERROR_MESSAGE
- )
- # Validate mapping request
- #await _validate_mapping_request(text_mapping_request=text_mapping_request)
- # Process segments
- segment_dict: Dict[str, List[Mapping]] = _get_segments_from_text_mapping(
- text_mappings=text_mapping_request.text_mappings
- )
- segment_ids: List[str] = list(segment_dict.keys())
-
- # Fetch and validate segments
- segments: List[Segment] = await get_segments_by_ids(segment_ids=segment_ids)
- if not segments:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="No valid segments found to update"
- )
- # Update segments
- segments_to_delete: List[Segment] = await _construct_delete_segments(
- segments=segments,
- delete_segment_dict=segment_dict
- )
- deleted_segments: List[Segment] = await update_mappings(segments=segments_to_delete)
- if deleted_segments:
- # Convert all segments to SegmentDTO
- segment_dtos = [
- SegmentDTO(
- id=str(segment.id),
- text_id=segment.text_id,
- content=segment.content,
- type=segment.type,
- mapping=[MappingResponse(**mapping.model_dump()) for mapping in segment.mapping]
- ) for segment in deleted_segments
- ]
- return SegmentResponse(segments=segment_dtos)
- else:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorConstants.SEGMENT_MAPPING_ERROR_MESSAGE
- )
-
-async def _get_text_and_segment_ids(text_mapping_request: TextMappingRequest) -> Tuple[Dict[str, str], Dict[str, str]]:
- segment_ids=[]
- text_ids=[]
- for tm in text_mapping_request.text_mappings:
- text_ids.append(tm.text_id)
- segment_ids.append(tm.segment_id)
- for map in tm.mappings:
- text_ids.append(map.parent_text_id)
- for segment in map.segments:
- segment_ids.append(segment)
- texts = await get_texts_by_pecha_text_ids(pecha_text_ids=text_ids)
- segments = await get_segments_by_pecha_segment_ids(pecha_segment_ids=segment_ids)
- text_id_dict = {text.pecha_text_id: str(text.id) for text in texts}
- segment_id_dict = {segment.pecha_segment_id: str(segment.id) for segment in segments}
- return text_id_dict, segment_id_dict
-
-async def _construct_delete_segments(segments: List[Segment], delete_segment_dict: Dict[str, List[Mapping]]) -> List[
- Segment]:
- deleted_segments = []
-
- for segment in segments:
- segment_id = str(segment.id)
- if segment_id in delete_segment_dict:
- # Get mappings to delete
- mappings_to_delete = delete_segment_dict[segment_id]
- existing_mappings: Dict[str, Mapping] = _get_existing_mappings(segment)
- # Filter out mappings that should be deleted
- remaining_mappings = [
- mapping for mapping in existing_mappings.values()
- if (mapping.text_id, tuple(sorted(mapping.segments))) not in {
- (delete_map.text_id, tuple(sorted(delete_map.segments)))
- for delete_map in mappings_to_delete
- }
- ]
- # Update segment with remaining mappings
- segment.mapping = remaining_mappings
- deleted_segments.append(segment)
-
- return deleted_segments
-
-async def _validate_mapping_request(text_mapping_request: TextMappingRequest) -> bool:
- tasks = [
- asyncio.create_task(_validate_request_info(
- text_id=tm.text_id,
- segment_id=tm.segment_id,
- mappings=tm.mappings
- )) for tm in text_mapping_request.text_mappings
- ]
- try:
- for completed in asyncio.as_completed(tasks):
- result = await completed
- if not result:
- # Cancel all other running tasks
- for task in tasks:
- if not task.done():
- task.cancel()
- return False
- except asyncio.CancelledError:
- pass
-
- return True
-
-def _merge_segment_mappings(existing_mapping: Mapping, new_mapping: Mapping) -> Mapping:
- # Create a set of unique segments and update existing mapping
- unique_segments = set(existing_mapping.segments + new_mapping.segments)
- existing_mapping.segments = list(unique_segments)
- return existing_mapping
-
-def _get_existing_mappings(segment: Segment) -> Dict[str, Mapping]:
- # Create a dictionary of existing mappings by text_id for easy lookup
- return {mapping.text_id: mapping for mapping in (segment.mapping or [])}
-
-def _process_new_mappings(new_mappings: List[Mapping], existing_mappings: Dict[str, Mapping]) -> List[Mapping]:
- merged = []
- processed_text_ids = set()
-
- # Process new mappings
- for new_mapping in new_mappings:
- processed_text_ids.add(new_mapping.text_id)
- if new_mapping.text_id in existing_mappings:
- merged.append(_merge_segment_mappings(
- existing_mappings[new_mapping.text_id],
- new_mapping
- ))
- else:
- merged.append(new_mapping)
-
- # Add unprocessed existing mappings
- merged.extend(
- mapping for text_id, mapping in existing_mappings.items()
- if text_id not in processed_text_ids
- )
-
- return merged
-
-async def _construct_update_segments(segments: List[Segment], update_segment_dict: Dict[str, List[Mapping]]) -> List[Segment]:
- updated_segments = []
-
- for segment in segments:
- segment_id = str(segment.id)
- if segment_id in update_segment_dict:
- existing_mappings = _get_existing_mappings(segment)
- new_mappings = update_segment_dict[segment_id]
-
- segment.mapping = _process_new_mappings(new_mappings, existing_mappings)
- updated_segments.append(segment)
-
- return updated_segments
-
-def _get_segments_from_text_mapping(text_mappings: List[TextMapping]) -> Dict[str, List[Mapping]]:
- segment_dict = {}
- for text_mapping in text_mappings:
- # Convert MappingsModel to Mapping objects
- mappings = [
- Mapping(text_id=m.parent_text_id, segments=m.segments)
- for m in text_mapping.mappings
- ]
- # Ensure segment_id is stored as string
- segment_dict[str(text_mapping.segment_id)] = mappings
- return segment_dict
-
-async def _validate_request_info(text_id: str, segment_id: str, mappings: List[MappingsModel]) -> bool:
- # validate the text id
- await TextUtils.validate_text_exists(text_id=text_id)
- # validate the segment id
- await SegmentUtils.validate_segment_exists(segment_id=segment_id)
- # validate the parent ids
- parent_text_ids = [mapping.parent_text_id for mapping in mappings]
- if text_id in parent_text_ids:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorConstants.SAME_TEXT_MAPPING_ERROR_MESSAGE)
- await TextUtils.validate_texts_exist(text_ids=parent_text_ids)
- # validate segment ids
- segment_ids = [segment for mapping in mappings for segment in mapping.segments]
- await SegmentUtils.validate_segments_exists(segment_ids=segment_ids)
- return True
-
diff --git a/pecha_api/texts/mappings/mappings_views.py b/pecha_api/texts/mappings/mappings_views.py
deleted file mode 100644
index b12d28271..000000000
--- a/pecha_api/texts/mappings/mappings_views.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from typing import Annotated
-
-from fastapi import APIRouter, Depends
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
-from starlette import status
-
-from .mappings_response_models import TextMappingRequest
-from .mappings_service import update_segment_mapping, delete_segment_mapping
-from ..segments.segments_response_models import SegmentResponse
-
-oauth2_scheme = HTTPBearer()
-
-mapping_router = APIRouter(
- prefix="/mappings",
- tags=["Text Mapping"]
-)
-
-
-@mapping_router.post("",status_code=status.HTTP_201_CREATED)
-async def create_text_mapping(authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)],
- text_mapping_request: TextMappingRequest) -> SegmentResponse:
- return await update_segment_mapping(token=authentication_credential.credentials, text_mapping_request=text_mapping_request)
-
-@mapping_router.delete("",status_code=status.HTTP_204_NO_CONTENT)
-async def delete_text_mapping(authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)],
- text_mapping_request: TextMappingRequest) -> None:
- await delete_segment_mapping(token=authentication_credential.credentials, text_mapping_request=text_mapping_request)
\ No newline at end of file
diff --git a/pecha_api/texts/segments/segments_cache_service.py b/pecha_api/texts/segments/segments_cache_service.py
index 6bdf0438c..62c5d1475 100644
--- a/pecha_api/texts/segments/segments_cache_service.py
+++ b/pecha_api/texts/segments/segments_cache_service.py
@@ -2,91 +2,14 @@
from pecha_api.cache.cache_repository import (
get_cache_data,
set_cache,
- clear_cache
)
from pecha_api import config
from .segments_response_models import (
SegmentDTO,
- SegmentInfoResponse,
- SegmentRootMappingResponse,
- SegmentTranslationsResponse,
- SegmentCommentariesResponse
)
from typing import Dict, List
from pecha_api.cache.cache_enums import CacheType
-# SEGMENTS
-async def get_segment_details_by_id_cache(segment_id: str = None, text_details: bool = None) -> SegmentDTO:
- payload = [segment_id, text_details]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: SegmentDTO = await get_cache_data(hash_key=hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = SegmentDTO(**cache_data)
- return cache_data
-
-async def set_segment_details_by_id_cache(segment_id: str = None, text_details: bool = None, cache_type: CacheType = None, data: SegmentDTO = None):
- payload = [segment_id, text_details, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def get_segment_info_by_id_cache(segment_id: str = None, cache_type: CacheType = None) -> SegmentInfoResponse:
- payload = [segment_id, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: SegmentInfoResponse = await get_cache_data(hash_key = hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = SegmentInfoResponse(**cache_data)
- return cache_data
-
-async def set_segment_info_by_id_cache(segment_id: str = None, cache_type: CacheType = None, data: SegmentInfoResponse = None):
- payload = [segment_id, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def get_segment_root_mapping_by_id_cache(segment_id: str = None) -> SegmentRootMappingResponse:
- payload = [segment_id]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: SegmentRootMappingResponse = await get_cache_data(hash_key = hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = SegmentRootMappingResponse(**cache_data)
- return cache_data
-
-async def set_segment_root_mapping_by_id_cache(segment_id: str = None, cache_type: CacheType = None, data: SegmentRootMappingResponse = None):
- payload = [segment_id, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def get_segment_translations_by_id_cache(segment_id: str = None) -> SegmentTranslationsResponse:
- payload = [segment_id]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: SegmentTranslationsResponse = await get_cache_data(hash_key = hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = SegmentTranslationsResponse(**cache_data)
- return cache_data
-
-async def set_segment_translations_by_id_cache(segment_id: str = None, cache_type: CacheType = None, data: SegmentTranslationsResponse = None):
- payload = [segment_id, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def get_segment_commentaries_by_id_cache(segment_id: str = None) -> SegmentCommentariesResponse:
- payload = [segment_id]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: SegmentCommentariesResponse = await get_cache_data(hash_key = hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = SegmentCommentariesResponse(**cache_data)
- return cache_data
-
-async def set_segment_commentaries_by_id_cache(segment_id: str = None, cache_type: CacheType = None, data: SegmentCommentariesResponse = None):
- payload = [segment_id, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-
async def get_segments_details_by_ids_cache(segment_ids: List[str] = None, cache_type: CacheType = None) -> Dict[str, SegmentDTO]:
payload = list(segment_ids) + [cache_type]
hashed_key: str = Utils.generate_hash_key(payload = payload)
@@ -99,9 +22,4 @@ async def set_segments_details_by_ids_cache(segment_ids: List[str] = None, cache
payload = list(segment_ids) + [cache_type]
hashed_key: str = Utils.generate_hash_key(payload = payload)
cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def delete_segments_details_by_ids_cache(segment_ids: List[str] = None, cache_type: CacheType = None):
- payload = list(segment_ids) + [cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- await clear_cache(hash_key = hashed_key)
\ No newline at end of file
+ await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
\ No newline at end of file
diff --git a/pecha_api/texts/segments/segments_models.py b/pecha_api/texts/segments/segments_models.py
index 7271dbba9..25f933936 100644
--- a/pecha_api/texts/segments/segments_models.py
+++ b/pecha_api/texts/segments/segments_models.py
@@ -24,30 +24,13 @@ class Settings:
"mapping.segments" # Index for faster lookup of segment IDs within mapping arrays
]
- @classmethod
- async def get_segments_by_pecha_segment_ids(cls, pecha_segment_ids: List[str]) -> List["Segment"]:
- pecha_segment_ids = [str(pecha_segment_id) for pecha_segment_id in pecha_segment_ids]
- return await cls.find({cls.pecha_segment_id: {"$in": pecha_segment_ids}}).to_list()
-
@classmethod
async def get_segment_by_id(cls, segment_id: str) -> Optional["Segment"]:
return await cls.find_one(cls.id == uuid.UUID(segment_id))
- @classmethod
- async def update_segment_by_pecha_segment_id(cls, pecha_segment_id: str, content: str) -> Optional["Segment"]:
- return await cls.find_one(cls.pecha_segment_id == pecha_segment_id).update({"$set": {"content": content}})
-
@classmethod
async def get_segment_by_pecha_segment_id(cls, pecha_segment_id: str) -> Optional["Segment"]:
return await cls.find_one(cls.pecha_segment_id == pecha_segment_id)
- @classmethod
- async def get_segments_by_ids(cls, segment_ids: List[str]) -> List["Segment"]:
- segment_ids = [uuid.UUID(segment_id) for segment_id in segment_ids]
- return await cls.find({"_id": {"$in": segment_ids}}).to_list()
-
- @classmethod
- async def get_segment_by_id_and_text_id(cls, segment_id: uuid.UUID, text_id: str) -> Optional["Segment"]:
- return await cls.find_one(cls.id == segment_id, cls.text_id == text_id)
@classmethod
async def get_segments_by_text_id(cls, text_id: str) -> List["Segment"]:
@@ -74,13 +57,6 @@ async def exists_all(cls, segment_ids: List[uuid.UUID], batch_size: int = 100) -
async def get_segments_by_ids(cls, segment_ids: List[str]) -> List["Segment"]:
segment_ids = [uuid.UUID(segment_id) for segment_id in segment_ids]
return await cls.find({"_id": {"$in": segment_ids}}).to_list(length=len(segment_ids))
- @classmethod
- async def get_first_segment_by_ids_and_type(cls, segment_ids: List[str], segment_type: SegmentType) -> Optional["Segment"]:
- """Get the first segment matching the IDs and type - optimized for single result"""
- if not segment_ids:
- return None
- segment_uuid_ids = [uuid.UUID(segment_id) for segment_id in segment_ids]
- return await cls.find_one({"_id": {"$in": segment_uuid_ids}, "type": segment_type})
@classmethod
async def get_related_mapped_segments(cls, parent_segment_id: str) -> List["Segment"]:
@@ -109,10 +85,6 @@ async def get_segments_by_pecha_ids(
return await cls.find(query).to_list()
- @classmethod
- async def delete_segment_by_text_id(cls, text_id: str):
- return await cls.find(cls.text_id == text_id).delete()
-
@classmethod
async def get_related_mapped_segments_batch(
cls,
diff --git a/pecha_api/texts/segments/segments_openpecha_service.py b/pecha_api/texts/segments/segments_openpecha_service.py
index 680d481e5..06b398c22 100644
--- a/pecha_api/texts/segments/segments_openpecha_service.py
+++ b/pecha_api/texts/segments/segments_openpecha_service.py
@@ -8,6 +8,7 @@
from openpecha_api.segments.openpecha_segment_service import (
fetch_related_segments,
fetch_segment_content,
+ fetch_segment_details,
)
from openpecha_api.text.openpecha_text_service import fetch_text_by_id
@@ -15,6 +16,9 @@
ParentSegment,
V2RelatedSegmentItem,
V2SegmentCommentariesResponse,
+ V2SegmentResponse,
+ V2SegmentRootTextResponse,
+ V2SegmentTextDetail,
V2SegmentTextGroup,
V2SegmentTranslationsResponse,
)
@@ -24,6 +28,7 @@
TRANSLATION = "translation"
COMMENTARY = "commentary"
+RELATED_SEGMENTS_MAX_PAGE_SIZE = 100
def _classify_text(text_payload: Dict[str, Any]) -> Optional[str]:
@@ -140,6 +145,121 @@ async def _get_related_segments_grouped_by_type(
return parent_segment, [grouped[text_id] for text_id in group_order], has_more
+async def _fetch_matching_related_segments_by_text_id(
+ segment_id: str,
+ text_id: str,
+ skip: int,
+ limit: int,
+) -> Tuple[List[Dict[str, Any]], bool]:
+ matching_items: List[Dict[str, Any]] = []
+ upstream_offset = 0
+ upstream_has_more = True
+ target_count = skip + limit + 1
+
+ while upstream_has_more and len(matching_items) < target_count:
+ related_page = await fetch_related_segments(
+ segment_id=segment_id,
+ limit=RELATED_SEGMENTS_MAX_PAGE_SIZE,
+ offset=upstream_offset,
+ )
+ items: List[Dict[str, Any]] = related_page.get("items", []) or []
+ matching_items.extend(
+ item for item in items if item.get("text_id") == text_id
+ )
+ upstream_has_more = bool(related_page.get("has_more", False))
+ if not items:
+ break
+ upstream_offset += len(items)
+
+ paginated_items = matching_items[skip : skip + limit]
+ has_more = len(matching_items) > skip + limit
+ return paginated_items, has_more
+
+
+async def get_root_text_by_segment_id_from_openpecha(
+ text_id: str,
+ segment_id: str,
+ skip: int = 0,
+ limit: int = 10,
+) -> V2SegmentRootTextResponse:
+ try:
+ parent_segment, (filtered_items, has_more) = await asyncio.gather(
+ _fetch_parent_segment(segment_id),
+ _fetch_matching_related_segments_by_text_id(
+ segment_id=segment_id,
+ text_id=text_id,
+ skip=skip,
+ limit=limit,
+ ),
+ )
+ except HTTPException:
+ raise
+ except Exception:
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail="Failed to fetch related segments from upstream service",
+ )
+
+ if not filtered_items:
+ return V2SegmentRootTextResponse(
+ parent_segment=parent_segment,
+ root_text=[],
+ skip=skip,
+ limit=limit,
+ has_more=has_more,
+ )
+
+ text_payload, *segment_contents = await asyncio.gather(
+ _fetch_text_safe(text_id),
+ *[_fetch_segment_content_safe(item["id"]) for item in filtered_items],
+ )
+
+ root_text_group = V2SegmentTextGroup(
+ text_id=text_id,
+ title=_extract_title(text_payload.get("title", {})) if text_payload else "",
+ language=text_payload.get("language") if text_payload else None,
+ segments=[
+ V2RelatedSegmentItem(id=item["id"], content=content)
+ for item, content in zip(filtered_items, segment_contents)
+ ],
+ )
+
+ return V2SegmentRootTextResponse(
+ parent_segment=parent_segment,
+ root_text=[root_text_group],
+ skip=skip,
+ limit=limit,
+ has_more=has_more,
+ )
+
+async def get_openpecha_segment_details_by_id(
+ segment_id: str,
+) -> V2SegmentResponse:
+ content = await _fetch_segment_content_safe(segment_id)
+ if content is None:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Segment with id '{segment_id}' not found",
+ )
+
+ segment_details=await fetch_segment_details(segment_id)
+ text_payload = await _fetch_text_safe(segment_details.get("text_id"))
+ if text_payload:
+ text_detail = V2SegmentTextDetail(
+ text_id=segment_details.get("text_id"),
+ title=_extract_title(text_payload.get("title", {})),
+ language=text_payload.get("language"),
+ )
+ else:
+ text_detail = None
+
+ return V2SegmentResponse(
+ segment_id=segment_id,
+ content=content,
+ text=text_detail,
+ )
+
+
async def get_translations_by_segment_id_from_openpecha(
segment_id: str,
skip: int = 0,
diff --git a/pecha_api/texts/segments/segments_openpecha_views.py b/pecha_api/texts/segments/segments_openpecha_views.py
index f364dc3f0..1ed0b58cf 100644
--- a/pecha_api/texts/segments/segments_openpecha_views.py
+++ b/pecha_api/texts/segments/segments_openpecha_views.py
@@ -1,27 +1,56 @@
-from typing import Annotated
+from typing import Annotated, Optional
from fastapi import APIRouter, Query
from starlette import status
from .segments_openpecha_service import (
get_commentaries_by_segment_id_from_openpecha,
+ get_openpecha_segment_details_by_id,
+ get_root_text_by_segment_id_from_openpecha,
get_translations_by_segment_id_from_openpecha,
)
from .segments_response_models import (
V2SegmentCommentariesResponse,
+ V2SegmentResponse,
V2SegmentTranslationsResponse,
+ V2SegmentRootTextResponse,
)
segments_v2_router = APIRouter(
- prefix="/v2/segments",
- tags=["segments-v2"],
+ prefix="/segments",
+ tags=["Segments"],
)
+@segments_v2_router.get("/{segment_id}", status_code=status.HTTP_200_OK)
+async def get_segment_v2(
+ segment_id: str,
+) -> V2SegmentResponse:
+ return await get_openpecha_segment_details_by_id(
+ segment_id=segment_id,
+ )
+
+@segments_v2_router.get(
+ "/{segment_id}/root_text",
+ status_code=status.HTTP_200_OK,
+ summary="Get Root Text for Segment",
+)
+async def get_root_text_for_segment_v2(
+ text_id: str,
+ segment_id: str,
+ skip: Annotated[int, Query(ge=0)] = 0,
+ limit: Annotated[int, Query(ge=1, le=100)] = 10,
+) -> V2SegmentRootTextResponse:
+ return await get_root_text_by_segment_id_from_openpecha(
+ text_id=text_id,
+ segment_id=segment_id,
+ skip=skip,
+ limit=limit,
+ )
@segments_v2_router.get(
"/{segment_id}/translations",
status_code=status.HTTP_200_OK,
- summary="Get translations for a segment from OpenPecha",
+ summary="Get Translations for Segment",
)
async def get_translations_for_segment_v2(
segment_id: str,
@@ -38,7 +67,7 @@ async def get_translations_for_segment_v2(
@segments_v2_router.get(
"/{segment_id}/commentaries",
status_code=status.HTTP_200_OK,
- summary="Get commentaries for a segment from OpenPecha",
+ summary="Get Commentaries for Segment",
)
async def get_commentaries_for_segment_v2(
segment_id: str,
diff --git a/pecha_api/texts/segments/segments_repository.py b/pecha_api/texts/segments/segments_repository.py
index 5fe1aaa4f..6b1529160 100644
--- a/pecha_api/texts/segments/segments_repository.py
+++ b/pecha_api/texts/segments/segments_repository.py
@@ -4,7 +4,7 @@
from pecha_api.constants import Constants
from .segments_models import Segment
-from .segments_response_models import CreateSegmentRequest, SegmentDTO, MappingResponse, SegmentUpdateRequest
+from .segments_response_models import SegmentDTO, MappingResponse
import logging
from beanie.exceptions import CollectionWasNotInitialized
from typing import List, Dict, Optional
@@ -12,13 +12,6 @@
from starlette import status
from pecha_api.error_contants import ErrorConstants
-async def get_segments_by_pecha_segment_ids(pecha_segment_ids: List[str]) -> List[SegmentDTO]:
- try:
- return await Segment.get_segments_by_pecha_segment_ids(pecha_segment_ids=pecha_segment_ids)
- except CollectionWasNotInitialized as e:
- logging.debug(e)
- return []
-
async def get_segment_by_id(segment_id: str) -> SegmentDTO | None:
try:
segment = await Segment.get_segment_by_id(segment_id=segment_id)
@@ -36,9 +29,6 @@ async def get_segments_by_text_id(text_id: str) -> List[SegmentDTO]:
return []
-async def get_segment_details_by_id(segment_id: str):
- return await Segment.get_segment_details(segment_id=segment_id)
-
async def check_segment_exists(segment_id: UUID) -> bool:
try:
is_segment_exists = await Segment.check_exists(segment_id=segment_id)
@@ -74,22 +64,6 @@ async def get_segments_by_ids(segment_ids: List[str]) -> Dict[str, SegmentDTO]:
return {}
-async def create_segment(create_segment_request: CreateSegmentRequest) -> List[Segment]:
- new_segment_list = [
- Segment(
- pecha_segment_id=segment.pecha_segment_id,
- text_id=create_segment_request.text_id,
- content=segment.content,
- mapping=segment.mapping,
- type=segment.type
- )
- for segment in create_segment_request.segments
- ]
- # Store the insert result but don't return it directly
- await Segment.insert_many(new_segment_list)
-
- return new_segment_list
-
async def get_related_mapped_segments(parent_segment_id: str) -> List[SegmentDTO]:
try:
segments = await Segment.get_related_mapped_segments(parent_segment_id=parent_segment_id)
@@ -125,25 +99,3 @@ async def get_related_mapped_segments_batch(
except CollectionWasNotInitialized as e:
logging.debug(e)
return {}
-
-async def delete_segments_by_text_id(text_id: str):
- try:
- await Segment.delete_segment_by_text_id(text_id=text_id)
- except CollectionWasNotInitialized as e:
- logging.debug(e)
- return False
-
-
-async def update_segment_by_id(segment_update_request: SegmentUpdateRequest) -> SegmentDTO | None:
- try:
- for segment_update in segment_update_request.segments:
- segment = await Segment.get_segment_by_pecha_segment_id(pecha_segment_id=segment_update.pecha_segment_id)
- if not segment:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE)
- segment.content = segment_update.content
- await segment.save()
-
- return segment
- except CollectionWasNotInitialized as e:
- logging.debug(e)
- return None
\ No newline at end of file
diff --git a/pecha_api/texts/segments/segments_response_models.py b/pecha_api/texts/segments/segments_response_models.py
index 11324379b..41d81a4cf 100644
--- a/pecha_api/texts/segments/segments_response_models.py
+++ b/pecha_api/texts/segments/segments_response_models.py
@@ -1,26 +1,12 @@
from pydantic import BaseModel
from typing import List, Optional
-from sqlalchemy.orm import strategies
from .segments_models import Mapping
from .segments_enum import SegmentType
from pecha_api.texts.texts_response_models import TextDTO
-class CreateSegment(BaseModel):
- pecha_segment_id: Optional[str] = None
- content: str
- pecha_segment_id: Optional[str] = None
- type: SegmentType
- mapping: Optional[List[Mapping]] = []
-
-
-class CreateSegmentRequest(BaseModel):
- text_id: str
- segments: List[CreateSegment]
-
-
class MappingResponse(BaseModel):
text_id: str
segments: List[str]
@@ -34,21 +20,10 @@ class SegmentDTO(BaseModel):
mapping: Optional[List[MappingResponse]] = None
text: Optional[TextDTO] = None
-class SegmentUpdate(BaseModel):
- pecha_segment_id: str
- content: str
-
-class SegmentUpdateRequest(BaseModel):
- pecha_text_id: str
- segments: List[SegmentUpdate]
-
class MappedSegmentDTO(BaseModel):
segment_id: str
content: str
-class SegmentResponse(BaseModel):
- segments: List[SegmentDTO]
-
class ParentSegment(BaseModel):
segment_id: str
content: str
@@ -86,9 +61,6 @@ class SegmentAdaptation(BaseModel):
language: str
content: str
-class SegmentTranslationsResponse(BaseModel):
- parent_segment: ParentSegment
- translations: List[SegmentTranslation]
# segment commentary models
class SegmentCommentry(BaseModel):
@@ -98,33 +70,6 @@ class SegmentCommentry(BaseModel):
language: str
count: int
-class SegmentCommentariesResponse(BaseModel):
- parent_segment: ParentSegment
- commentaries: List[SegmentCommentry]
-
-# segment info models
-
-class SegmentInfosRequest(BaseModel):
- text_id: str
-
-class RelatedText(BaseModel):
- commentaries: Optional[int] = 0
- root_text: Optional[int] = 0
-
-class Resources(BaseModel):
- sheets: int
-
-class SegmentInfo(BaseModel):
- segment_id: str
- text_id: str
- translations: Optional[int] = 0
- related_text: RelatedText
- resources: Resources
-
-class SegmentInfoResponse(BaseModel):
- segment_info: SegmentInfo
-
-# segment's root mapping models
class MappedSegmentResponseDTO(BaseModel):
segment_id: str
@@ -136,11 +81,6 @@ class SegmentRootMapping(BaseModel):
language: str
segments: List[MappedSegmentResponseDTO]
-class SegmentRootMappingResponse(BaseModel):
- parent_segment: ParentSegment
- segment_root_mapping: List[SegmentRootMapping]
-
-
class V2RelatedSegmentItem(BaseModel):
id: str
content: Optional[str] = None
@@ -160,6 +100,12 @@ class V2SegmentTranslationsResponse(BaseModel):
limit: int
has_more: bool = False
+class V2SegmentRootTextResponse(BaseModel):
+ parent_segment: ParentSegment
+ root_text: List[V2SegmentTextGroup]
+ skip: int
+ limit: int
+ has_more: bool = False
class V2SegmentCommentariesResponse(BaseModel):
parent_segment: ParentSegment
@@ -167,3 +113,15 @@ class V2SegmentCommentariesResponse(BaseModel):
skip: int
limit: int
has_more: bool = False
+
+
+class V2SegmentTextDetail(BaseModel):
+ text_id: str
+ title: str
+ language: Optional[str] = None
+
+
+class V2SegmentResponse(BaseModel):
+ segment_id: str
+ content: str
+ text: Optional[V2SegmentTextDetail] = None
diff --git a/pecha_api/texts/segments/segments_service.py b/pecha_api/texts/segments/segments_service.py
index 7feab34aa..a16ace606 100644
--- a/pecha_api/texts/segments/segments_service.py
+++ b/pecha_api/texts/segments/segments_service.py
@@ -1,62 +1,19 @@
-from pecha_api.error_contants import ErrorConstants
from .segments_repository import (
- create_segment,
- get_segment_by_id,
get_segments_by_ids,
- get_related_mapped_segments,
- get_related_mapped_segments_batch,
- get_segments_by_text_id,
- delete_segments_by_text_id,
- update_segment_by_id
)
-from ...users.users_service import verify_admin_access
from .segments_response_models import (
- CreateSegmentRequest,
- SegmentResponse,
- MappingResponse,
- SegmentDTO,
- SegmentInfoResponse,
- SegmentRootMappingResponse,
- SegmentUpdateRequest
+ SegmentDTO,
)
from pecha_api.cache.cache_enums import CacheType
-from fastapi import HTTPException
-from starlette import status
-
-from .segments_utils import SegmentUtils
-from ..texts_utils import TextUtils
-
from typing import List, Dict
-from .segments_response_models import (
- SegmentTranslationsResponse,
- ParentSegment,
- SegmentCommentariesResponse,
- RelatedText,
- Resources,
- SegmentInfo,
- SegmentRootMappingResponse
-)
-
from .segments_cache_service import (
- set_segment_info_by_id_cache,
- get_segment_info_by_id_cache,
- get_segment_root_mapping_by_id_cache,
- set_segment_root_mapping_by_id_cache,
get_segments_details_by_ids_cache,
set_segments_details_by_ids_cache,
- delete_segments_details_by_ids_cache
)
-from pecha_api.uploads.S3_utils import generate_presigned_access_url
-
-from .segments_enum import SegmentType
-from ..texts_service import TextUtils
-from ..texts_repository import get_text_by_pecha_text_id
-from ...users.users_service import validate_user_exists
-
async def get_segments_details_by_ids(segment_ids: List[str]) -> Dict[str, SegmentDTO]:
cached_data: Dict[str, SegmentDTO] = await get_segments_details_by_ids_cache(segment_ids=segment_ids, cache_type=CacheType.SEGMENTS_DETAILS)
if cached_data is not None:
@@ -67,162 +24,3 @@ async def get_segments_details_by_ids(segment_ids: List[str]) -> Dict[str, Segme
await set_segments_details_by_ids_cache(segment_ids=segment_ids, cache_type=CacheType.SEGMENTS_DETAILS, data=segments)
return segments
-
-async def get_segment_details_by_id(segment_id: str, text_details: bool = False) -> SegmentDTO:
-
- segment = await get_segment_by_id(segment_id=segment_id)
- if not segment:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE)
- mapping_responses: List[MappingResponse] = [
- MappingResponse(**mapping.model_dump()) for mapping in segment.mapping
- ]
- text = None
- if text_details:
- text = await TextUtils.get_text_details_by_id(text_id=segment.text_id)
-
- response = SegmentDTO(
- id=str(segment.id),
- pecha_segment_id=str(segment.pecha_segment_id),
- text_id=segment.text_id,
- content=segment.content,
- mapping=mapping_responses,
- type=segment.type,
- text=text
- )
- return response
-
-async def create_new_segment(create_segment_request: CreateSegmentRequest, token: str) -> SegmentResponse:
- is_valid_user = validate_user_exists(token=token)
- if is_valid_user:
- await TextUtils.validate_text_exists(text_id=create_segment_request.text_id)
- new_segment = await create_segment(create_segment_request=create_segment_request)
- segments = [
- SegmentDTO(
- id=str(segment.id),
- pecha_segment_id=str(segment.pecha_segment_id),
- text_id=segment.text_id,
- content=segment.content,
- mapping= [MappingResponse(**mapping.model_dump()) for mapping in segment.mapping],
- type=segment.type
- )
- for segment in new_segment
- ]
- return SegmentResponse(segments=segments)
- else:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE)
-
-async def get_translations_by_segment_id(segment_id: str) -> SegmentTranslationsResponse:
- """
- Get translations for a given segment ID.
- """
-
- is_valid_segment = await SegmentUtils.validate_segment_exists(segment_id=segment_id)
- if not is_valid_segment:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE)
- parent_segment = await get_segment_by_id(segment_id=segment_id)
- mapped_segments = await get_related_mapped_segments(parent_segment_id=segment_id)
- translations = await SegmentUtils.filter_segment_mapping_by_type_or_text_id(segments=mapped_segments, type="version")
- response = SegmentTranslationsResponse(
- parent_segment=ParentSegment(
- segment_id=str(parent_segment.id),
- content=parent_segment.content
- ),
- translations=translations
- )
-
- return response
-
-async def get_commentaries_by_segment_id(
- segment_id: str
-) -> SegmentCommentariesResponse:
-
- is_valid_segment = await SegmentUtils.validate_segment_exists(segment_id=segment_id)
- if not is_valid_segment:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE)
- parent_segment = await get_segment_by_id(segment_id=segment_id)
- mapped_segments = await get_related_mapped_segments(parent_segment_id=segment_id)
- commentaries = await SegmentUtils.filter_segment_mapping_by_type_or_text_id(segments=mapped_segments, type="commentary")
- response = SegmentCommentariesResponse(
- parent_segment=ParentSegment(
- segment_id=segment_id,
- content=parent_segment.content
- ),
- commentaries=commentaries
- )
-
- return response
-
-async def get_info_by_segment_id(segment_id: str) -> SegmentInfoResponse:
-
- is_valid_segment = await SegmentUtils.validate_segment_exists(segment_id=segment_id)
- if not is_valid_segment:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE)
-
- cache_data = await get_segment_info_by_id_cache(segment_id=segment_id, cache_type=CacheType.SEGMENT_INFO)
- if cache_data:
- return cache_data
- segment = await get_segment_by_id(segment_id=segment_id)
- text_detail=await TextUtils.get_text_details_by_id(text_id=segment.text_id)
- mapped_segments = await get_related_mapped_segments(parent_segment_id=segment_id)
- counts = await SegmentUtils.get_count_of_each_commentary_and_version(mapped_segments,parent_text=text_detail)
- segment_root_mapping_count = await SegmentUtils.get_root_mapping_count(segment_id=segment_id)
- response = SegmentInfoResponse(
- segment_info= SegmentInfo(
- segment_id=segment_id,
- text_id=text_detail.id,
- translations=counts["version"],
- related_text=RelatedText(
- commentaries=counts["commentary"],
- root_text=segment_root_mapping_count
- ),
- resources=Resources(
- sheets=0
- )
- )
- )
- await set_segment_info_by_id_cache(
- segment_id = segment_id,
- cache_type = CacheType.SEGMENT_INFO,
- data = response
- )
- return response
-
-async def get_root_text_mapping_by_segment_id(segment_id: str) -> SegmentRootMappingResponse:
-
- is_valid_segment = await SegmentUtils.validate_segment_exists(segment_id=segment_id)
- if not is_valid_segment:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE)
- parent_segment = await get_segment_by_id(segment_id=segment_id)
- parent_text = await TextUtils.get_text_details_by_id(text_id=parent_segment.text_id)
- mapped_segments = await get_related_mapped_segments(parent_segment_id=segment_id)
- segment_root_mapping = await SegmentUtils.get_segment_root_mapping_details(segments=mapped_segments, parent_segment_text=parent_text)
- response = SegmentRootMappingResponse(
- parent_segment=ParentSegment(
- segment_id=segment_id,
- content=parent_segment.content
- ),
- segment_root_mapping=segment_root_mapping
- )
- return response
-async def fetch_segments_by_text_id(text_id: str) -> List[SegmentDTO]:
- segments = await get_segments_by_text_id(text_id=text_id)
- return segments
-
-async def remove_segments_by_text_id(text_id: str):
- is_valid_text = await TextUtils.validate_text_exists(text_id=text_id)
- if not is_valid_text:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE)
- return await delete_segments_by_text_id(text_id=text_id)
-
-
-async def update_segments_service(token: str, segment_update_request: SegmentUpdateRequest):
- is_admin = verify_admin_access(token=token)
- if is_admin:
- text = await get_text_by_pecha_text_id(pecha_text_id=segment_update_request.pecha_text_id)
- if not text:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE)
-
- return await update_segment_by_id(segment_update_request=segment_update_request)
-
-
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE)
\ No newline at end of file
diff --git a/pecha_api/texts/segments/segments_utils.py b/pecha_api/texts/segments/segments_utils.py
index 3e21717dc..6b6181646 100644
--- a/pecha_api/texts/segments/segments_utils.py
+++ b/pecha_api/texts/segments/segments_utils.py
@@ -2,27 +2,21 @@
from uuid import UUID
from fastapi import HTTPException
from starlette import status
-import bophono
-from botok.tokenizers.wordtokenizer import WordTokenizer
from pecha_api.error_contants import ErrorConstants
from pecha_api.texts.texts_enums import TextType
-from .segments_response_models import MappedSegmentDTO, MappedSegmentResponseDTO, SegmentDTO, SegmentCommentry, SegmentTranslation, SegmentTransliteration, SegmentAdaptation, SegmentRootMapping, SegmentRecitation
+from .segments_response_models import MappedSegmentDTO, SegmentDTO, SegmentCommentry, SegmentTranslation, SegmentAdaptation
from .segments_repository import (
check_segment_exists,
check_all_segment_exists,
get_segment_by_id,
get_related_mapped_segments,
)
-from ..texts_response_models import TextDTO
from ..texts_repository import get_contents_by_id
from pecha_api.constants import Constants
-from ..groups.groups_service import (
- get_group_details
-)
from ..texts_utils import TextUtils
from ..texts_response_models import (
DetailTableOfContent,
@@ -36,8 +30,6 @@
SegmentTranslation,
SegmentTransliteration,
SegmentAdaptation,
- SegmentRootMapping,
- SegmentRecitation
)
def _extract_segment_order(section, segment_order_map: Dict[str, int]):
@@ -95,31 +87,6 @@ async def validate_segments_exists(segment_ids: List[str]) -> bool:
detail=f"{ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE} {segment_ids}",
)
return all_exists
-
-
- @staticmethod
- async def get_count_of_each_commentary_and_version(
- segments: List[SegmentDTO],
- parent_text: TextDTO
- ) -> Dict[str, int]:
- """
- Count the number of commentary and version segments in the provided list.
- """
- count = {"commentary": 0, "version": 0}
- unique_text_ids = set()
- text_ids = [segment.text_id for segment in segments]
- text_details_dict = await TextUtils.get_text_details_by_ids(text_ids=text_ids)
- for segment in segments:
- text_id = segment.text_id
- if text_id in unique_text_ids:
- continue
- unique_text_ids.add(text_id)
- text_detail = text_details_dict.get(text_id)
- if text_detail and text_detail.type == "commentary":
- count["commentary"] += 1
- elif text_detail and text_detail.type == "version" and text_detail.type == parent_text.type:
- count["version"] += 1
- return count
@staticmethod
async def filter_segment_mapping_by_type_or_text_id(
@@ -186,23 +153,6 @@ async def filter_segment_mapping_by_type_or_text_id(
appended_commentary_text_ids.append(segment.text_id)
return filtered_segments
-
- @staticmethod
- async def get_root_mapping_count(segment_id: str) -> int:
- segment = await get_segment_by_id(segment_id=segment_id)
- text_id = segment.text_id
- text_detail = await TextUtils.get_text_details_by_id(text_id=text_id)
- group_id = text_detail.group_id
- group_detail = await get_group_details(group_id=group_id)
- if group_detail.type == "text":
- return 0
- root_mapping_count = 0
- for mapping in segment.mapping:
- text_detail = await TextUtils.get_text_details_by_id(text_id=mapping.text_id)
- if text_detail.type == "commentary":
- continue
- root_mapping_count += 1
- return root_mapping_count
@staticmethod
async def get_mapped_segment_content_for_table_of_content(
@@ -210,7 +160,7 @@ async def get_mapped_segment_content_for_table_of_content(
) -> DetailTableOfContent:
"""
Convert a TableOfContent model to a DetailTableOfContent model by enriching
- each segment with detailed information fetched from get_segment_details_by_id.
+ each segment with content loaded from the segment repository.
Args:
table_of_content: The TableOfContent model to be converted
@@ -281,42 +231,6 @@ async def process_section(section) -> DetailSection:
return detail_table_of_content
- @staticmethod
- async def get_segment_root_mapping_details(segments: List[SegmentDTO], parent_segment_text: TextDTO) -> List[SegmentRootMapping]:
- list_of_text_ids = [
- segment.text_id
- for segment in segments
- ]
- texts_dict = await TextUtils.get_text_details_by_ids(text_ids=list_of_text_ids)
- grouped_segments = await SegmentUtils._group_segment_content_by_text_id(segments=segments)
- list_of_segment_root_mapping = []
- appended_text_ids = []
-
- for segment in segments:
- text_detail = texts_dict.get(segment.text_id)
- if text_detail:
- if segment.text_id in appended_text_ids:
- continue
- mapped_segments = []
- for segment_item in grouped_segments.get(segment.text_id, []):
- mapped_segments.append(MappedSegmentResponseDTO(
- segment_id=str(segment_item.id),
- content=segment_item.content,
- language=text_detail.language
- ))
- if text_detail.type == parent_segment_text.type:
- continue
- list_of_segment_root_mapping.append(
- SegmentRootMapping(
- text_id=segment.text_id,
- title=text_detail.title,
- language=text_detail.language,
- segments=mapped_segments
- )
- )
- appended_text_ids.append(segment.text_id)
- return list_of_segment_root_mapping
-
@staticmethod
async def _group_segment_content_by_text_id(segments: List[SegmentDTO]) -> Dict[str, List[SegmentDTO]]:
grouped_segments = {}
@@ -340,20 +254,4 @@ async def _group_segment_content_by_text_id(segments: List[SegmentDTO]) -> Dict[
segment_list.sort(key=lambda s: s.pecha_segment_id or "")
return grouped_segments
-
- @staticmethod
- def apply_bophono(segmentContent:str)->str:
- options = {
- 'aspirateLowTones': True
- }
- tokenizer = WordTokenizer()
- tokens = tokenizer.tokenize(segmentContent)
- token_text = []
- for token in tokens:
- token_text.append(token.text)
- kvpconverter = bophono.UnicodeToApi(schema="KVP", options = options)
- kvp_ipa_list = []
- for segment in token_text:
- kvp_ipa = kvpconverter.get_api(segment)
- kvp_ipa_list.append(kvp_ipa)
- return " ".join(kvp_ipa_list)
+
\ No newline at end of file
diff --git a/pecha_api/texts/segments/segments_views.py b/pecha_api/texts/segments/segments_views.py
deleted file mode 100644
index dae5224bc..000000000
--- a/pecha_api/texts/segments/segments_views.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
-from fastapi import APIRouter, Depends
-from starlette import status
-
-from typing import Annotated
-
-from .segments_service import (
- create_new_segment,
- get_translations_by_segment_id,
- get_commentaries_by_segment_id,
- get_segment_details_by_id,
- get_info_by_segment_id,
- get_root_text_mapping_by_segment_id,
- update_segments_service
-)
-from .segments_response_models import (
- CreateSegmentRequest,
- SegmentDTO,
- SegmentResponse,
- SegmentInfoResponse,
- SegmentTranslationsResponse,
- SegmentCommentariesResponse,
- SegmentUpdateRequest
-)
-
-oauth2_scheme = HTTPBearer()
-segment_router = APIRouter(
- prefix="/segments",
- tags=["Segments"]
-)
-
-from fastapi import Query
-
-
-@segment_router.post("", status_code=status.HTTP_201_CREATED)
-async def create_segment(
- create_segment_request: CreateSegmentRequest,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)],
-) -> SegmentResponse:
- return await create_new_segment(create_segment_request=create_segment_request, token=authentication_credential.credentials)
-
-@segment_router.get("/{segment_id}", status_code=status.HTTP_200_OK)
-async def get_segment(
- segment_id: str,
- text_details: bool = Query(default=False)
-) -> SegmentDTO:
- return await get_segment_details_by_id(segment_id=segment_id, text_details=text_details)
-
-@segment_router.get("/{segment_id}/info", status_code=status.HTTP_200_OK)
-async def get_info_for_segment(
- segment_id: str
-) -> SegmentInfoResponse:
- return await get_info_by_segment_id(segment_id=segment_id)
-
-@segment_router.get("/{segment_id}/root_text", status_code=status.HTTP_200_OK)
-async def get_root_text_for_segment(
- segment_id: str
-):
- return await get_root_text_mapping_by_segment_id(segment_id=segment_id)
-
-@segment_router.get("/{segment_id}/translations", status_code=status.HTTP_200_OK)
-async def get_translations_for_segment(
- segment_id: str
-) -> SegmentTranslationsResponse:
- return await get_translations_by_segment_id(
- segment_id=segment_id
- )
-
-@segment_router.get("/{segment_id}/commentaries", status_code=status.HTTP_200_OK)
-async def get_commentaries_for_segment(
- segment_id: str
-) -> SegmentCommentariesResponse:
- return await get_commentaries_by_segment_id(
- segment_id=segment_id
- )
-
-
-@segment_router.put("", status_code=status.HTTP_200_OK)
-async def update_segment(
- segment_update_request: SegmentUpdateRequest,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)],
-):
- return await update_segments_service(
- token=authentication_credential.credentials,
- segment_update_request=segment_update_request,
-
- )
\ No newline at end of file
diff --git a/pecha_api/texts/texts_cache_service.py b/pecha_api/texts/texts_cache_service.py
index 8c51cd884..ea4eaca67 100644
--- a/pecha_api/texts/texts_cache_service.py
+++ b/pecha_api/texts/texts_cache_service.py
@@ -73,26 +73,6 @@ async def set_table_of_contents_by_text_id_cache(text_id: str = None, language:
cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-async def get_table_of_content_by_sheet_id_cache(sheet_id: str = None, cache_type: CacheType = None) -> Optional[TableOfContent]:
- payload = [sheet_id, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: TableOfContent = await get_cache_data(hash_key = hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = TableOfContent(**cache_data)
- return cache_data
-
-async def set_table_of_content_by_sheet_id_cache(sheet_id: str = None, cache_type: CacheType = None, data: TableOfContent = None):
- #Set table of content by sheet id cache asynchronously.
- payload = [sheet_id, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_SHEET_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def delete_table_of_content_by_sheet_id_cache(sheet_id: str = None, cache_type: CacheType = None):
- payload = [sheet_id, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- await clear_cache(hash_key = hashed_key)
-
async def get_text_versions_by_group_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> TextVersionResponse:
#Get text versions by group_id cache asynchronously.
payload = [text_id, language, skip, limit, cache_type]
@@ -131,93 +111,47 @@ async def delete_text_details_by_id_cache(text_id: str = None, cache_type: Cache
async def update_text_details_cache(text_id: str, updated_text_data: TextDTO, cache_type: CacheType = CacheType.TEXT_DETAIL) -> bool:
- #Update cached text details for a specific text_id with new data. Handles both text and sheet instances.
try:
- # Determine cache types based on whether this is a text or sheet
- is_sheet = cache_type == CacheType.SHEET_DETAIL
-
- # Update primary cache (TEXT_DETAIL for texts, SHEET_DETAIL for sheets)
primary_cache_payload = [text_id, cache_type]
primary_cache_hash_key = Utils.generate_hash_key(payload=primary_cache_payload)
- # Update TEXTS_BY_ID_OR_COLLECTION cache (used for both texts and sheets)
texts_by_id_payload = [text_id, None, None, None, None, CacheType.TEXTS_BY_ID_OR_COLLECTION]
texts_by_id_hash_key = Utils.generate_hash_key(payload=texts_by_id_payload)
- # Try to update existing cache entries
update_results = []
- # Update primary cache (text detail or sheet detail)
cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
is_primary_cache_updated = await update_cache(hash_key=primary_cache_hash_key, value=updated_text_data, cache_time_out=cache_time_out)
update_results.append(is_primary_cache_updated)
- # Update texts by id cache
is_texts_by_id_updated = await update_cache(hash_key=texts_by_id_hash_key, value=updated_text_data, cache_time_out=cache_time_out)
update_results.append(is_texts_by_id_updated)
- # For sheets, also try to update table of content cache
- if is_sheet:
- try:
- from .texts_service import get_table_of_content_by_sheet_id
- updated_table_of_content = await get_table_of_content_by_sheet_id(sheet_id=text_id)
- if updated_table_of_content:
- toc_payload = [text_id, CacheType.SHEET_TABLE_OF_CONTENT]
- toc_hash_key = Utils.generate_hash_key(payload=toc_payload)
- is_toc_updated = await update_cache(hash_key=toc_hash_key, value=updated_table_of_content, cache_time_out=cache_time_out)
- update_results.append(is_toc_updated)
- except Exception as e:
- logging.warning(f"Could not update table of content cache for sheet {text_id}: {str(e)}")
-
- # If direct updates failed, fallback to invalidation to ensure cache consistency
if not any(update_results):
await invalidate_text_cache_on_update(text_id=text_id, cache_type=cache_type)
return True
except Exception as e:
logging.error(f"Error updating text details cache for text_id {text_id}: {str(e)}", exc_info=True)
- # Fallback to cache invalidation to maintain consistency
await invalidate_text_cache_on_update(text_id=text_id, cache_type=cache_type)
return False
async def invalidate_text_cache_on_update(text_id: str, cache_type: CacheType = CacheType.TEXT_DETAIL) -> bool:
- #Invalidate all cache entries related to a text when it's updated. Handles both text and sheet instances.
try:
- # Generate hash keys for all possible cache entries related to this text/sheet
cache_keys_to_invalidate = []
- # Determine if this is a sheet
- is_sheet = cache_type == CacheType.SHEET_DETAIL
-
- # Primary cache (TEXT_DETAIL for texts, SHEET_DETAIL for sheets)
primary_cache_payload = [text_id, cache_type]
cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=primary_cache_payload))
- # TEXTS_BY_ID_OR_COLLECTION cache (used for both texts and sheets)
texts_by_id_payload = [text_id, None, None, None, None, CacheType.TEXTS_BY_ID_OR_COLLECTION]
cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=texts_by_id_payload))
- if is_sheet:
- # Sheet-specific cache entries
-
- # SHEET_TABLE_OF_CONTENT cache
- sheet_toc_payload = [text_id, CacheType.SHEET_TABLE_OF_CONTENT]
- cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=sheet_toc_payload))
-
- # SHEETS cache (general sheets listing)
- sheets_payload = [None, None, None, None, None, None, CacheType.SHEETS]
- cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=sheets_payload))
- else:
- # Text-specific cache entries
-
- # TEXT_TABLE_OF_CONTENTS cache
- toc_payload = [text_id, None, None, None, CacheType.TEXT_TABLE_OF_CONTENTS]
- cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=toc_payload))
-
- # TEXT_VERSIONS cache
- versions_payload = [text_id, None, None, None, CacheType.TEXT_VERSIONS]
- cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=versions_payload))
+ toc_payload = [text_id, None, None, None, CacheType.TEXT_TABLE_OF_CONTENTS]
+ cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=toc_payload))
+
+ versions_payload = [text_id, None, None, None, CacheType.TEXT_VERSIONS]
+ cache_keys_to_invalidate.append(Utils.generate_hash_key(payload=versions_payload))
# Invalidate specific cache keys
await invalidate_multiple_cache_keys(hash_keys=cache_keys_to_invalidate)
diff --git a/pecha_api/texts/texts_enums.py b/pecha_api/texts/texts_enums.py
index 9d2ef9dd6..2f9b5109b 100644
--- a/pecha_api/texts/texts_enums.py
+++ b/pecha_api/texts/texts_enums.py
@@ -12,6 +12,16 @@ class TextType(Enum):
class TextTypes(Enum):
VERSIONS = "versions"
+class SortOrder(Enum):
+ ASC = "asc"
+ DESC = "desc"
+
+
+class SortBy(Enum):
+ CREATED_DATE = "created_date"
+ PUBLISHED_DATE = "published_date"
+
+
class PaginationDirection(Enum):
NEXT = "next"
PREVIOUS = "previous"
diff --git a/pecha_api/texts/texts_models.py b/pecha_api/texts/texts_models.py
index efea44a2c..cb5b9baea 100644
--- a/pecha_api/texts/texts_models.py
+++ b/pecha_api/texts/texts_models.py
@@ -7,11 +7,6 @@
from pydantic import Field
from beanie import Document
-from pecha_api.sheets.sheets_enum import (
- SortBy,
- SortOrder
-)
-
from .texts_enums import TextType
from .texts_response_models import TextDTO, TableOfContentType
@@ -211,56 +206,3 @@ async def delete_text_by_id(cls, text_id: UUID):
return await cls.find_one(cls.id == text_id).delete()
- @classmethod
- async def get_sheets(
- cls,
- published_by: Optional[str] = None,
- is_published: Optional[bool] = None,
- sort_by: Optional[SortBy] = None,
- sort_order: Optional[SortOrder] = None,
- skip: int = 0,
- limit: int = 10
- ) -> List["Text"]:
- query = {"type": TextType.SHEET}
-
- if published_by is not None:
- query["published_by"] = published_by
-
- if is_published is not None:
- query["is_published"] = is_published
-
- mongo_query = cls.find(query)
- if sort_by:
- field = {
- SortBy.CREATED_DATE: "created_date",
- SortBy.PUBLISHED_DATE: "published_date",
- }
- sort_field = field.get(sort_by)
- if sort_field:
- if sort_order == SortOrder.DESC:
- sort_string = f"-{sort_field}"
- else:
- sort_string = sort_field
-
- mongo_query = mongo_query.sort(sort_string)
-
- mongo_query = mongo_query.skip(skip).limit(limit)
-
-
- texts = await mongo_query.to_list()
-
- return texts
-
-
- @classmethod
- async def get_published_sheets_count_from_db(
- cls,
- email: Optional[str] = None
- ) -> int:
- query = {
- "type": TextType.SHEET,
- "is_published": True
- }
- if email is not None:
- query["published_by"] = email
- return await cls.find(query).count()
diff --git a/pecha_api/texts/texts_repository.py b/pecha_api/texts/texts_repository.py
index 8a9ccfaf9..d5417d098 100644
--- a/pecha_api/texts/texts_repository.py
+++ b/pecha_api/texts/texts_repository.py
@@ -12,10 +12,6 @@
TextDTO,
UpdateTextRequest
)
-from pecha_api.sheets.sheets_enum import (
- SortBy,
- SortOrder
-)
from .texts_models import Text, TableOfContent
from datetime import datetime, timezone
from pecha_api.utils import Utils
@@ -69,25 +65,6 @@ async def get_texts_by_ids(text_ids: List[str]) -> Dict[str, TextDTO]:
for text in list_of_texts_detail
}
-async def fetch_sheets_from_db(
- published_by: Optional[str] = None,
- is_published: Optional[bool] = None,
- sort_by: Optional[SortBy] = None,
- sort_order: Optional[SortOrder] = None,
- skip: int = 0,
- limit: int = 10
-) -> List[Text]:
- sheets = await Text.get_sheets(
- published_by=published_by,
- is_published=is_published,
- sort_by=sort_by,
- sort_order=sort_order,
- skip=skip,
- limit=limit
- )
- return sheets
-
-
async def check_text_exists(text_id: UUID) -> bool:
try:
is_text_exits = await Text.check_exists(text_id=text_id)
diff --git a/pecha_api/texts/texts_service.py b/pecha_api/texts/texts_service.py
index caeb2f71e..06159ed99 100644
--- a/pecha_api/texts/texts_service.py
+++ b/pecha_api/texts/texts_service.py
@@ -5,17 +5,14 @@
from pecha_api.http_message_utils import handle_http_status_error, handle_request_error
from .texts_repository import (
get_all_texts_by_collection,
- get_texts_by_collection,
get_texts_by_group_id,
create_text,
create_table_of_content_detail,
get_contents_by_id,
get_table_of_content_by_content_id,
- get_sections_count_of_table_of_content,
delete_table_of_content_by_text_id,
update_text_details_by_id,
delete_text_by_id,
- fetch_sheets_from_db,
get_all_texts_by_collection,
get_all_recitation_texts_by_collection,
get_texts_by_pecha_text_ids,
@@ -63,25 +60,13 @@
)
from .segments.segments_models import Segment
from pecha_api.texts.texts_cache_service import (
- set_text_details_cache,
- get_text_details_cache,
get_text_by_text_id_or_collection_cache,
set_text_by_text_id_or_collection_cache,
- get_table_of_contents_by_text_id_cache,
- set_table_of_contents_by_text_id_cache,
- get_text_versions_by_group_id_cache,
set_text_versions_by_group_id_cache,
- get_table_of_content_by_sheet_id_cache,
- set_table_of_content_by_sheet_id_cache,
- delete_table_of_content_by_sheet_id_cache,
update_text_details_cache,
invalidate_text_cache_on_update
)
from .segments.segments_repository import get_segments_by_text_id
-from pecha_api.sheets.sheets_enum import (
- SortBy,
- SortOrder
-)
from pecha_api.cache.cache_enums import CacheType
from .texts_utils import TextUtils
@@ -95,7 +80,7 @@
from typing import List, Dict, Optional, Tuple, Set
from pecha_api.config import get
from pecha_api.utils import Utils
-from .texts_enums import PaginationDirection, LANGUAGE_ORDERS, TextType, TextTypes
+from .texts_enums import PaginationDirection, TextType, TextTypes
import logging
import httpx
@@ -216,37 +201,6 @@ async def get_titles_and_ids_by_query(
return [TitleSearchResult(id=str(text.id), title=text.title) for text in texts]
-async def get_sheet(published_by: Optional[str] = None, is_published: Optional[bool] = None, sort_by: Optional[SortBy] = None, sort_order: Optional[SortOrder] = None, skip: int = 0, limit: int = 10):
-
- sheets = await fetch_sheets_from_db(
- published_by=published_by,
- is_published=is_published,
- sort_by=sort_by,
- sort_order=sort_order,
- skip=skip,
- limit=limit
- )
- return sheets
-
-async def get_table_of_content_by_sheet_id(sheet_id: str) -> Optional[TableOfContent]:
- cached_data: TableOfContent = await get_table_of_content_by_sheet_id_cache(sheet_id=sheet_id, cache_type=CacheType.SHEET_TABLE_OF_CONTENT)
- if cached_data is not None:
- return cached_data
-
- table_of_content = None
- is_valid_sheet: bool = await TextUtils.validate_text_exists(text_id=sheet_id)
- if not is_valid_sheet:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE)
-
- table_of_contents: List[TableOfContent] = await get_contents_by_id(text_id=sheet_id)
- if len(table_of_contents) > 0 and table_of_contents[0] is not None:
- table_of_content: TableOfContent = table_of_contents[0]
-
- if table_of_content is not None:
- await set_table_of_content_by_sheet_id_cache(sheet_id=sheet_id, cache_type=CacheType.SHEET_TABLE_OF_CONTENT, data=table_of_content)
-
- return table_of_content
-
async def get_table_of_contents_by_text_id(text_id: str, language: str = None, skip: int = 0, limit: int = 10) -> TableOfContentResponse:
if language is None:
diff --git a/tests/collection/test_collection_views.py b/tests/collection/test_collection_views.py
index 37e44c477..72fb64362 100644
--- a/tests/collection/test_collection_views.py
+++ b/tests/collection/test_collection_views.py
@@ -275,50 +275,3 @@ async def test_delete_collection_with_children():
assert response.status_code == status.HTTP_400_BAD_REQUEST
-
-# Tests for GET /text-uploader/collections/{pecha_collection_id} endpoint
-
-@pytest.mark.asyncio
-async def test_get_collection_by_pecha_collection_id_success():
- # Test successful retrieval of collection by pecha_collection_id
- pecha_collection_id = "pecha_60d21b4667d0d8992e610c85"
- expected_collection_id = "60d21b4667d0d8992e610c85"
-
- with patch("pecha_api.text_uploader.collections.uploader_collections_views.get_collection_by_pecha_collection_id_service",
- new_callable=AsyncMock, return_value=expected_collection_id):
-
- response = client.get(f"/text-uploader/collections/{pecha_collection_id}")
-
- assert response.status_code == status.HTTP_200_OK
- assert response.json() == expected_collection_id
-
-
-@pytest.mark.asyncio
-async def test_get_collection_by_pecha_collection_id_not_found():
- # Test retrieval when pecha_collection_id doesn't exist
- pecha_collection_id = "nonexistent_pecha_id"
-
- with patch("pecha_api.text_uploader.collections.uploader_collections_views.get_collection_by_pecha_collection_id_service",
- new_callable=AsyncMock, return_value=None):
-
- response = client.get(f"/text-uploader/collections/{pecha_collection_id}")
-
- assert response.status_code == status.HTTP_200_OK
- assert response.json() is None
-
-
-@pytest.mark.asyncio
-async def test_get_collection_by_pecha_collection_id_service_error():
- # Test handling of service errors
- pecha_collection_id = "pecha_error_id"
-
- with patch("pecha_api.text_uploader.collections.uploader_collections_views.get_collection_by_pecha_collection_id_service",
- new_callable=AsyncMock, side_effect=HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal server error"
- )):
-
- response = client.get(f"/text-uploader/collections/{pecha_collection_id}")
-
- assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
-
diff --git a/tests/recitations/test_recitations_services.py b/tests/recitations/test_recitations_services.py
index faf419f0c..15aa8f540 100644
--- a/tests/recitations/test_recitations_services.py
+++ b/tests/recitations/test_recitations_services.py
@@ -379,15 +379,15 @@ def create_mock_segment_recitation(segment_id: str, language: str = "en", conten
content=content
)
- @patch('pecha_api.recitations.recitations_services.get_segment_details_by_id')
- @patch('pecha_api.recitations.recitations_services.get_related_mapped_segments')
+ @patch('pecha_api.recitations.recitations_services.get_segments_details_by_ids')
+ @patch('pecha_api.recitations.recitations_services.get_related_mapped_segments_batch')
@patch('pecha_api.recitations.recitations_services.SegmentUtils.filter_segment_mapping_by_type_or_text_id')
@pytest.mark.asyncio
async def test_segments_mapping_by_toc_empty_table_of_contents(
self,
mock_filter_segments,
mock_get_related_segments,
- mock_get_segment_details
+ mock_get_segments_details,
):
"""Test mapping with empty table of contents."""
request = RecitationDetailsRequest(
@@ -406,7 +406,7 @@ async def test_segments_mapping_by_toc_empty_table_of_contents(
assert result == []
# Verify no mock calls were made
- mock_get_segment_details.assert_not_called()
+ mock_get_segments_details.assert_not_called()
mock_get_related_segments.assert_not_called()
mock_filter_segments.assert_not_called()
diff --git a/tests/segments/test_segments_cache_service.py b/tests/segments/test_segments_cache_service.py
deleted file mode 100644
index 8f39d4982..000000000
--- a/tests/segments/test_segments_cache_service.py
+++ /dev/null
@@ -1,183 +0,0 @@
-import pytest
-from unittest.mock import AsyncMock, patch, MagicMock
-
-from pecha_api.texts.segments.segments_cache_service import (
- get_segment_details_by_id_cache,
- set_segment_details_by_id_cache,
- get_segment_root_mapping_by_id_cache,
- set_segment_root_mapping_by_id_cache,
- get_segment_commentaries_by_id_cache,
- set_segment_commentaries_by_id_cache,
- get_segment_translations_by_id_cache,
- set_segment_translations_by_id_cache
-)
-
-from pecha_api.texts.segments.segments_response_models import (
- SegmentDTO,
- SegmentRootMappingResponse,
- ParentSegment,
- SegmentRootMapping,
- SegmentCommentariesResponse,
- SegmentTranslation,
- SegmentTranslationsResponse
-)
-from pecha_api.texts.segments.segments_enum import SegmentType
-
-@pytest.mark.asyncio
-async def test_get_segment_details_by_id_cache_success():
- mock_segment = SegmentDTO(
- id="segment_id",
- text_id="text_id",
- mapping=[],
- content="content",
- type=SegmentType.CONTENT
- )
-
- with patch("pecha_api.texts.segments.segments_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_segment):
- response = await get_segment_details_by_id_cache(segment_id="segment_id", text_details=True)
-
- assert response is not None
- assert isinstance(response, SegmentDTO)
- assert response.id == "segment_id"
-
-@pytest.mark.asyncio
-async def test_set_segment_details_by_id_cache_success():
-
- mock_segment=SegmentDTO(
- id="segment_id",
- text_id="text_id",
- mapping=[],
- content="content",
- type=SegmentType.CONTENT
- )
-
- with patch("pecha_api.texts.segments.segments_cache_service.set_cache", new_callable=AsyncMock, return_value=None):
- response = await set_segment_details_by_id_cache(segment_id="segment_id", text_details=True, data=mock_segment)
-
-@pytest.mark.asyncio
-async def test_get_segment_root_mapping_by_id_cache_success():
- mock_segment_root_mapping = SegmentRootMappingResponse(
- parent_segment=ParentSegment(
- segment_id="segment_id",
- content="content"
- ),
- segment_root_mapping=[]
- )
-
- with patch("pecha_api.texts.segments.segments_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_segment_root_mapping):
-
- response = await get_segment_root_mapping_by_id_cache(segment_id="segment_id")
-
- assert response is not None
- assert isinstance(response, SegmentRootMappingResponse)
- assert response.parent_segment is not None
- assert isinstance(response.parent_segment, ParentSegment)
- assert response.parent_segment.segment_id == "segment_id"
- assert response.parent_segment.content == "content"
- assert response.segment_root_mapping == []
-
-@pytest.mark.asyncio
-async def test_set_segment_root_mapping_by_id_cache_success():
- mock_segment_root_mapping = SegmentRootMappingResponse(
- parent_segment=ParentSegment(
- segment_id="segment_id",
- content="content"
- ),
- segment_root_mapping=[]
- )
-
- with patch("pecha_api.texts.segments.segments_cache_service.set_cache", new_callable=AsyncMock, return_value=None):
-
- response = await set_segment_root_mapping_by_id_cache(segment_id="segment_id", data=mock_segment_root_mapping)
-
-@pytest.mark.asyncio
-async def test_get_segment_commentaries_by_id_cache_success():
- mock_segment_commentaries = SegmentCommentariesResponse(
- parent_segment=ParentSegment(
- segment_id="segment_id",
- content="content"
- ),
- commentaries=[]
- )
-
- with patch("pecha_api.texts.segments.segments_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_segment_commentaries):
-
- response = await get_segment_commentaries_by_id_cache(segment_id="segment_id")
-
- assert response is not None
- assert isinstance(response, SegmentCommentariesResponse)
- assert response.parent_segment is not None
- assert isinstance(response.parent_segment, ParentSegment)
- assert response.parent_segment.segment_id == "segment_id"
- assert response.parent_segment.content == "content"
- assert response.commentaries == []
-
-@pytest.mark.asyncio
-async def test_set_segment_commentaries_by_id_cache_success():
- mock_segment_commentaries = SegmentCommentariesResponse(
- parent_segment=ParentSegment(
- segment_id="segment_id",
- content="content"
- ),
- commentaries=[]
- )
-
- with patch("pecha_api.texts.segments.segments_cache_service.set_cache", new_callable=AsyncMock, return_value=None):
-
- response = await set_segment_commentaries_by_id_cache(segment_id="segment_id", data=mock_segment_commentaries)
-
-@pytest.mark.asyncio
-async def test_get_segment_translations_by_id_cache_success():
- mock_data = SegmentTranslationsResponse(
- parent_segment=ParentSegment(
- segment_id="segment_id",
- content="content"
- ),
- translations=[
- SegmentTranslation(
- segment_id="segment_id",
- text_id="text_id",
- title="title",
- source="source",
- language="language",
- content="content"
- )
- ]
- )
-
- with patch("pecha_api.texts.segments.segments_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_data):
-
- response = await get_segment_translations_by_id_cache(segment_id="segment_id")
-
- assert response is not None
- assert isinstance(response, SegmentTranslationsResponse)
- assert response.parent_segment is not None
- assert isinstance(response.parent_segment, ParentSegment)
- assert response.parent_segment.segment_id == "segment_id"
- assert response.parent_segment.content == "content"
- assert response.translations is not None
- assert len(response.translations) == 1
- assert response.translations[0].segment_id == "segment_id"
-
-@pytest.mark.asyncio
-async def test_set_segment_translations_by_id_cache_success():
- mock_data = SegmentTranslationsResponse(
- parent_segment=ParentSegment(
- segment_id="segment_id",
- content="content"
- ),
- translations=[
- SegmentTranslation(
- segment_id="segment_id",
- text_id="text_id",
- title="title",
- source="source",
- language="language",
- content="content"
- )
- ]
- )
-
- with patch("pecha_api.texts.segments.segments_cache_service.set_cache", new_callable=AsyncMock, return_value=None):
-
- await set_segment_translations_by_id_cache(segment_id="segment_id", data=mock_data)
diff --git a/tests/segments/test_segments_openpecha_service.py b/tests/segments/test_segments_openpecha_service.py
index cdf2cffa2..217d31824 100644
--- a/tests/segments/test_segments_openpecha_service.py
+++ b/tests/segments/test_segments_openpecha_service.py
@@ -4,17 +4,22 @@
from pecha_api.texts.segments.segments_openpecha_service import (
_classify_text,
- get_translations_by_segment_id_from_openpecha,
get_commentaries_by_segment_id_from_openpecha,
+ get_openpecha_segment_details_by_id,
+ get_root_text_by_segment_id_from_openpecha,
+ get_translations_by_segment_id_from_openpecha,
)
from pecha_api.texts.segments.segments_response_models import (
V2SegmentCommentariesResponse,
+ V2SegmentResponse,
+ V2SegmentRootTextResponse,
V2SegmentTranslationsResponse,
)
PARENT_SEGMENT_ID = "parent-seg-1"
TRANSLATION_TEXT_ID = "text-trans-1"
COMMENTARY_TEXT_ID = "text-comm-1"
+ROOT_TEXT_ID = "1AIZhR8IBkX4WMfNYkmpc"
def _translation_text(text_id: str) -> dict:
@@ -37,6 +42,16 @@ def _commentary_text(text_id: str) -> dict:
}
+def _root_text(text_id: str) -> dict:
+ return {
+ "id": text_id,
+ "title": {"bo": f"Root text {text_id}"},
+ "language": "bo",
+ "translation_of": None,
+ "commentary_of": None,
+ }
+
+
def _related_item(segment_id: str, text_id: str) -> dict:
return {
"id": segment_id,
@@ -72,6 +87,82 @@ def test_returns_none_for_empty_payload(self):
assert _classify_text(None) is None
+class TestGetOpenpechaSegmentDetailsById:
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content",
+ new_callable=AsyncMock,
+ )
+ async def test_returns_segment_content_without_text_details(
+ self,
+ mock_fetch_content,
+ mock_fetch_details,
+ mock_fetch_text,
+ ):
+ mock_fetch_content.return_value = "Segment content"
+ mock_fetch_details.return_value = {"text_id": "unused-text-ref"}
+ mock_fetch_text.return_value = None
+
+ result = await get_openpecha_segment_details_by_id(segment_id="seg-1")
+
+ assert isinstance(result, V2SegmentResponse)
+ assert result.segment_id == "seg-1"
+ assert result.content == "Segment content"
+ assert result.text is None
+ mock_fetch_content.assert_awaited_once_with("seg-1")
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content",
+ new_callable=AsyncMock,
+ )
+ async def test_returns_text_details_when_segment_details_include_text_id(
+ self,
+ mock_fetch_content,
+ mock_fetch_details,
+ mock_fetch_text,
+ ):
+ mock_fetch_content.return_value = "Segment content"
+ mock_fetch_details.return_value = {"text_id": ROOT_TEXT_ID}
+ mock_fetch_text.return_value = _root_text(ROOT_TEXT_ID)
+
+ result = await get_openpecha_segment_details_by_id(segment_id="seg-1")
+
+ assert result.text is not None
+ assert result.text.text_id == ROOT_TEXT_ID
+ assert result.text.title == f"Root text {ROOT_TEXT_ID}"
+ assert result.text.language == "bo"
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content",
+ new_callable=AsyncMock,
+ )
+ async def test_raises_not_found_when_content_missing(self, mock_fetch_content):
+ mock_fetch_content.return_value = None
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_openpecha_segment_details_by_id(segment_id="missing-seg")
+
+ assert exc_info.value.status_code == 404
+
+
class TestGetTranslationsBySegmentIdFromOpenpecha:
@pytest.mark.asyncio
@patch(
@@ -398,3 +489,198 @@ async def text_side_effect(text_id: str):
assert len(result.translations) == 1
assert result.translations[0].text_id == TRANSLATION_TEXT_ID
assert len(result.translations[0].segments) == 1
+
+
+class TestGetRootTextBySegmentIdFromOpenpecha:
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_related_segments",
+ new_callable=AsyncMock,
+ )
+ async def test_returns_root_text_segments_for_matching_text_id(
+ self,
+ mock_fetch_related,
+ mock_fetch_content,
+ mock_fetch_text,
+ ):
+ mock_fetch_related.return_value = _related_page(
+ [
+ _related_item("root-seg-1", ROOT_TEXT_ID),
+ _related_item("root-seg-2", ROOT_TEXT_ID),
+ _related_item("seg-trans-1", TRANSLATION_TEXT_ID),
+ ],
+ has_more=False,
+ )
+
+ async def content_side_effect(segment_id: str):
+ return {
+ PARENT_SEGMENT_ID: "Parent segment content",
+ "root-seg-1": "Root segment 1",
+ "root-seg-2": "Root segment 2",
+ }.get(segment_id)
+
+ mock_fetch_content.side_effect = content_side_effect
+ mock_fetch_text.return_value = _root_text(ROOT_TEXT_ID)
+
+ result = await get_root_text_by_segment_id_from_openpecha(
+ text_id=ROOT_TEXT_ID,
+ segment_id=PARENT_SEGMENT_ID,
+ skip=0,
+ limit=10,
+ )
+
+ assert isinstance(result, V2SegmentRootTextResponse)
+ assert result.parent_segment.segment_id == PARENT_SEGMENT_ID
+ assert result.parent_segment.content == "Parent segment content"
+ assert len(result.root_text) == 1
+ assert result.root_text[0].text_id == ROOT_TEXT_ID
+ assert result.root_text[0].title == f"Root text {ROOT_TEXT_ID}"
+ assert result.root_text[0].language == "bo"
+ assert len(result.root_text[0].segments) == 2
+ assert result.root_text[0].segments[0].id == "root-seg-1"
+ assert result.root_text[0].segments[0].content == "Root segment 1"
+ assert result.has_more is False
+
+ mock_fetch_related.assert_awaited_once_with(
+ segment_id=PARENT_SEGMENT_ID,
+ limit=100,
+ offset=0,
+ )
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_related_segments",
+ new_callable=AsyncMock,
+ )
+ async def test_paginates_matching_root_text_segments(
+ self,
+ mock_fetch_related,
+ mock_fetch_content,
+ mock_fetch_text,
+ ):
+ async def related_side_effect(segment_id: str, limit: int, offset: int):
+ if offset == 0:
+ return _related_page(
+ [_related_item(f"root-seg-{index}", ROOT_TEXT_ID) for index in range(1, 3)],
+ has_more=True,
+ offset=0,
+ limit=100,
+ )
+ return _related_page(
+ [_related_item(f"root-seg-{index}", ROOT_TEXT_ID) for index in range(3, 5)],
+ has_more=False,
+ offset=100,
+ limit=100,
+ )
+
+ mock_fetch_related.side_effect = related_side_effect
+
+ async def content_side_effect(segment_id: str):
+ return {
+ PARENT_SEGMENT_ID: "Parent segment content",
+ "root-seg-1": "Root segment 1",
+ "root-seg-2": "Root segment 2",
+ "root-seg-3": "Root segment 3",
+ "root-seg-4": "Root segment 4",
+ }.get(segment_id)
+
+ mock_fetch_content.side_effect = content_side_effect
+ mock_fetch_text.return_value = _root_text(ROOT_TEXT_ID)
+
+ result = await get_root_text_by_segment_id_from_openpecha(
+ text_id=ROOT_TEXT_ID,
+ segment_id=PARENT_SEGMENT_ID,
+ skip=1,
+ limit=2,
+ )
+
+ assert len(result.root_text) == 1
+ assert len(result.root_text[0].segments) == 2
+ assert result.root_text[0].segments[0].id == "root-seg-2"
+ assert result.root_text[0].segments[1].id == "root-seg-3"
+ assert result.skip == 1
+ assert result.limit == 2
+ assert result.has_more is True
+ assert mock_fetch_related.await_count == 2
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_related_segments",
+ new_callable=AsyncMock,
+ )
+ async def test_returns_empty_root_text_when_no_matching_text_id(
+ self,
+ mock_fetch_related,
+ mock_fetch_content,
+ ):
+ mock_fetch_related.return_value = _related_page(
+ [_related_item("seg-trans-1", TRANSLATION_TEXT_ID)]
+ )
+ mock_fetch_content.return_value = "Parent content"
+
+ result = await get_root_text_by_segment_id_from_openpecha(
+ text_id=ROOT_TEXT_ID,
+ segment_id=PARENT_SEGMENT_ID,
+ )
+
+ assert result.root_text == []
+ assert result.has_more is False
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_content",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_related_segments",
+ new_callable=AsyncMock,
+ )
+ async def test_stops_fetching_after_enough_matches(
+ self,
+ mock_fetch_related,
+ mock_fetch_content,
+ ):
+ mock_fetch_related.side_effect = [
+ _related_page(
+ [_related_item(f"root-seg-{index}", ROOT_TEXT_ID) for index in range(1, 12)],
+ has_more=True,
+ ),
+ ]
+
+ mock_fetch_content.return_value = "content"
+
+ with patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ return_value=_root_text(ROOT_TEXT_ID),
+ ):
+ result = await get_root_text_by_segment_id_from_openpecha(
+ text_id=ROOT_TEXT_ID,
+ segment_id=PARENT_SEGMENT_ID,
+ skip=0,
+ limit=10,
+ )
+
+ assert len(result.root_text[0].segments) == 10
+ assert result.has_more is True
+ mock_fetch_related.assert_awaited_once()
diff --git a/tests/segments/test_segments_openpecha_views.py b/tests/segments/test_segments_openpecha_views.py
index a75b58049..7be0b94d6 100644
--- a/tests/segments/test_segments_openpecha_views.py
+++ b/tests/segments/test_segments_openpecha_views.py
@@ -1,4 +1,4 @@
-from unittest.mock import patch
+from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from fastapi.testclient import TestClient
@@ -8,16 +8,72 @@
ParentSegment,
V2RelatedSegmentItem,
V2SegmentCommentariesResponse,
+ V2SegmentResponse,
+ V2SegmentTextDetail,
V2SegmentTextGroup,
V2SegmentTranslationsResponse,
)
client = TestClient(api)
+SEGMENTS_ROOT = "/segments"
+
+
+class TestSegmentsV2GetSegmentEndpoint:
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_views.get_openpecha_segment_details_by_id",
+ new_callable=AsyncMock,
+ )
+ def test_get_segment_without_text_id(self, mock_service):
+ mock_service.return_value = V2SegmentResponse(
+ segment_id="seg-1",
+ content="Segment content",
+ )
+
+ response = client.get(f"{SEGMENTS_ROOT}/seg-1")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["segment_id"] == "seg-1"
+ assert data["content"] == "Segment content"
+ assert data["text"] is None
+
+ mock_service.assert_awaited_once_with(
+ segment_id="seg-1",
+ )
+
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_views.get_openpecha_segment_details_by_id",
+ new_callable=AsyncMock,
+ )
+ def test_get_segment_returns_text_when_service_includes_detail(self, mock_service):
+ mock_service.return_value = V2SegmentResponse(
+ segment_id="seg-1",
+ content="Segment content",
+ text=V2SegmentTextDetail(
+ text_id="text-1",
+ title="Root Text Title",
+ language="bo",
+ ),
+ )
+
+ response = client.get(f"{SEGMENTS_ROOT}/seg-1")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["text"]["text_id"] == "text-1"
+ assert data["text"]["title"] == "Root Text Title"
+ assert data["text"]["language"] == "bo"
+
+ mock_service.assert_awaited_once_with(
+ segment_id="seg-1",
+ )
+
class TestSegmentsV2TranslationsEndpoint:
@patch(
- "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha"
+ "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha",
+ new_callable=AsyncMock,
)
def test_get_translations_success(self, mock_service):
mock_service.return_value = V2SegmentTranslationsResponse(
@@ -47,7 +103,7 @@ def test_get_translations_success(self, mock_service):
has_more=True,
)
- response = client.get("/v2/segments/parent-seg-1/translations")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations")
assert response.status_code == 200
data = response.json()
@@ -66,7 +122,8 @@ def test_get_translations_success(self, mock_service):
)
@patch(
- "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha"
+ "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha",
+ new_callable=AsyncMock,
)
def test_get_translations_with_pagination(self, mock_service):
mock_service.return_value = V2SegmentTranslationsResponse(
@@ -80,7 +137,7 @@ def test_get_translations_with_pagination(self, mock_service):
has_more=False,
)
- response = client.get("/v2/segments/parent-seg-1/translations?skip=5&limit=3")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations?skip=5&limit=3")
assert response.status_code == 200
data = response.json()
@@ -97,7 +154,8 @@ def test_get_translations_with_pagination(self, mock_service):
class TestSegmentsV2CommentariesEndpoint:
@patch(
- "pecha_api.texts.segments.segments_openpecha_views.get_commentaries_by_segment_id_from_openpecha"
+ "pecha_api.texts.segments.segments_openpecha_views.get_commentaries_by_segment_id_from_openpecha",
+ new_callable=AsyncMock,
)
def test_get_commentaries_success(self, mock_service):
mock_service.return_value = V2SegmentCommentariesResponse(
@@ -123,7 +181,7 @@ def test_get_commentaries_success(self, mock_service):
has_more=False,
)
- response = client.get("/v2/segments/parent-seg-1/commentaries")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/commentaries")
assert response.status_code == 200
data = response.json()
@@ -135,29 +193,30 @@ def test_get_commentaries_success(self, mock_service):
class TestSegmentsV2ValidationErrors:
def test_invalid_skip_negative_for_translations(self):
- response = client.get("/v2/segments/parent-seg-1/translations?skip=-1")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations?skip=-1")
assert response.status_code == 422
def test_invalid_limit_zero_for_translations(self):
- response = client.get("/v2/segments/parent-seg-1/translations?limit=0")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations?limit=0")
assert response.status_code == 422
def test_invalid_limit_too_large_for_translations(self):
- response = client.get("/v2/segments/parent-seg-1/translations?limit=101")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations?limit=101")
assert response.status_code == 422
def test_invalid_skip_negative_for_commentaries(self):
- response = client.get("/v2/segments/parent-seg-1/commentaries?skip=-1")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/commentaries?skip=-1")
assert response.status_code == 422
def test_invalid_limit_zero_for_commentaries(self):
- response = client.get("/v2/segments/parent-seg-1/commentaries?limit=0")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/commentaries?limit=0")
assert response.status_code == 422
class TestSegmentsV2ErrorHandling:
@patch(
- "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha"
+ "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha",
+ new_callable=AsyncMock,
)
def test_get_translations_not_found(self, mock_service):
mock_service.side_effect = HTTPException(
@@ -165,13 +224,14 @@ def test_get_translations_not_found(self, mock_service):
detail="Segment with id 'missing-segment' not found",
)
- response = client.get("/v2/segments/missing-segment/translations")
+ response = client.get(f"{SEGMENTS_ROOT}/missing-segment/translations")
assert response.status_code == 404
assert "missing-segment" in response.json()["detail"]
@patch(
- "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha"
+ "pecha_api.texts.segments.segments_openpecha_views.get_translations_by_segment_id_from_openpecha",
+ new_callable=AsyncMock,
)
def test_get_translations_upstream_error(self, mock_service):
mock_service.side_effect = HTTPException(
@@ -179,13 +239,14 @@ def test_get_translations_upstream_error(self, mock_service):
detail="Failed to fetch related segments from upstream service",
)
- response = client.get("/v2/segments/parent-seg-1/translations")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/translations")
assert response.status_code == 502
assert "upstream" in response.json()["detail"].lower()
@patch(
- "pecha_api.texts.segments.segments_openpecha_views.get_commentaries_by_segment_id_from_openpecha"
+ "pecha_api.texts.segments.segments_openpecha_views.get_commentaries_by_segment_id_from_openpecha",
+ new_callable=AsyncMock,
)
def test_get_commentaries_upstream_error(self, mock_service):
mock_service.side_effect = HTTPException(
@@ -193,7 +254,7 @@ def test_get_commentaries_upstream_error(self, mock_service):
detail="Failed to fetch related segments from upstream service",
)
- response = client.get("/v2/segments/parent-seg-1/commentaries")
+ response = client.get(f"{SEGMENTS_ROOT}/parent-seg-1/commentaries")
assert response.status_code == 502
assert "upstream" in response.json()["detail"].lower()
diff --git a/tests/segments/test_segments_service.py b/tests/segments/test_segments_service.py
index 0b95f6bf8..a8b64faa5 100644
--- a/tests/segments/test_segments_service.py
+++ b/tests/segments/test_segments_service.py
@@ -1,495 +1,54 @@
-import uuid
-from unittest.mock import AsyncMock, patch, MagicMock
-from fastapi import HTTPException
-import uuid
import pytest
-from pecha_api.texts.segments.segments_service import (
- create_new_segment,
- get_translations_by_segment_id,
- get_segment_details_by_id,
- get_commentaries_by_segment_id,
- get_info_by_segment_id,
- get_root_text_mapping_by_segment_id,
- remove_segments_by_text_id,
- fetch_segments_by_text_id,
- get_segments_details_by_ids,
- update_segments_service
-)
-from pecha_api.texts.segments.segments_utils import SegmentUtils
-from pecha_api.texts.segments.segments_response_models import (
- CreateSegmentRequest,
- SegmentResponse,
- CreateSegment,
- ParentSegment,
- SegmentTranslationsResponse,
- SegmentTranslation,
- SegmentDTO,
- MappingResponse,
- SegmentCommentariesResponse,
- SegmentCommentry,
- SegmentInfoResponse,
- SegmentInfo,
- RelatedText,
- Resources,
- SegmentRootMappingResponse,
- SegmentRootMapping,
- MappedSegmentDTO,
- SegmentUpdateRequest,
- SegmentUpdate
-)
+from unittest.mock import AsyncMock, patch
+from fastapi import HTTPException
+from pecha_api.texts.segments.segments_service import get_segments_details_by_ids
+from pecha_api.texts.segments.segments_utils import SegmentUtils
+from pecha_api.texts.segments.segments_response_models import SegmentDTO
from pecha_api.texts.segments.segments_enum import SegmentType
-
-
-from pecha_api.texts.texts_response_models import TextDTO
-
from pecha_api.error_contants import ErrorConstants
-from pecha_api.cache.cache_enums import CacheType
-
-@pytest.mark.asyncio
-async def test_get_translations_by_segment_id_success():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- segment = SegmentDTO(
- id=segment_id,
- text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- content="To the buddhas: Vipaśyin, Śikhin, Viśvabhū, Krakucchanda, Kanakamuni, and Kāśyapa, And Śākyamuni—Gautama, deity of all deities, To the seven warrior-like buddhas, I pay homage!",
- mapping=[],
- type=SegmentType.SOURCE
- )
- translations = [
- SegmentTranslation(
- segment_id=f"efb26a06-f373-450b-ba57-e7a8d4dd5b64_{i}",
- text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- title = f"Title {i}",
- source = f"source {i}",
- language = "en",
- content="To the buddhas: Vipaśyin, Śikhin, Viśvabhū, Krakucchanda, Kanakamuni, and Kāśyapa, And Śākyamuni—Gautama, deity of all deities, To the seven warrior-like buddhas, I pay homage!",
- )
- for i in range(1, 4)
- ]
- with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.segments.segments_service.get_segment_by_id", new_callable=AsyncMock) as mock_segment, \
- patch("pecha_api.texts.segments.segments_service.SegmentUtils.filter_segment_mapping_by_type_or_text_id", new_callable=AsyncMock) as mock_filter, \
- patch("pecha_api.texts.segments.segments_service.get_related_mapped_segments", new_callable=AsyncMock) as mock_translations:
- mock_segment.return_value = segment
- mock_translations.return_value = translations
- mock_filter.return_value = translations
-
- response = await get_translations_by_segment_id(segment_id=segment_id)
-
- assert response == SegmentTranslationsResponse(
- parent_segment=ParentSegment(
- segment_id=segment_id,
- content="To the buddhas: Vipaśyin, Śikhin, Viśvabhū, Krakucchanda, Kanakamuni, and Kāśyapa, And Śākyamuni—Gautama, deity of all deities, To the seven warrior-like buddhas, I pay homage!"
- ),
- translations=translations
- )
-
-
-@pytest.mark.asyncio
-async def test_get_translations_by_segment_id_segment_not_found():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
-
- with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as excinfo:
- await get_translations_by_segment_id(segment_id=segment_id)
- assert excinfo.value.status_code == 404
- assert excinfo.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE
-@pytest.mark.asyncio
-async def test_create_new_segment():
- """
- Test case for the create_new_segment function from the segments_service file
- """
- create_segment_request = CreateSegmentRequest(
- text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- segments=[
- CreateSegment(
- content="content",
- mapping=[],
- type=SegmentType.SOURCE
- )
- ]
- )
-
- with patch('pecha_api.texts.segments.segments_service.validate_user_exists', return_value=True), \
- patch('pecha_api.texts.segments.segments_service.TextUtils.validate_text_exists', new_callable=AsyncMock, return_value=True), \
- patch('pecha_api.texts.segments.segments_service.create_segment', new_callable=AsyncMock) as mock_create_segment:
- mock_segment = type('Segment', (), {
- 'id': uuid.UUID("efb26a06-f373-450b-ba57-e7a8d4dd5b64"),
- 'pecha_segment_id': "pecha_efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- 'text_id': "efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- 'content': "content",
- 'mapping': [],
- 'type': SegmentType.SOURCE,
- 'model_dump': lambda self: {
- 'id': self.id,
- 'pecha_segment_id': self.pecha_segment_id,
- 'text_id': self.text_id,
- 'content': self.content,
- 'mapping': self.mapping,
- 'type': self.type
- }
- })()
- mock_create_segment.return_value = [mock_segment]
-
- response = await create_new_segment(
- create_segment_request=create_segment_request,
- token="admin"
- )
-
- expected_response = SegmentResponse(
- segments=[
- SegmentDTO(
- id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- pecha_segment_id="pecha_efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- content="content",
- mapping=[],
- type=SegmentType.SOURCE
- )
- ]
- )
- assert response == expected_response
-
-
-@pytest.mark.asyncio
-async def test_create_new_segment_invalid_user():
- """
- Test case for the create_new_segment function fails due to admin
- """
- create_segment_request = CreateSegmentRequest(
- text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- segments=[
- CreateSegment(
- content="content",
- mapping=[],
- type=SegmentType.SOURCE
- )
- ]
- )
-
- with patch('pecha_api.texts.segments.segments_service.validate_user_exists', return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await create_new_segment(
- create_segment_request=create_segment_request,
- token="no_admin"
- )
- assert exc_info.value.status_code == 401
- assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE
@pytest.mark.asyncio
async def test_validate_segment_exists_success():
segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- with patch('pecha_api.texts.segments.segments_utils.check_segment_exists', new_callable=AsyncMock) as mock_check:
+ with patch("pecha_api.texts.segments.segments_utils.check_segment_exists", new_callable=AsyncMock) as mock_check:
mock_check.return_value = True
result = await SegmentUtils.validate_segment_exists(segment_id)
assert result is True
+
@pytest.mark.asyncio
async def test_validate_segment_exists_not_found():
segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- with patch('pecha_api.texts.segments.segments_utils.check_segment_exists', new_callable=AsyncMock) as mock_check:
+ with patch("pecha_api.texts.segments.segments_utils.check_segment_exists", new_callable=AsyncMock) as mock_check:
mock_check.return_value = False
with pytest.raises(HTTPException) as exc_info:
await SegmentUtils.validate_segment_exists(segment_id)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE
+
@pytest.mark.asyncio
async def test_validate_segments_exists_success():
segment_ids = ["efb26a06-f373-450b-ba57-e7a8d4dd5b64", "efb26a06-f373-450b-ba57-e7a8d4dd5b65"]
- with patch('pecha_api.texts.segments.segments_utils.check_all_segment_exists', new_callable=AsyncMock) as mock_check:
+ with patch("pecha_api.texts.segments.segments_utils.check_all_segment_exists", new_callable=AsyncMock) as mock_check:
mock_check.return_value = True
result = await SegmentUtils.validate_segments_exists(segment_ids)
assert result is True
+
@pytest.mark.asyncio
async def test_validate_segments_exists_not_found():
segment_ids = ["efb26a06-f373-450b-ba57-e7a8d4dd5b64", "efb26a06-f373-450b-ba57-e7a8d4dd5b65"]
- with patch('pecha_api.texts.segments.segments_utils.check_all_segment_exists', new_callable=AsyncMock) as mock_check:
+ with patch("pecha_api.texts.segments.segments_utils.check_all_segment_exists", new_callable=AsyncMock) as mock_check:
mock_check.return_value = False
with pytest.raises(HTTPException) as exc_info:
await SegmentUtils.validate_segments_exists(segment_ids)
assert exc_info.value.status_code == 404
- # The error message includes the segment IDs in the format: "Segment not found {segment_ids}"
assert ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE in exc_info.value.detail
assert str(segment_ids) in exc_info.value.detail
-@pytest.mark.asyncio
-async def test_get_segment_details_by_id_without_text_details_success():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- mock_segment = type('Segment', (), {
- 'id': segment_id,
- 'pecha_segment_id': f"pecha_{segment_id}",
- 'text_id': "text123",
- 'content': "test content",
- 'mapping': [],
- 'type': SegmentType.SOURCE,
- 'model_dump': lambda self: {
- 'id': self.id,
- 'pecha_segment_id': self.pecha_segment_id,
- 'text_id': self.text_id,
- 'content': self.content,
- 'mapping': self.mapping,
- 'type': self.type
- }
- })()
-
- with patch('pecha_api.texts.segments.segments_service.get_segment_by_id', new_callable=AsyncMock) as mock_get:
- mock_get.return_value = mock_segment
- response = await get_segment_details_by_id(segment_id)
- assert isinstance(response, SegmentDTO)
- assert str(response.id) == segment_id
- assert response.text_id == "text123"
- assert response.content == "test content"
- assert response.mapping == []
- assert response.type == SegmentType.SOURCE
-
-@pytest.mark.asyncio
-async def test_get_segment_details_by_id_not_found():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- with patch('pecha_api.texts.segments.segments_service.get_segment_by_id', new_callable=AsyncMock) as mock_get:
- mock_get.return_value = None
- with pytest.raises(HTTPException) as exc_info:
- await get_segment_details_by_id(segment_id)
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_get_segment_details_by_id_with_text_details_success():
- segment_id = str(uuid.uuid4())
- text_id = str(uuid.uuid4())
- group_id = str(uuid.uuid4())
- mock_text_details = TextDTO(
- id=text_id,
- title="title",
- language="en",
- type="text",
- group_id=group_id,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="admin",
- categories=["category1", "category2"],
- views=0
- )
- mock_segment = SegmentDTO(
- id=segment_id,
- text_id=text_id,
- content="test content",
- mapping=[],
- type=SegmentType.SOURCE,
- text=mock_text_details
- )
- with patch("pecha_api.texts.segments.segments_service.get_segment_by_id", new_callable=AsyncMock, return_value=mock_segment), \
- patch("pecha_api.texts.segments.segments_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_details):
-
- response = await get_segment_details_by_id(segment_id=segment_id, text_details=True)
-
- assert response is not None
- assert response.text_id == text_id
- assert response.id == segment_id
- assert response.text is not None
-
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_by_segment_id_success():
- parent_segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- # repository segment object with id and content attributes
- repo_parent_segment = type('Segment', (), {
- 'id': parent_segment_id,
- 'content': "parent_segment_content"
- })()
- commentaries = [
- SegmentDTO(
- id=f"id_{i}",
- text_id=f"text_id_{i}",
- content=f"content_{i}",
- mapping=[
- MappingResponse(
- text_id="parent_text_id",
- segments=[
- parent_segment_id
- ]
- )
- ],
- type=SegmentType.SOURCE
- )
- for i in range(1,6)
- ]
- filtered_commentaries = [
- SegmentCommentry(
- text_id=f"text_id_{i}",
- title=f"title_{i}",
- segments=[
- MappedSegmentDTO(
- segment_id=f"id_{i}",
- content=f"content_{i}"
- )
- ],
- language="en",
- count=1
- )
- for i in range(1,6)
- ]
- with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.segments.segments_service.get_segment_by_id", new_callable=AsyncMock) as mock_parent_segment, \
- patch("pecha_api.texts.segments.segments_service.get_related_mapped_segments", new_callable=AsyncMock) as mock_get_related_mapped_segment, \
- patch("pecha_api.texts.segments.segments_service.SegmentUtils.filter_segment_mapping_by_type_or_text_id", new_callable=AsyncMock) as mock_filtered_segment_mapping:
- mock_parent_segment.return_value = repo_parent_segment
- mock_get_related_mapped_segment.return_value = commentaries
- mock_filtered_segment_mapping.return_value = filtered_commentaries
- response = await get_commentaries_by_segment_id(segment_id=parent_segment_id)
- assert isinstance(response, SegmentCommentariesResponse)
- assert response.parent_segment.segment_id == parent_segment_id
- assert response.parent_segment.content == "parent_segment_content"
- assert response.commentaries[0].text_id == "text_id_1"
- assert len(response.commentaries[0].segments) == 1
- assert response.commentaries[0].segments[0].segment_id == "id_1"
- assert response.commentaries[0].segments[0].content == "content_1"
- assert response.commentaries[0].language == "en"
- assert response.commentaries[0].count == 1
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_by_segment_id_not_found():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_commentaries_by_segment_id(segment_id=segment_id)
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_get_infos_by_segment_id_success():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- text_id = "text_id_1"
- mock_segment = type('Segment', (), {
- 'id': segment_id,
- 'text_id': text_id,
- 'content': "test content",
- 'mapping': [],
- 'type': SegmentType.SOURCE
- })()
- mock_text_detail = TextDTO(
- id=text_id,
- title="title",
- language="en",
- type="text",
- group_id="group_id",
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="admin",
- categories=["category1"],
- views=0
- )
- related_mapped_segments = [
- SegmentDTO(
- id=f"id_{i}",
- text_id=f"text_id_{i}",
- content=f"content_{i}",
- mapping=[],
- type=SegmentType.SOURCE
- )
- for i in range(1,6)
- ]
-
- mock_segment_with_text = SegmentDTO(
- id=segment_id,
- text_id=text_id,
- content="segment_content",
- mapping=[],
- type=SegmentType.SOURCE,
- text=mock_text_detail
- )
-
- with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.segments.segments_service.get_segment_info_by_id_cache", new_callable=AsyncMock, return_value=None), \
- patch("pecha_api.texts.segments.segments_service.get_segment_by_id", new_callable=AsyncMock, return_value=mock_segment), \
- patch("pecha_api.texts.segments.segments_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.segments.segments_service.get_related_mapped_segments", new_callable=AsyncMock) as mock_get_related_mapped_segment, \
- patch("pecha_api.texts.segments.segments_service.SegmentUtils.get_count_of_each_commentary_and_version", new_callable=AsyncMock, return_value={"version": 1, "commentary": 2}), \
- patch("pecha_api.texts.segments.segments_service.SegmentUtils.get_root_mapping_count", new_callable=AsyncMock, return_value=3), \
- patch("pecha_api.texts.segments.segments_service.set_segment_info_by_id_cache", new_callable=AsyncMock):
- mock_get_related_mapped_segment.return_value = related_mapped_segments
-
- response = await get_info_by_segment_id(segment_id=segment_id)
- assert isinstance(response, SegmentInfoResponse)
- assert isinstance(response.segment_info, SegmentInfo)
- assert isinstance(response.segment_info.related_text, RelatedText)
- assert isinstance(response.segment_info.resources, Resources)
- assert response.segment_info.segment_id == segment_id
- assert response.segment_info.translations == 1
- assert response.segment_info.related_text.commentaries == 2
- assert response.segment_info.related_text.root_text == 3
-
-
-@pytest.mark.asyncio
-async def test_get_infos_by_segment_id_invalid_segment_id():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_info_by_segment_id(segment_id=segment_id)
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE
-
-
-@pytest.mark.asyncio
-async def test_get_root_text_mapping_by_segment_id_invalid_segment_id():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- with patch("pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_root_text_mapping_by_segment_id(segment_id=segment_id)
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE
-
-
-@pytest.mark.asyncio
-async def test_remove_segments_by_text_id_success():
- text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- with patch("pecha_api.texts.segments.segments_service.delete_segments_by_text_id", new_callable=AsyncMock, return_value=True),\
- patch("pecha_api.texts.segments.segments_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True):
-
- response = await remove_segments_by_text_id(text_id=text_id)
-
- assert response is not None
-
-@pytest.mark.asyncio
-async def test_remove_segments_by_text_id_invalid_text_id():
- text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- with patch("pecha_api.texts.segments.segments_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await remove_segments_by_text_id(text_id=text_id)
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_fetch_segments_by_text_id_success():
- text_id = "text_id"
- mock_segments = [
- SegmentDTO(
- id=f"id_{i}",
- text_id=f"{text_id}_{i}",
- content=f"content_{i}",
- mapping=[],
- type=SegmentType.SOURCE
- )
- for i in range(1,6)
- ]
- with patch("pecha_api.texts.segments.segments_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments):
- response = await fetch_segments_by_text_id(text_id=text_id)
-
- assert response is not None
- assert len(response) == 5
- assert response[0].id == "id_1"
- assert response[0].text_id == f"{text_id}_1"
- assert response[0].type == SegmentType.SOURCE
-
@pytest.mark.asyncio
async def test_get_segments_details_by_ids_cache_hit():
@@ -533,7 +92,7 @@ async def test_get_segments_details_by_ids_cache_miss_sets_cache():
"pecha_api.texts.segments.segments_service.get_segments_details_by_ids_cache",
new_callable=AsyncMock,
return_value=None,
- ) as mock_cache, patch(
+ ), patch(
"pecha_api.texts.segments.segments_service.get_segments_by_ids",
new_callable=AsyncMock,
return_value=repo_result,
@@ -543,244 +102,6 @@ async def test_get_segments_details_by_ids_cache_miss_sets_cache():
) as mock_set:
result = await get_segments_details_by_ids(segment_ids)
assert result == repo_result
- mock_cache.assert_awaited_once()
mock_repo.assert_awaited_once_with(segment_ids=segment_ids)
- # ensure cache set called with expected segment_ids
assert mock_set.await_count == 1
- called_kwargs = mock_set.await_args.kwargs
- assert called_kwargs["segment_ids"] == segment_ids
-
-
-@pytest.mark.asyncio
-async def test_get_info_by_segment_id_cache_hit():
- segment_id = "seg_1"
- cached_response = SegmentInfoResponse(
- segment_info=SegmentInfo(
- segment_id=segment_id,
- text_id="text_id_1",
- translations=0,
- related_text=RelatedText(commentaries=0, root_text=0),
- resources=Resources(sheets=0),
- )
- )
-
- mock_text_detail = TextDTO(
- id="text_id_1",
- title="title",
- language="en",
- type="commentary",
- group_id="group_id_1",
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- )
-
- mock_segment = type('Segment', (), {
- 'id': segment_id,
- 'text_id': "text_id_1",
- 'content': "content",
- 'mapping': [],
- 'type': SegmentType.SOURCE
- })()
-
- with patch(
- "pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists",
- new_callable=AsyncMock,
- return_value=True,
- ), patch(
- "pecha_api.texts.segments.segments_service.get_segment_info_by_id_cache",
- new_callable=AsyncMock,
- return_value=None, # Changed to None to test actual flow
- ), patch(
- "pecha_api.texts.segments.segments_service.get_segment_by_id",
- new_callable=AsyncMock,
- return_value=mock_segment,
- ), patch(
- "pecha_api.texts.segments.segments_service.TextUtils.get_text_details_by_id",
- new_callable=AsyncMock,
- return_value=mock_text_detail,
- ), patch(
- "pecha_api.texts.segments.segments_service.get_related_mapped_segments",
- new_callable=AsyncMock,
- return_value=[],
- ), patch(
- "pecha_api.texts.segments.segments_service.SegmentUtils.get_count_of_each_commentary_and_version",
- new_callable=AsyncMock,
- ) as mock_count, patch(
- "pecha_api.texts.segments.segments_service.SegmentUtils.get_root_mapping_count",
- new_callable=AsyncMock,
- ) as mock_root_count, patch(
- "pecha_api.texts.segments.segments_service.set_segment_info_by_id_cache",
- new_callable=AsyncMock,
- ):
- mock_count.return_value = {"version": 0, "commentary": 0}
- mock_root_count.return_value = 0
- result = await get_info_by_segment_id(segment_id)
- assert isinstance(result, SegmentInfoResponse)
- assert result.segment_info.segment_id == segment_id
-
-
-@pytest.mark.asyncio
-async def test_get_info_by_segment_id_sets_cache_on_miss():
- segment_id = "seg_1"
- text_id = "text_id_1"
- mock_segment = type('Segment', (), {
- 'id': segment_id,
- 'text_id': text_id,
- 'content': "test content",
- 'mapping': [],
- 'type': SegmentType.SOURCE
- })()
- mock_text_detail = TextDTO(
- id=text_id,
- title="title",
- language="en",
- type="text",
- group_id="group_id",
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="admin",
- categories=["category1"],
- views=0
- )
-
- with patch(
- "pecha_api.texts.segments.segments_service.SegmentUtils.validate_segment_exists",
- new_callable=AsyncMock,
- return_value=True,
- ), patch(
- "pecha_api.texts.segments.segments_service.get_segment_info_by_id_cache",
- new_callable=AsyncMock,
- return_value=None,
- ), patch(
- "pecha_api.texts.segments.segments_service.get_segment_by_id",
- new_callable=AsyncMock,
- return_value=mock_segment,
- ), patch(
- "pecha_api.texts.segments.segments_service.TextUtils.get_text_details_by_id",
- new_callable=AsyncMock,
- return_value=mock_text_detail,
- ), patch(
- "pecha_api.texts.segments.segments_service.get_related_mapped_segments",
- new_callable=AsyncMock,
- return_value=[],
- ), patch(
- "pecha_api.texts.segments.segments_service.SegmentUtils.get_count_of_each_commentary_and_version",
- new_callable=AsyncMock,
- ) as mock_count, patch(
- "pecha_api.texts.segments.segments_service.SegmentUtils.get_root_mapping_count",
- new_callable=AsyncMock,
- ) as mock_root_count, patch(
- "pecha_api.texts.segments.segments_service.set_segment_info_by_id_cache",
- new_callable=AsyncMock,
- ) as mock_set:
- mock_count.return_value = {"version": 0, "commentary": 0}
- mock_root_count.return_value = 0
- result = await get_info_by_segment_id(segment_id)
- assert isinstance(result, SegmentInfoResponse)
- # ensure cache set was called with the built response
- assert mock_set.await_count == 1
- called_kwargs = mock_set.await_args.kwargs
- assert called_kwargs["segment_id"] == segment_id
- assert called_kwargs["cache_type"] == CacheType.SEGMENT_INFO
-
-
-@pytest.mark.asyncio
-async def test_update_segments_service_success():
- """
- Test case for successful segment update with admin access
- """
- segment_update_request = SegmentUpdateRequest(
- pecha_text_id="pecha_text_123",
- segments=[
- SegmentUpdate(
- pecha_segment_id="pecha_segment_123",
- content="Updated content"
- )
- ]
- )
-
- mock_text = type('Text', (), {
- 'id': "text_123",
- 'pecha_text_id': "pecha_text_123",
- 'title': "Test Text"
- })()
-
- mock_updated_segment = type('Segment', (), {
- 'id': "segment_id_123",
- 'pecha_segment_id': "pecha_segment_123",
- 'text_id': "text_123",
- 'content': "Updated content",
- 'mapping': [],
- 'type': SegmentType.SOURCE
- })()
-
- with patch('pecha_api.texts.segments.segments_service.verify_admin_access', return_value=True), \
- patch('pecha_api.texts.segments.segments_service.get_text_by_pecha_text_id', new_callable=AsyncMock, return_value=mock_text), \
- patch('pecha_api.texts.segments.segments_service.update_segment_by_id', new_callable=AsyncMock) as mock_update:
- mock_update.return_value = mock_updated_segment
-
- result = await update_segments_service(
- token="admin_token",
- segment_update_request=segment_update_request
- )
-
- assert result is not None
- mock_update.assert_awaited_once_with(segment_update_request=segment_update_request)
-
-
-@pytest.mark.asyncio
-async def test_update_segments_service_forbidden():
- """
- Test case for segment update with non-admin access
- """
- segment_update_request = SegmentUpdateRequest(
- pecha_text_id="pecha_text_123",
- segments=[
- SegmentUpdate(
- pecha_segment_id="pecha_segment_123",
- content="Updated content"
- )
- ]
- )
-
- with patch('pecha_api.texts.segments.segments_service.verify_admin_access', return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await update_segments_service(
- token="user_token",
- segment_update_request=segment_update_request
- )
- assert exc_info.value.status_code == 403
- assert exc_info.value.detail == ErrorConstants.ADMIN_ERROR_MESSAGE
-
-
-@pytest.mark.asyncio
-async def test_update_segments_service_text_not_found():
- """
- Test case for segment update with invalid text
- """
- segment_update_request = SegmentUpdateRequest(
- pecha_text_id="invalid_pecha_text_id",
- segments=[
- SegmentUpdate(
- pecha_segment_id="pecha_segment_123",
- content="Updated content"
- )
- ]
- )
-
- with patch('pecha_api.texts.segments.segments_service.verify_admin_access', return_value=True), \
- patch('pecha_api.texts.segments.segments_service.get_text_by_pecha_text_id', new_callable=AsyncMock, return_value=None):
- with pytest.raises(HTTPException) as exc_info:
- await update_segments_service(
- token="admin_token",
- segment_update_request=segment_update_request
- )
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
+ assert mock_set.await_args.kwargs["segment_ids"] == segment_ids
diff --git a/tests/segments/test_segments_utils.py b/tests/segments/test_segments_utils.py
index a436458a8..64d5ea7d1 100644
--- a/tests/segments/test_segments_utils.py
+++ b/tests/segments/test_segments_utils.py
@@ -26,96 +26,6 @@
)
from pecha_api.texts.segments.segments_enum import SegmentType
-from pecha_api.texts.groups.groups_response_models import GroupDTO
-
-@pytest.mark.asyncio
-async def test_get_count_of_each_commentary_and_version_success():
- parent_text = TextDTO(
- id="parent-text-id",
- title="Parent Text",
- language="bo",
- group_id="group_id",
- type="version",
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- )
- text_details = {
- "efb26a06-f373-450b-ba57-e7a8d4dd5b64": TextDTO(
- id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- title="title",
- language="language",
- group_id="group_id",
- type="commentary",
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- ),
- "efb26a06-f373-450b-ba57-e7a8d4dd5b65": TextDTO(
- id="efb26a06-f373-450b-ba57-e7a8d4dd5b65",
- title="title",
- language="language",
- group_id="group_id",
- type="version",
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- ),
- "efb26a06-f373-450b-ba57-e7a8d4dd5b66": TextDTO(
- id="efb26a06-f373-450b-ba57-e7a8d4dd5b66",
- title="title",
- language="language",
- group_id="group_id",
- type="commentary",
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- )
- }
- list_of_segment_paramenter = [
- SegmentDTO(
- id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- content="content",
- mapping=[],
- type=SegmentType.SOURCE
- ),
- SegmentDTO(
- id="efb26a06-f373-450b-ba57-e7a8d4dd5b65",
- text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b65",
- content="content",
- mapping=[],
- type=SegmentType.SOURCE
- ),
- SegmentDTO(
- id="efb26a06-f373-450b-ba57-e7a8d4dd5b66",
- text_id="efb26a06-f373-450b-ba57-e7a8d4dd5b66",
- content="content",
- mapping=[],
- type=SegmentType.SOURCE
- )
- ]
- with patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_ids", new_callable=AsyncMock, return_value=text_details):
- result = await SegmentUtils.get_count_of_each_commentary_and_version(list_of_segment_paramenter, parent_text=parent_text)
- assert result["commentary"] == 2
- assert result["version"] == 1
-
@pytest.mark.asyncio
async def test_filter_segment_mapping_by_type_success():
"""Test filtering segments by commentary type returns correct SegmentCommentry objects."""
@@ -211,155 +121,6 @@ async def test_filter_segment_mapping_by_type_success():
assert response[1].segments[0].segment_id == "efb26a06-f373-450b-ba57-e7a8d4dd5b66"
assert response[1].segments[0].content == "commentary two content"
assert response[1].count == 1
-
-@pytest.mark.asyncio
-async def test_get_root_mapping_count_success():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- group_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b65"
-
- # Create mock text details for each mapped text (non-commentary types to be counted)
- mock_text_details_map = {}
- for i in range(1, 6):
- mock_text_details_map[f"text_id_{i}"] = TextDTO(
- id=f"text_id_{i}",
- title=f"title_{i}",
- language="language",
- type="version", # Changed to version so they won't be skipped
- group_id=group_id,
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- )
-
- segment = SegmentDTO(
- id=segment_id,
- text_id=text_id,
- content="content",
- mapping=[
- MappingResponse(
- text_id=f"text_id_{i}",
- segments=[
- f"segment_id_{i}"
- ]
- )
- for i in range(1,6)
- ],
- type=SegmentType.SOURCE
- )
-
- text_details = TextDTO(
- id=text_id,
- title="title",
- language="language",
- type="commentary",
- group_id=group_id,
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- )
-
- # Create mapped text details with different type/group to pass the filter
- mapped_text_details = {
- f"text_id_{i}": TextDTO(
- id=f"text_id_{i}",
- title=f"title_{i}",
- language="language",
- type="version", # Different type from parent
- group_id="different_group_id", # Different group from parent
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- )
- for i in range(1, 6)
- }
-
- mock_group_details = GroupDTO(
- id=group_id,
- type="COMMENTARY"
- )
-
- # Mock to return different text details based on text_id
- async def mock_get_text_details_by_id(text_id):
- if text_id in mock_text_details_map:
- return mock_text_details_map[text_id]
- return text_details
-
- with patch("pecha_api.texts.segments.segments_utils.get_segment_by_id", new_callable=AsyncMock, return_value=segment), \
- patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_id", new_callable=AsyncMock, side_effect=mock_get_text_details_by_id), \
- patch("pecha_api.texts.segments.segments_utils.get_group_details", new_callable=AsyncMock, return_value=mock_group_details):
- response = await SegmentUtils.get_root_mapping_count(segment_id=segment_id)
- assert response == 5
-
-@pytest.mark.asyncio
-async def test_get_segment_root_mapping_details_success():
- parent_text = TextDTO(
- id="parent-text-id",
- title="Parent Title",
- language="bo",
- group_id="group_id",
- type="version",
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- )
- segments = [
- SegmentDTO(
- id="efb26a06-f373-450b-ba57-e7a8d4dd5b64",
- text_id="text_id_1",
- content="content",
- mapping=[
- MappingResponse(
- text_id="text_id_1",
- segments=[
- "segment_id_1"
- ]
- )
- ],
- type=SegmentType.SOURCE
- )
- ]
- text_details = {
- "text_id_1": TextDTO(
- id="text_id_1",
- title="title",
- language="language",
- group_id="different_group_id",
- type="commentary", # Different type
- is_published=True,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=["categories"],
- views=0
- )
- }
- with patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_ids", new_callable=AsyncMock, return_value=text_details):
- response = await SegmentUtils.get_segment_root_mapping_details(segments=segments, parent_segment_text=parent_text)
- assert isinstance(response[0], SegmentRootMapping)
- assert response[0].text_id == "text_id_1"
- assert response[0].title == "title"
- assert response[0].language == "language"
- assert len(response[0].segments) == 1
- assert response[0].segments[0].segment_id == "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- assert response[0].segments[0].content == "content"
@pytest.mark.asyncio
async def test_mapped_segment_content_for_table_of_content_without_version_id_success():
@@ -436,77 +197,6 @@ async def test_validate_segments_exists_invalid_uuid_in_list():
assert exc_info.value.status_code == 400
-@pytest.mark.asyncio
-async def test_get_root_mapping_count_group_type_text_returns_zero():
- segment_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- text_id = "text-id-1"
-
- parent_text = TextDTO(
- id="parent_text_id",
- title="parent title",
- language="en",
- type="root_text",
- group_id="parent_group_id",
- is_published=True,
- created_date="",
- updated_date="",
- published_date="",
- published_by="",
- categories=[],
- views=0,
- )
-
- segment = SegmentDTO(
- id=segment_id,
- text_id=text_id,
- content="content",
- mapping=[
- MappingResponse(text_id="t1", segments=["s1", "s2"]),
- ],
- type=SegmentType.SOURCE,
- )
- text_details = TextDTO(
- id=text_id,
- title="title",
- language="en",
- type="commentary",
- group_id="g1",
- is_published=True,
- created_date="",
- updated_date="",
- published_date="",
- published_by="",
- categories=[],
- views=0,
- )
-
- mapped_text_details = {
- "t1": TextDTO(
- id="t1",
- title="title",
- language="en",
- type="version",
- group_id="different_group",
- is_published=True,
- created_date="",
- updated_date="",
- published_date="",
- published_by="",
- categories=[],
- views=0,
- )
- }
-
- group_detail = GroupDTO(id="g1", type="text")
-
- with patch("pecha_api.texts.segments.segments_utils.get_segment_by_id", new_callable=AsyncMock, return_value=segment), \
- patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=text_details), \
- patch("pecha_api.texts.segments.segments_utils.TextUtils.get_text_details_by_ids", new_callable=AsyncMock, return_value=mapped_text_details), \
- patch("pecha_api.texts.segments.segments_utils.get_group_details", new_callable=AsyncMock, return_value=group_detail):
- count = await SegmentUtils.get_root_mapping_count(segment_id=segment_id)
- assert count == 0
-
-
@pytest.mark.asyncio
async def test_filter_segment_mapping_by_type_commentary_merges_same_text_id():
"""
@@ -857,168 +547,6 @@ async def test_validate_segments_exists_success():
"efb26a06-f373-450b-ba57-e7a8d4dd5b65",
]) is True
-
-def test_apply_bophono_with_tibetan_text():
- """Test apply_bophono with Tibetan text input."""
- from unittest.mock import Mock
-
- # Mock the tokenizer and its tokens
- mock_token1 = type('Token', (), {'text': 'བཀྲ་ཤིས་'})()
- mock_token2 = type('Token', (), {'text': 'བདེ་ལེགས་'})()
- mock_tokens = [mock_token1, mock_token2]
-
- # Mock the converter
- mock_converter = Mock()
- mock_converter.get_api.side_effect = ['tra.shi', 'de.legs']
-
- with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \
- patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class:
-
- # Setup mocks
- mock_tokenizer = Mock()
- mock_tokenizer.tokenize.return_value = mock_tokens
- mock_tokenizer_class.return_value = mock_tokenizer
- mock_converter_class.return_value = mock_converter
-
- # Test the method
- result = SegmentUtils.apply_bophono("བཀྲ་ཤིས་བདེ་ལེགས་")
-
- # Verify the result
- assert result == "tra.shi de.legs"
-
- # Verify method calls
- mock_tokenizer.tokenize.assert_called_once_with("བཀྲ་ཤིས་བདེ་ལེགས་")
- mock_converter_class.assert_called_once_with(schema="KVP", options={'aspirateLowTones': True})
- assert mock_converter.get_api.call_count == 2
- mock_converter.get_api.assert_any_call('བཀྲ་ཤིས་')
- mock_converter.get_api.assert_any_call('བདེ་ལེགས་')
-
-
-def test_apply_bophono_with_empty_string():
- """Test apply_bophono with empty string input."""
- from unittest.mock import Mock
-
- mock_tokenizer = Mock()
- mock_tokenizer.tokenize.return_value = []
- mock_converter = Mock()
-
- with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \
- patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class:
-
- mock_tokenizer_class.return_value = mock_tokenizer
- mock_converter_class.return_value = mock_converter
-
- result = SegmentUtils.apply_bophono("")
-
- assert result == ""
- mock_tokenizer.tokenize.assert_called_once_with("")
- mock_converter_class.assert_called_once_with(schema="KVP", options={'aspirateLowTones': True})
- mock_converter.get_api.assert_not_called()
-
-
-def test_apply_bophono_with_single_word():
- """Test apply_bophono with single Tibetan word."""
- from unittest.mock import Mock
-
- mock_token = type('Token', (), {'text': 'ཨོཾ'})()
- mock_tokens = [mock_token]
-
- mock_tokenizer = Mock()
- mock_tokenizer.tokenize.return_value = mock_tokens
- mock_converter = Mock()
- mock_converter.get_api.return_value = 'om'
-
- with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \
- patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class:
-
- mock_tokenizer_class.return_value = mock_tokenizer
- mock_converter_class.return_value = mock_converter
-
- result = SegmentUtils.apply_bophono("ཨོཾ")
-
- assert result == "om"
- mock_tokenizer.tokenize.assert_called_once_with("ཨོཾ")
- mock_converter.get_api.assert_called_once_with('ཨོཾ')
-
-
-def test_apply_bophono_with_mixed_content():
- """Test apply_bophono with mixed Tibetan and punctuation."""
- from unittest.mock import Mock
-
- mock_token1 = type('Token', (), {'text': 'མཆོད་'})()
- mock_token2 = type('Token', (), {'text': '།'})()
- mock_token3 = type('Token', (), {'text': 'རྟེན་'})()
- mock_tokens = [mock_token1, mock_token2, mock_token3]
-
- mock_tokenizer = Mock()
- mock_tokenizer.tokenize.return_value = mock_tokens
- mock_converter = Mock()
- mock_converter.get_api.side_effect = ['chö', '།', 'ten']
-
- with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \
- patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class:
-
- mock_tokenizer_class.return_value = mock_tokenizer
- mock_converter_class.return_value = mock_converter
-
- result = SegmentUtils.apply_bophono("མཆོད་། རྟེན་")
-
- assert result == "chö ། ten"
- mock_tokenizer.tokenize.assert_called_once_with("མཆོད་། རྟེན་")
- assert mock_converter.get_api.call_count == 3
- mock_converter.get_api.assert_any_call('མཆོད་')
- mock_converter.get_api.assert_any_call('།')
- mock_converter.get_api.assert_any_call('རྟེན་')
-
-
-def test_apply_bophono_converter_options():
- """Test that apply_bophono uses correct converter options."""
- from unittest.mock import Mock
-
- mock_token = type('Token', (), {'text': 'དཀར་'})()
- mock_tokens = [mock_token]
-
- mock_tokenizer = Mock()
- mock_tokenizer.tokenize.return_value = mock_tokens
- mock_converter = Mock()
- mock_converter.get_api.return_value = 'kar'
-
- with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \
- patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class:
-
- mock_tokenizer_class.return_value = mock_tokenizer
- mock_converter_class.return_value = mock_converter
-
- SegmentUtils.apply_bophono("དཀར་")
-
- # Verify that the converter was initialized with correct options
- mock_converter_class.assert_called_once_with(
- schema="KVP",
- options={'aspirateLowTones': True}
- )
-
-
-def test_apply_bophono_with_whitespace_only():
- """Test apply_bophono with whitespace-only input."""
- from unittest.mock import Mock
-
- mock_tokenizer = Mock()
- mock_tokenizer.tokenize.return_value = []
- mock_converter = Mock()
-
- with patch("pecha_api.texts.segments.segments_utils.WordTokenizer") as mock_tokenizer_class, \
- patch("pecha_api.texts.segments.segments_utils.bophono.UnicodeToApi") as mock_converter_class:
-
- mock_tokenizer_class.return_value = mock_tokenizer
- mock_converter_class.return_value = mock_converter
-
- result = SegmentUtils.apply_bophono(" ")
-
- assert result == ""
- mock_tokenizer.tokenize.assert_called_once_with(" ")
- mock_converter.get_api.assert_not_called()
-
-
@pytest.mark.asyncio
async def test_group_segment_content_by_text_id_with_table_of_contents():
"""Test _group_segment_content_by_text_id groups and sorts segments correctly using TOC."""
diff --git a/tests/segments/test_segments_views.py b/tests/segments/test_segments_views.py
deleted file mode 100644
index 35327fe25..000000000
--- a/tests/segments/test_segments_views.py
+++ /dev/null
@@ -1,341 +0,0 @@
-from unittest.mock import patch
-from fastapi.testclient import TestClient
-from fastapi import status, HTTPException
-from uuid import uuid4
-from pecha_api.app import api
-
-from pecha_api.texts.segments.segments_response_models import (
- CreateSegmentRequest,
- CreateSegment,
- SegmentTranslationsResponse,
- SegmentTranslation,
- SegmentDTO,
- SegmentUpdateRequest,
- SegmentUpdate
-)
-from pecha_api.texts.texts_response_models import TextDTO
-
-
-from pecha_api.error_contants import ErrorConstants
-from pecha_api.texts.segments.segments_response_models import ParentSegment
-from pecha_api.texts.segments.segments_enum import SegmentType
-
-client = TestClient(api)
-
-@patch("pecha_api.texts.segments.segments_views.get_segment_details_by_id")
-def test_get_segment_without_text_details_success(mock_get_segment_details_by_id):
- segment_id = str(uuid4())
- mock_response = SegmentDTO(
- id=segment_id,
- text_id="text_id",
- content="content",
- mapping=[],
- type=SegmentType.SOURCE
- )
- mock_get_segment_details_by_id.return_value = mock_response
- response = client.get(f"/api/v1/segments/{segment_id}")
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["id"] == segment_id
- assert data["text_id"] == "text_id"
- assert data["content"] == "content"
- assert data["text"] is None
-
-@patch("pecha_api.texts.segments.segments_views.get_segment_details_by_id")
-def test_get_segment_with_text_details_success(mock_get_segment_details_by_id):
- segment_id = str(uuid4())
- text_id = str(uuid4())
- mock_response = SegmentDTO(
- id=segment_id,
- text_id="text_id",
- content="content",
- mapping=[],
- type=SegmentType.SOURCE,
- text=TextDTO(
- id=text_id,
- title="title",
- language="language",
- type="type",
- group_id="group_id",
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="admin",
- categories=["category1", "category2"],
- parent_id=None
- )
- )
- mock_get_segment_details_by_id.return_value = mock_response
- response = client.get(f"/api/v1/segments/{segment_id}?text_details=True")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["id"] == segment_id
- assert data["text"] is not None
- assert data["text"]["id"] == text_id
- assert data["text"]["title"] == "title"
-
-
-@patch("pecha_api.texts.segments.segments_views.get_translations_by_segment_id")
-def test_get_translations_success(mock_get_translations):
- # Mock data
- segment_id = str(uuid4())
- mock_response = SegmentTranslationsResponse(
- parent_segment=ParentSegment(
- segment_id=segment_id,
- content="Test segment content"
- ),
- translations=[
- SegmentTranslation(
- segment_id="segment_id_1",
- text_id="text1",
- title='title',
- source='source',
- language='en',
- content="Translation 1"
- ),
- SegmentTranslation(
- segment_id="segment_id_2",
- text_id="text2",
- title='title2',
- source='source2',
- language='bo',
- content="Translation 2"
- )
- ]
- )
-
- # Mock the service function
- mock_get_translations.return_value = mock_response
- # Make request
- response = client.get(f"/api/v1/segments/{segment_id}/translations")
-
- # Assert response
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["parent_segment"]["segment_id"] == segment_id
- assert data["parent_segment"]["content"] == "Test segment content"
- assert len(data["translations"]) == 2
-
- # Verify first translation
- assert data["translations"][0]["text_id"] == "text1"
- assert data["translations"][0]["title"] == "title"
- assert data["translations"][0]["source"] == "source"
- assert data["translations"][0]["language"] == "en"
- assert data["translations"][0]["content"] == "Translation 1"
-
- # Verify second translation
- assert data["translations"][1]["text_id"] == "text2"
- assert data["translations"][1]["language"] == "bo"
-
-@patch("pecha_api.texts.segments.segments_views.get_translations_by_segment_id")
-def test_get_translations_with_pagination(mock_get_translations):
- segment_id = str(uuid4())
- mock_response = SegmentTranslationsResponse(
- parent_segment=ParentSegment(
- segment_id=segment_id,
- content="Test segment content"
- ),
- translations=[
- SegmentTranslation(
- segment_id=f"segment_id_{i}",
- text_id=f"text{i}",
- title=f"title{i}",
- source=f"source{i}",
- language='en',
- content=f"Translation {i}"
- ) for i in range(5)
- ]
- )
-
- mock_get_translations.return_value = mock_response
- response = client.get(f"/api/v1/segments/{segment_id}/translations?skip=2&limit=3")
-
- assert response.status_code == status.HTTP_200_OK
- mock_get_translations.assert_called_with(segment_id=segment_id)
-
-@patch("pecha_api.texts.segments.segments_views.get_translations_by_segment_id")
-def test_get_translations_not_found(mock_get_translations):
- segment_id = str(uuid4())
- mock_get_translations.side_effect = HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE
- )
-
- response = client.get(f"/api/v1/segments/{segment_id}/translations")
- assert response.status_code == status.HTTP_404_NOT_FOUND
- assert response.json()["detail"] == ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE
-
-@patch("pecha_api.texts.segments.segments_views.create_new_segment")
-def test_create_segment_success(mock_create_segment):
- # Mock data
- segment_id = str(uuid4())
- segment_request = CreateSegmentRequest(
- text_id="text123",
- segments=[
- CreateSegment(
- content="New segment content",
- mapping=[],
- type=SegmentType.SOURCE
- )
- ]
- )
- mock_response = {
- "segments": [
- {
- "id": segment_id,
- "text_id": segment_request.text_id,
- "content": segment_request.segments[0].content,
- "mapping": segment_request.segments[0].mapping,
- "type": segment_request.segments[0].type
- }
- ]
-
- }
- mock_create_segment.return_value = mock_response
-
- # Make request with auth token
- response = client.post(
- "/api/v1/segments",
- json=segment_request.model_dump(mode="json"),
- headers={"Authorization": "Bearer test_token"}
- )
-
- # Assert response
- assert response.status_code == status.HTTP_201_CREATED
- data = response.json()
-
- # Verify segment
- assert data['segments'][0]["text_id"] == segment_request.text_id
- assert data['segments'][0]["content"] == segment_request.segments[0].content
- assert data['segments'][0]["id"] == segment_id
-
-def test_create_segment_unauthorized():
- segment_request = CreateSegmentRequest(
- text_id="text123",
- segments=[
- CreateSegment(
- content="New segment content",
- mapping=[],
- type=SegmentType.SOURCE
- )
- ]
- )
-
- # Make request without auth token
- response = client.post(
- "/api/v1/segments",
- json=segment_request.model_dump(mode="json")
- )
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
-@patch("pecha_api.texts.segments.segments_views.update_segments_service")
-def test_update_segment_success(mock_update_segments_service):
- # Mock data
- segment_id = str(uuid4())
- segment_update_request = SegmentUpdateRequest(
- pecha_text_id="pecha_text_123",
- segments=[
- SegmentUpdate(
- pecha_segment_id="pecha_segment_123",
- content="Updated segment content"
- )
- ]
- )
-
- mock_response = SegmentDTO(
- id=segment_id,
- pecha_segment_id="pecha_segment_123",
- text_id="text123",
- content="Updated segment content",
- mapping=[],
- type=SegmentType.SOURCE
- )
-
- mock_update_segments_service.return_value = mock_response
-
- # Make request with auth token
- response = client.put(
- "/api/v1/segments",
- json=segment_update_request.model_dump(mode="json"),
- headers={"Authorization": "Bearer admin_token"}
- )
-
- # Assert response
- assert response.status_code == status.HTTP_200_OK
-
-def test_update_segment_unauthorized():
- segment_update_request = SegmentUpdateRequest(
- pecha_text_id="pecha_text_123",
- segments=[
- SegmentUpdate(
- pecha_segment_id="pecha_segment_123",
- content="Updated segment content"
- )
- ]
- )
-
- # Make request without auth token
- response = client.put(
- "/api/v1/segments",
- json=segment_update_request.model_dump(mode="json")
- )
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
-@patch("pecha_api.texts.segments.segments_views.update_segments_service")
-def test_update_segment_forbidden(mock_update_segments_service):
- segment_update_request = SegmentUpdateRequest(
- pecha_text_id="pecha_text_123",
- segments=[
- SegmentUpdate(
- pecha_segment_id="pecha_segment_123",
- content="Updated segment content"
- )
- ]
- )
-
- # Mock the service to raise a 403 Forbidden exception
- mock_update_segments_service.side_effect = HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=ErrorConstants.ADMIN_ERROR_MESSAGE
- )
-
- # Make request with non-admin token
- response = client.put(
- "/api/v1/segments",
- json=segment_update_request.model_dump(mode="json"),
- headers={"Authorization": "Bearer user_token"}
- )
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
- assert response.json()["detail"] == ErrorConstants.ADMIN_ERROR_MESSAGE
-
-@patch("pecha_api.texts.segments.segments_views.update_segments_service")
-def test_update_segment_text_not_found(mock_update_segments_service):
- segment_update_request = SegmentUpdateRequest(
- pecha_text_id="invalid_pecha_text_id",
- segments=[
- SegmentUpdate(
- pecha_segment_id="pecha_segment_123",
- content="Updated segment content"
- )
- ]
- )
-
- # Mock the service to raise a 404 Not Found exception
- mock_update_segments_service.side_effect = HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE
- )
-
- response = client.put(
- "/api/v1/segments",
- json=segment_update_request.model_dump(mode="json"),
- headers={"Authorization": "Bearer admin_token"}
- )
-
- assert response.status_code == status.HTTP_404_NOT_FOUND
- assert response.json()["detail"] == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
\ No newline at end of file
diff --git a/tests/share/test_share_service.py b/tests/share/test_share_service.py
index 23850fa20..22539f093 100644
--- a/tests/share/test_share_service.py
+++ b/tests/share/test_share_service.py
@@ -17,9 +17,11 @@
ShareRequest
)
from pecha_api.share.share_enums import TextColor, BgColor
-from pecha_api.texts.segments.segments_service import SegmentDTO
+from pecha_api.texts.segments.segments_response_models import (
+ V2SegmentResponse,
+ V2SegmentTextDetail,
+)
from pecha_api.texts.texts_response_models import TextDTO
-from pecha_api.texts.segments.segments_enum import SegmentType
@pytest.mark.asyncio
@@ -90,38 +92,25 @@ async def test_generate_short_url_with_logo():
async def test_generate_short_url_for_segment_content_success():
share_request = ShareRequest(
url="https://pecha.io/share/123",
- segment_id="id_1",
+ segment_id="em5HPUEMRke2e0Qs2519J",
+ text_id="text_1",
language="en",
)
mock_short_url_response = ShortUrlResponse(
shortUrl="https://pecha.io/share/123"
)
- mock_segment_details = SegmentDTO(
- id="id_1",
- text_id="text_1",
+ mock_segment_details = V2SegmentResponse(
+ segment_id="em5HPUEMRke2e0Qs2519J",
content="content_1",
- mapping=[],
- type=SegmentType.SOURCE
- )
- mock_text_detail = TextDTO(
- id="text_1",
- title="title_1",
- language="en",
- type="version",
- group_id="group_1",
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="user_1",
- categories=[],
- views=0
+ text=V2SegmentTextDetail(
+ text_id="text_1",
+ title="title_1",
+ language="en",
+ ),
)
with patch("pecha_api.share.share_service.get_short_url", new_callable=AsyncMock, return_value=mock_short_url_response), \
- patch("pecha_api.share.share_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.share.share_service.get_segment_details_by_id", new_callable=AsyncMock, return_value=mock_segment_details), \
- patch("pecha_api.share.share_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
+ patch("pecha_api.share.share_service.get_openpecha_segment_details_by_id", new_callable=AsyncMock, return_value=mock_segment_details), \
patch("pecha_api.share.share_service.generate_segment_image") as mock_generate_image:
response = await generate_short_url(share_request=share_request)
@@ -192,40 +181,33 @@ def test_generate_logo_image():
async def test_generate_segment_content_image_with_segment():
share_request = ShareRequest(
text_id="text_1",
- segment_id="seg_1",
+ segment_id="em5HPUEMRke2e0Qs2519J",
language="en",
text_color=TextColor.BLACK,
bg_color=BgColor.DEFAULT
)
- mock_text_detail = TextDTO(
- id="text_1",
- title="Test Title",
- language="en",
- type="version",
- group_id="group_1",
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="user_1",
- categories=[],
- views=0
- )
- mock_segment = SegmentDTO(
- id="seg_1",
- text_id="text_1",
+ mock_segment = V2SegmentResponse(
+ segment_id="em5HPUEMRke2e0Qs2519J",
content="Test segment content",
- mapping=[],
- type=SegmentType.SOURCE
+ text=V2SegmentTextDetail(
+ text_id="text_1",
+ title="Test Title",
+ language="en",
+ ),
)
- with patch("pecha_api.share.share_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.share.share_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock), \
- patch("pecha_api.share.share_service.get_segment_details_by_id", new_callable=AsyncMock, return_value=mock_segment), \
- patch("pecha_api.share.share_service.generate_segment_image") as mock_generate_image:
-
+ with patch(
+ "pecha_api.share.share_service.get_openpecha_segment_details_by_id",
+ new_callable=AsyncMock,
+ return_value=mock_segment,
+ ) as mock_get_segment, patch(
+ "pecha_api.share.share_service.generate_segment_image"
+ ) as mock_generate_image:
await _generate_segment_content_image_(share_request)
+ mock_get_segment.assert_awaited_once_with(
+ segment_id="em5HPUEMRke2e0Qs2519J",
+ )
mock_generate_image.assert_called_once_with(
text="Test segment content",
ref_str="Test Title",
diff --git a/tests/sheets/test_sheets_service.py b/tests/sheets/test_sheets_service.py
deleted file mode 100644
index 50b92b3f8..000000000
--- a/tests/sheets/test_sheets_service.py
+++ /dev/null
@@ -1,2176 +0,0 @@
-import io
-import pytest
-import uuid
-from unittest.mock import patch, MagicMock, AsyncMock
-from fastapi import UploadFile, HTTPException, status
-from pecha_api.error_contants import ErrorConstants
-from pecha_api.image_utils import ImageUtils
-
-from pecha_api.sheets.sheets_response_models import (
- CreateSheetRequest,
- Source,
- SheetIdResponse,
- SheetDetailDTO,
- SheetSection,
- SheetDTO,
- SheetDTOResponse,
- SheetDTO,
- Publisher
-)
-from pecha_api.texts.segments.segments_enum import SegmentType
-from pecha_api.texts.segments.segments_response_models import (
- CreateSegmentRequest,
- SegmentResponse,
- SegmentDTO
-)
-from pecha_api.texts.texts_response_models import (
- TableOfContent,
- TableOfContentType,
- Section,
- TextSegment,
- TextDTO
-)
-from pecha_api.texts.texts_enums import TextType
-from pecha_api.users.user_response_models import UserInfoResponse
-from pecha_api.texts.groups.groups_response_models import GroupDTO
-from pecha_api.sheets.sheets_service import (
- create_new_sheet,
- update_sheet_by_id,
- get_sheet_by_id,
- delete_sheet_by_id,
- fetch_sheets,
- _generate_sheet_summary_,
- _strip_html_tags_,
- _generate_sheet_detail_dto_,
- upload_sheet_image_request,
- _fetch_user_sheets_,
- _generate_sheet_dto_response_,
- _create_publisher_object_,
- _generate_sheet_section_,
- _get_all_segment_ids_in_table_of_content_,
- _update_text_details_,
- _generate_and_upload_sheet_table_of_content,
- _process_and_upload_sheet_segments,
- _generate_sheet_table_of_content_,
- _generate_segment_dictionary_,
- _generate_segment_creation_request_payload_,
- _create_sheet_text_,
- _create_sheet_group_,
- clean_text
-)
-
-from pecha_api.texts.groups.groups_enums import GroupType
-from pecha_api.users.users_models import Users
-from pecha_api.sheets.sheets_enum import SortBy, SortOrder
-import hashlib
-
-from pecha_api.texts.segments.segments_response_models import (
- SegmentDTO,
- SegmentResponse
-)
-from pecha_api.users.user_response_models import UserInfoResponse
-from pecha_api.texts.texts_enums import TextType
-
-
-def test_validate_and_compress_image_success():
- file_content = io.BytesIO(b"fake_image_data")
- file = UploadFile(filename="test.jpg", file=file_content)
-
- with patch("pecha_api.image_utils.get_int", side_effect=[5, 75]), \
- patch("PIL.Image.open") as mock_open:
- mock_image = MagicMock()
- mock_image.mode = 'RGB' # Set the mode to RGB
- mock_open.return_value = mock_image
- mock_image.save = MagicMock()
-
- image_utils = ImageUtils()
- compressed_image = image_utils.validate_and_compress_image(file=file, content_type="image/jpeg")
- assert isinstance(compressed_image, io.BytesIO)
- mock_image.save.assert_called_once_with(compressed_image, format="JPEG", quality=75)
-
-
-def test_validate_and_compress_image_invalid_file_type():
- file_content = io.BytesIO(b"fake_image_data")
- file = UploadFile(filename="test.txt", file=file_content)
- try:
- image_utils = ImageUtils()
- image_utils.validate_and_compress_image(file=file, content_type="text/plain")
- except HTTPException as e:
- assert e.status_code == status.HTTP_400_BAD_REQUEST
- assert e.detail == 'Only image files are allowed'
-
-
-def test_validate_and_compress_image_file_too_large():
- file_content = io.BytesIO(b"fake_image_data" * 1024 * 1024 * 6) # 6 MB
- file = UploadFile(filename="test.jpg", file=file_content)
-
- with patch("pecha_api.config.get_int", return_value=5), \
- pytest.raises(HTTPException) as exc_info:
- image_utils = ImageUtils()
- image_utils.validate_and_compress_image(file=file, content_type="image/jpeg")
- assert exc_info.value.status_code == 413
- assert exc_info.value.detail == "File size exceeds 1MB limit"
-
-
-def test_validate_and_compress_image_processing_failure():
- file_content = io.BytesIO(b"fake_image_data")
- file = UploadFile(filename="test.jpg", file=file_content)
-
- with patch("pecha_api.config.get_int", side_effect=[5, 75]), \
- pytest.raises(HTTPException) as exc_info:
- image_utils = ImageUtils()
- image_utils.validate_and_compress_image(file=file, content_type="image/jpeg")
- assert exc_info.value.status_code == 400
- assert exc_info.value.detail == "Failed to process the image"
-
-
-@pytest.mark.asyncio
-async def test_create_new_sheet_success():
- mock_source = [
- Source(
- position=1,
- type=SegmentType.SOURCE,
- content="source_segment_id"
- ),
- Source(
- position=2,
- type=SegmentType.CONTENT,
- content="content"
- ),
- Source(
- position=3,
- type=SegmentType.IMAGE,
- content="image_url"
- )
- ]
- mock_create_sheet_request = CreateSheetRequest(
- title="sheet_title",
- source=mock_source,
- is_published=True
- )
- mock_token = "valid_token"
- mock_user_details = type("User",(), {
- "email": "test_user@gmail.com",
- })
- mock_group_response = type("GroupDTO", (), {
- "id": "group_id",
- "type": "sheet"
- })
- mock_text_response = type("TextDTO", (), {
- "id": "text_id",
- "title": "sheet_title",
- "group_id": "group_id",
- "type": "sheet",
- "is_published": True,
- "created_date": "2021-01-01",
- "updated_date": "2021-01-01",
- "published_date": "2021-01-01",
- "published_by": "test_user"
- })
- mock_segment_response = SegmentResponse(
- segments=[
- SegmentDTO(
- id="segment_id_1",
- text_id="text_id",
- content="source_segment_id",
- type=SegmentType.SOURCE
- ),
- SegmentDTO(
- id="segment_id_2",
- text_id="text_id",
- content="content",
- type=SegmentType.CONTENT
- ),
- SegmentDTO(
- id="segment_id_3",
- text_id="text_id",
- content="image_url",
- type=SegmentType.IMAGE
- )
- ]
- )
- mock_table_of_content_response = TableOfContent(
- id="table_of_content_id",
- text_id="text_id",
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_id",
- section_number=1,
- segments=[
- TextSegment(
- segment_id="segment_id_1",
- segment_number=1
- ),
- TextSegment(
- segment_id="segment_id_2",
- segment_number=2
- ),
- TextSegment(
- segment_id="segment_id_3",
- segment_number=3
- )
- ]
- )
- ]
- )
- with patch("pecha_api.sheets.sheets_service.create_new_group", new_callable=AsyncMock, return_value=mock_group_response), \
- patch("pecha_api.sheets.sheets_service.create_new_text", new_callable=AsyncMock, return_value=mock_text_response), \
- patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user_details), \
- patch("pecha_api.sheets.sheets_service.create_new_segment", new_callable=AsyncMock, return_value=mock_segment_response), \
- patch("pecha_api.sheets.sheets_service.create_table_of_content", new_callable=AsyncMock, return_value=mock_table_of_content_response):
-
- response = await create_new_sheet(
- create_sheet_request=mock_create_sheet_request,
- token=mock_token
- )
-
- assert response is not None
- assert isinstance(response, SheetIdResponse)
- assert response.sheet_id == "text_id"
-
-@pytest.mark.asyncio
-async def test_create_sheet_invalid_token():
- mock_create_sheet_request = CreateSheetRequest(
- title="sheet_title",
- source=[]
- )
- with pytest.raises(HTTPException) as exc_info:
- await create_new_sheet(
- create_sheet_request=mock_create_sheet_request,
- token="invalid_token"
- )
- assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
- assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE
-
-@pytest.mark.asyncio
-async def test_update_sheet_success():
- # Use proper UUID format for sheet_id
- sheet_id = str(uuid.uuid4())
-
- mock_source = [
- Source(
- position=1,
- type=SegmentType.SOURCE,
- content="source_segment_id"
- ),
- Source(
- position=2,
- type=SegmentType.CONTENT,
- content="content"
- ),
- Source(
- position=3,
- type=SegmentType.IMAGE,
- content="image_url"
- )
- ]
- mock_segment_response = SegmentResponse(
- segments=[
- SegmentDTO(
- id="segment_id_1",
- text_id=sheet_id,
- content="source_segment_id",
- type=SegmentType.SOURCE
- ),
- SegmentDTO(
- id="segment_id_2",
- text_id=sheet_id,
- content="content",
- type=SegmentType.CONTENT
- ),
- SegmentDTO(
- id="segment_id_3",
- text_id=sheet_id,
- content="image_url",
- type=SegmentType.IMAGE
- )
- ]
- )
- mock_update_sheet_request = CreateSheetRequest(
- title="updated_sheet_title",
- source=mock_source,
- is_published=True
- )
-
- # Add mock for TextUtils.get_text_details_by_id which is called at the end of update_sheet_by_id
- mock_text_details = TextDTO(
- id=sheet_id,
- title="updated_sheet_title",
- language="language",
- group_id="group_id",
- type=TextType.SHEET,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="test_user",
- categories=[],
- views=10
- )
-
- with patch("pecha_api.sheets.sheets_service.remove_segments_by_text_id", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \
- patch("pecha_api.sheets.sheets_service.remove_table_of_content_by_text_id", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.delete_text_details_by_id_cache", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.delete_table_of_content_by_sheet_id_cache", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.delete_segments_details_by_ids_cache", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.update_text_details", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.create_new_segment", new_callable=AsyncMock, return_value=mock_segment_response), \
- patch("pecha_api.sheets.sheets_service.create_table_of_content", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_details), \
- patch("pecha_api.sheets.sheets_service.update_text_details_cache", new_callable=AsyncMock):
-
- response = await update_sheet_by_id(
- sheet_id=sheet_id,
- update_sheet_request=mock_update_sheet_request,
- token="valid_token"
- )
-
- assert response is not None
- assert isinstance(response, SheetIdResponse)
- assert response.sheet_id == sheet_id
-
-@pytest.mark.asyncio
-async def test_update_sheet_invalid_token():
- # Use proper UUID format for sheet_id
- sheet_id = str(uuid.uuid4())
-
- mock_update_sheet_request = CreateSheetRequest(
- title="updated_sheet_title",
- source=[]
- )
- with pytest.raises(HTTPException) as exc_info:
- await update_sheet_by_id(
- sheet_id=sheet_id,
- update_sheet_request=mock_update_sheet_request,
- token="invalid_token"
- )
- assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
- assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE
-
-@pytest.mark.asyncio
-async def test_get_sheet_by_id_success():
- sheet_id = "text_id"
- mock_sheet_details = TextDTO(
- id=sheet_id,
- title="sheet_title",
- language="language",
- group_id="group_id",
- type=TextType.SHEET,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="test_user",
- categories=[],
- views=10
- )
- mock_user_details = UserInfoResponse(
- firstname="firstname",
- lastname="lastname",
- username="username",
- email="test_user@gmail.com",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
- mock_table_of_content_response = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_id",
- section_number=1,
- segments=[
- TextSegment(
- segment_id="segment_id_1",
- segment_number=1
- ),
- TextSegment(
- segment_id="segment_id_2",
- segment_number=2
- )
- ]
- )
- ]
- )
-
- segment_dict = {
- "segment_id_1": SegmentDTO(
- id="segment_id_1",
- text_id="text_id",
- content="content",
- type=SegmentType.CONTENT,
- ),
- "segment_id_2": SegmentDTO(
- id="segment_id_2",
- text_id="text_id",
- content="image_key",
- type=SegmentType.IMAGE,
- )
- }
- with patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_user_details), \
- patch("pecha_api.sheets.sheets_service.get_segments_details_by_ids", new_callable=AsyncMock, return_value=segment_dict), \
- patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content_response), \
- patch("pecha_api.sheets.sheets_service.generate_presigned_access_url", new_callable=MagicMock, return_value="image_url"), \
- patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_sheet_details):
-
- response = await get_sheet_by_id(sheet_id=sheet_id, skip=0, limit=10)
-
- assert response is not None
- assert isinstance(response, SheetDetailDTO)
- assert response.id == sheet_id
- assert response.sheet_title == "sheet_title"
- assert response.publisher is not None
- assert isinstance(response.content, SheetSection)
- assert response.content is not None
- assert response.content.segments is not None
- assert len(response.content.segments) == 2
- assert response.content.segments[0].type == SegmentType.CONTENT
- assert response.content.segments[0].content == "content"
- assert response.content.segments[0].key is None
- assert response.content.segments[1].type == SegmentType.IMAGE
- assert response.content.segments[1].content == "image_url"
- assert response.content.segments[1].key == "image_key"
-
-@pytest.mark.asyncio
-async def test_get_sheet_by_id_invalid_sheet_id():
- sheet_id="invalid_sheet_id"
- with patch("pecha_api.texts.texts_utils.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_sheet_by_id(sheet_id=sheet_id, skip=0, limit=10)
- assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_get_sheet_by_id_table_of_content_not_found():
- sheet_id = "text_id"
- mock_sheet_details = TextDTO(
- id=sheet_id,
- title="sheet_title",
- language="language",
- group_id="group_id",
- type=TextType.SHEET,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="test_user",
- categories=[],
- views=10
- )
- mock_user_details = UserInfoResponse(
- firstname="firstname",
- lastname="lastname",
- username="username",
- email="test_user@gmail.com",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
-
- segment_dict = {
- "segment_id_1": SegmentDTO(
- id="segment_id_1",
- text_id="text_id",
- content="content",
- type=SegmentType.CONTENT,
- ),
- "segment_id_2": SegmentDTO(
- id="segment_id_2",
- text_id="text_id",
- content="content",
- type=SegmentType.IMAGE,
- )
- }
- with patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_sheet_details), \
- patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_user_details), \
- patch("pecha_api.sheets.sheets_service.get_segments_details_by_ids", new_callable=AsyncMock, return_value=segment_dict), \
- patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=None):
-
- with pytest.raises(HTTPException) as exc_info:
- await get_sheet_by_id(sheet_id=sheet_id, skip=0, limit=10)
- assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
- assert exc_info.value.detail == ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_delete_sheet_success():
- mock_sheet_id = "text_id"
- mock_token = "valid_token"
- text_details = TextDTO(
- id="sheet_id",
- title="sheet_title",
- language="language",
- group_id="group_id",
- type=TextType.SHEET,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="test_user@gmail.com",
- categories=[],
- views=10
- )
- mock_user_details = UserInfoResponse(
- firstname="firstname",
- lastname="lastname",
- username="username",
- email="test_user@gmail.com",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
- with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \
- patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=text_details), \
- patch("pecha_api.sheets.sheets_service.get_user_info", new_callable=AsyncMock, return_value=mock_user_details), \
- patch("pecha_api.sheets.sheets_service.delete_group_by_group_id", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.remove_segments_by_text_id", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.remove_table_of_content_by_text_id", new_callable=AsyncMock), \
- patch("pecha_api.sheets.sheets_service.delete_text_by_text_id", new_callable=AsyncMock):
-
- response = await delete_sheet_by_id(
- sheet_id=mock_sheet_id,
- token=mock_token
- )
-
- assert response is None
-
-@pytest.mark.asyncio
-async def test_delete_sheet_invalid_token():
- with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await delete_sheet_by_id(
- sheet_id="text_id",
- token="invalid_token"
- )
- assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
- assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE
-
-@pytest.mark.asyncio
-async def test_delete_sheet_invalid_sheet_id():
- with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \
- patch("pecha_api.texts.texts_utils.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await delete_sheet_by_id(
- sheet_id="invalid_sheet_id",
- token="valid_token"
- )
- assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-
-
-
-
-
-
-# Test cases for fetch_sheets function
-@pytest.mark.asyncio
-async def test_fetch_sheets_community_page_all_published():
- mock_user = UserInfoResponse(
- firstname="firstname",
- lastname="lastname",
- username="username",
- email="test_user@gmail.com",
- educations=[],
- avatar_url="avatar_url",
- social_profiles=[],
- followers=0,
- following=0,
- )
- mock_sheets = _generate_mock_sheets_response_()
-
- with patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets), \
- patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="time passed"), \
- patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_user), \
- patch("pecha_api.texts.texts_models.Text.get_published_sheets_count_from_db", new_callable=AsyncMock, return_value=5):
-
- result = await fetch_sheets(
- token="valid_token",
- language="en",
- email=None,
- skip=0,
- limit=10
- )
-
- assert result is not None
- assert isinstance(result, SheetDTOResponse)
- assert result.sheets is not None
- assert len(result.sheets) == 5
- assert isinstance(result.sheets[0], SheetDTO)
- assert result.sheets[0].id == "sheet_id_1"
- assert result.sheets[-1].id == "sheet_id_5"
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_user_own_sheets():
- #Test fetch_sheets for user's own sheets - show both published and unpublished
- mock_user_details = type("User", (), {
- "email": "mock_user@gmail.com",
- })
- mock_publisher_details = UserInfoResponse(
- firstname="firstname",
- lastname="lastname",
- username="username",
- email="mock_user@gmail.com",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
- mock_sheets = _generate_mock_sheets_response_()
- for i in range(len(mock_sheets)):
- mock_sheets[i].published_by = "mock_user@gmail.com"
-
- with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user_details), \
- patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="time passed"), \
- patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_publisher_details), \
- patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets), \
- patch("pecha_api.texts.texts_models.Text.get_published_sheets_count_from_db", new_callable=AsyncMock, return_value=5):
-
- result = await fetch_sheets(
- token="valid_token",
- email="mock_user@gmail.com",
- skip=0,
- limit=10
- )
-
- assert result is not None
- assert isinstance(result, SheetDTOResponse)
- assert result.sheets is not None
- assert len(result.sheets) == 5
- assert isinstance(result.sheets[0], SheetDTO)
- assert result.sheets[0].id == "sheet_id_1"
- assert result.sheets[-1].id == "sheet_id_5"
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_user_viewing_other_users_sheets_status_logged_in():
- #Test fetch_sheets for user viewing other user's sheets - show only published
- mock_user_details = type("User", (), {
- "email": "mock_user@gmail.com",
- })
- mock_publisher_details = UserInfoResponse(
- firstname="firstname",
- lastname="lastname",
- username="username",
- email="other_user@gmail.com",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
- mock_sheets = _generate_mock_sheets_response_()
- for i in range(len(mock_sheets)):
- mock_sheets[i].published_by = "other_user@gmail.com"
-
- with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user_details), \
- patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="time passed"), \
- patch("pecha_api.sheets.sheets_service.fetch_user_by_email", new_callable=MagicMock, return_value=mock_publisher_details), \
- patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets), \
- patch("pecha_api.texts.texts_models.Text.get_published_sheets_count_from_db", new_callable=AsyncMock, return_value=5):
-
- result = await fetch_sheets(
- token="valid_token",
- language="en",
- email="test_user@gmail.com",
- skip=0,
- limit=10
- )
-
- assert result is not None
- assert isinstance(result, SheetDTOResponse)
- assert result.sheets is not None
- assert len(result.sheets) == 5
- assert isinstance(result.sheets[0], SheetDTO)
- assert result.sheets[0].id == "sheet_id_1"
- assert result.sheets[-1].id == "sheet_id_5"
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_invalid_token():
- #Test fetch_sheets with invalid token
- with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", side_effect=HTTPException(status_code=401, detail=ErrorConstants.TOKEN_ERROR_MESSAGE)), \
- patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="time passed"):
- with pytest.raises(HTTPException) as exc_info:
- await fetch_sheets(
- token="invalid_token",
- language="en",
- email="test@example.com",
- skip=0,
- limit=10
- )
-
- assert exc_info.value.status_code == 401
- assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE
-
-
-def _generate_mock_sheets_response_():
- return [
- TextDTO(
- id=f"sheet_id_{i}",
- title="Test Sheet",
- language="en",
- group_id="group_id",
- type=TextType.SHEET,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="test_user",
- categories=[],
- views=10
- )
- for i in range(1,6)
- ]
-
-# Test cases for _generate_sheet_summary_ function
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_success():
- #Test normal case with content segments#
- sheet_id = "test_sheet_id"
-
- # Mock table of content with sections and segments
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="content_segment_1", segment_number=1),
- TextSegment(segment_id="content_segment_2", segment_number=2),
- TextSegment(segment_id="image_segment_1", segment_number=3)
- ]
- )
- ]
- )
-
- # Mock the first content segment that will be returned
- mock_content_segment = SegmentDTO(
- id="content_segment_1",
- text_id=sheet_id,
- content="This is the first content segment.",
- type=SegmentType.CONTENT
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- expected = "This is the first content segment."
- assert result == expected
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_with_html_tags():
- #Test HTML tag stripping functionality
- sheet_id = "test_sheet_id"
-
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="content_segment_1", segment_number=1)
- ]
- )
- ]
- )
-
- mock_content_segment = SegmentDTO(
- id="content_segment_1",
- text_id=sheet_id,
- content="
This is bold text with italics and links .
",
- type=SegmentType.CONTENT
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- expected = "This is bold text with italics and links."
- assert result == expected
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_exceeds_max_words():
- #Test content that exceeds max_words limit
- sheet_id = "test_sheet_id"
-
- # Create content with more than 30 words (default max_words)
- long_content = " ".join([f"word{i}" for i in range(1, 41)]) # 40 words
-
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="content_segment_1", segment_number=1)
- ]
- )
- ]
- )
-
- mock_content_segment = SegmentDTO(
- id="content_segment_1",
- text_id=sheet_id,
- content=long_content,
- type=SegmentType.CONTENT
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- # Should contain exactly 30 words plus "..."
- words = result.split()
- assert len(words) == 30 # 30 words (the last word has "..." appended)
- assert result.endswith("...")
- assert "word1" in result
- assert "word30" in result
- assert "word31" not in result.replace("...", "")
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_custom_max_words():
- #Test with limited content (less than default 30 words)
- sheet_id = "test_sheet_id"
-
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="content_segment_1", segment_number=1)
- ]
- )
- ]
- )
-
- mock_content_segment = SegmentDTO(
- id="content_segment_1",
- text_id=sheet_id,
- content="This is a test content with only a few words.",
- type=SegmentType.CONTENT
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- expected = "This is a test content with only a few words."
- assert result == expected
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_no_table_of_content():
- #Test when table of content doesn't exist
- sheet_id = "test_sheet_id"
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=None):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- assert result == ""
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_empty_sections():
- #Test when table of content exists but has no sections
- sheet_id = "test_sheet_id"
-
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[]
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- assert result == ""
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_no_segments():
- #Test when no segments exist in the table of content
- sheet_id = "test_sheet_id"
-
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- section_number=1,
- segments=[]
- )
- ]
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- assert result == ""
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_no_content_segments():
- #Test when no content type segments exist (only images, sources, etc.)
- sheet_id = "test_sheet_id"
-
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="image_segment_1", segment_number=1),
- TextSegment(segment_id="source_segment_1", segment_number=2)
- ]
- )
- ]
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=None):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- assert result == ""
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_empty_content():
- #Test when content segments exist but have empty content
- sheet_id = "test_sheet_id"
-
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="content_segment_1", segment_number=1),
- TextSegment(segment_id="content_segment_2", segment_number=2)
- ]
- )
- ]
- )
-
- mock_content_segment = SegmentDTO(
- id="content_segment_1",
- text_id=sheet_id,
- content="", # Empty string instead of None
- type=SegmentType.CONTENT
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_content_segment):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- assert result == ""
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_error_handling():
- #Test error handling - should return empty string when exceptions occurd
- sheet_id = "test_sheet_id"
-
- # Simulate an exception in get_table_of_content_by_sheet_id
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, side_effect=Exception("Database error")):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- assert result == ""
-
-
-@pytest.mark.asyncio
-async def test_generate_sheet_summary_segment_not_found():
- #Test when segment IDs in table of content don't exist in segments dictionaryx1
- sheet_id = "test_sheet_id"
-
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="missing_segment", segment_number=1)
- ]
- )
- ]
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=None):
-
- result = await _generate_sheet_summary_(sheet_id)
-
- assert result == ""
-
-# Test cases for _strip_html_tags_ function
-def test_strip_html_tags_simple_tags():
- #Test stripping simple HTML tags#
- html_content = "Hello world !
"
- result = _strip_html_tags_(html_content)
- assert result == "Hello world!"
-
-def test_strip_html_tags_complex_html():
- #Test stripping complex HTML with attributes#
- html_content = ''
- result = _strip_html_tags_(html_content)
- assert result == "Test link content"
-
-def test_strip_html_tags_no_html():
- #Test with plain text (no HTML tags)#
- plain_text = "This is plain text without HTML"
- result = _strip_html_tags_(plain_text)
- assert result == "This is plain text without HTML"
-
-def test_strip_html_tags_empty_string():
- #Test with empty string#
- result = _strip_html_tags_("")
- assert result == ""
-
-def test_strip_html_tags_whitespace_handling():
- #Test HTML stripping with whitespace#
- html_content = " Content with spaces
"
- result = _strip_html_tags_(html_content)
- assert result == "Content with spaces"
-
-def test_strip_html_tags_nested_tags():
- #Test stripping nested HTML tags#
- html_content = ""
- result = _strip_html_tags_(html_content)
- assert result == "Nested content"
-
-
-# Test cases for upload_sheet_image_request function
-def test_upload_sheet_image_request_with_sheet_id():
- #Test uploading sheet image with sheet ID#
- file_content = io.BytesIO(b"fake_image_data")
- file = UploadFile(filename="test.jpg", file=file_content)
- # Create a mock file with content_type since the property is read-only
- mock_file = MagicMock(spec=UploadFile)
- mock_file.filename = "test.jpg"
- mock_file.file = file_content
- mock_file.content_type = "image/jpeg"
-
- sheet_id = "test_sheet_id"
-
- with patch("pecha_api.sheets.sheets_service.ImageUtils.validate_and_compress_image") as mock_validate, \
- patch("pecha_api.sheets.sheets_service.upload_bytes", return_value="test_upload_key") as mock_upload, \
- patch("pecha_api.sheets.sheets_service.generate_presigned_access_url", return_value="https://test-url.com") as mock_presigned, \
- patch("pecha_api.sheets.sheets_service.get", return_value="test-bucket"):
-
- mock_validate.return_value = io.BytesIO(b"compressed_image_data")
-
- result = upload_sheet_image_request(sheet_id=sheet_id, file=mock_file)
-
- assert result.url == "https://test-url.com"
- assert result.key == "test_upload_key"
- mock_validate.assert_called_once()
- mock_upload.assert_called_once()
- mock_presigned.assert_called_once()
-
-def test_upload_sheet_image_request_without_sheet_id():
- #Test uploading sheet image without sheet ID#
- file_content = io.BytesIO(b"fake_image_data")
- file = UploadFile(filename="test.jpg", file=file_content)
- # Create a mock file with content_type since the property is read-only
- mock_file = MagicMock(spec=UploadFile)
- mock_file.filename = "test.jpg"
- mock_file.file = file_content
- mock_file.content_type = "image/jpeg"
-
- with patch("pecha_api.sheets.sheets_service.ImageUtils.validate_and_compress_image") as mock_validate, \
- patch("pecha_api.sheets.sheets_service.upload_bytes", return_value="test_upload_key") as mock_upload, \
- patch("pecha_api.sheets.sheets_service.generate_presigned_access_url", return_value="https://test-url.com") as mock_presigned, \
- patch("pecha_api.sheets.sheets_service.get", return_value="test-bucket"):
-
- mock_validate.return_value = io.BytesIO(b"compressed_image_data")
-
- result = upload_sheet_image_request(sheet_id=None, file=mock_file)
-
- assert result.url == "https://test-url.com"
- assert result.key == "test_upload_key"
-
-
-# Test cases for _fetch_user_sheets_ function
-@pytest.mark.asyncio
-async def test_fetch_user_sheets_none_token():
- #Test _fetch_user_sheets_ with 'None' token#
- mock_sheets = _generate_mock_sheets_response_()
-
- with patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets):
- result = await _fetch_user_sheets_(
- token="None",
- email="test@example.com",
- sort_by=SortBy.CREATED_DATE,
- sort_order=SortOrder.DESC,
- skip=0,
- limit=10
- )
-
- assert result == mock_sheets
-
-@pytest.mark.asyncio
-async def test_fetch_user_sheets_own_sheets():
- #Test _fetch_user_sheets_ for user's own sheets#
- mock_user = Users(email="test@example.com")
- mock_sheets = _generate_mock_sheets_response_()
-
- with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user), \
- patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets):
-
- result = await _fetch_user_sheets_(
- token="valid_token",
- email="test@example.com",
- sort_by=SortBy.CREATED_DATE,
- sort_order=SortOrder.DESC,
- skip=0,
- limit=10
- )
-
- assert result == mock_sheets
-
-@pytest.mark.asyncio
-async def test_fetch_user_sheets_other_user():
- #Test _fetch_user_sheets_ for other user's sheets#
- mock_user = Users(email="current@example.com")
- mock_sheets = _generate_mock_sheets_response_()
-
- with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user), \
- patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets):
-
- result = await _fetch_user_sheets_(
- token="valid_token",
- email="other@example.com",
- sort_by=SortBy.CREATED_DATE,
- sort_order=SortOrder.DESC,
- skip=0,
- limit=10
- )
-
- assert result == mock_sheets
-
-
-# Test cases for _generate_sheet_dto_response_ function
-@pytest.mark.asyncio
-async def test_generate_sheet_dto_response():
- #Test _generate_sheet_dto_response_#
- mock_sheets = _generate_mock_sheets_response_()
- mock_user = UserInfoResponse(
- firstname="Test",
- lastname="User",
- username="testuser",
- email="test@example.com",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
-
- with patch("pecha_api.sheets.sheets_service._generate_sheet_summary_", new_callable=AsyncMock, return_value="Test summary"), \
- patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="2 days ago"), \
- patch("pecha_api.sheets.sheets_service.fetch_user_by_email", return_value=mock_user):
-
- result = await _generate_sheet_dto_response_(sheets=mock_sheets, total=5, skip=0, limit=10)
-
- assert isinstance(result, SheetDTOResponse)
- assert len(result.sheets) == 5
- assert result.skip == 0
- assert result.limit == 10
- assert result.total == 5
- assert all(sheet.summary == "Test summary" for sheet in result.sheets)
-
-
-# Test cases for _create_publisher_object_ function
-def test_create_publisher_object():
- #Test _create_publisher_object_#
- mock_user = UserInfoResponse(
- firstname="John",
- lastname="Doe",
- username="johndoe",
- email="john@example.com",
- avatar_url="https://avatar.url",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
-
- with patch("pecha_api.sheets.sheets_service.fetch_user_by_email", return_value=mock_user):
- result = _create_publisher_object_(published_by="john@example.com")
-
- assert isinstance(result, Publisher)
- assert result.name == "John Doe"
- assert result.username == "johndoe"
- assert result.email == "john@example.com"
- assert result.avatar_url == "https://avatar.url"
-
-def test_create_publisher_object_no_name():
- #Test _create_publisher_object_ when user has no first/last name#
- mock_user = UserInfoResponse(
- firstname="",
- lastname="",
- username="johndoe",
- email="john@example.com",
- avatar_url="https://avatar.url",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
-
- with patch("pecha_api.sheets.sheets_service.fetch_user_by_email", return_value=mock_user):
- result = _create_publisher_object_(published_by="john@example.com")
-
- assert result.name == "johndoe" # Falls back to username
-
-
-# Test cases for _generate_sheet_section_ function
-@pytest.mark.asyncio
-async def test_generate_sheet_section():
- #Test _generate_sheet_section_#
- segments = [
- TextSegment(segment_id="content_segment", segment_number=1),
- TextSegment(segment_id="image_segment", segment_number=2),
- TextSegment(segment_id="source_segment", segment_number=3)
- ]
-
- segments_dict = {
- "content_segment": SegmentDTO(
- id="content_segment",
- text_id="test_text_id",
- content="Test content",
- type=SegmentType.CONTENT
- ),
- "image_segment": SegmentDTO(
- id="image_segment",
- text_id="test_text_id",
- content="image_key",
- type=SegmentType.IMAGE
- ),
- "source_segment": SegmentDTO(
- id="source_segment",
- text_id="source_text_id",
- content="source_content",
- type=SegmentType.SOURCE
- )
- }
-
- mock_source_text = TextDTO(
- id="source_text_id",
- title="Source Text",
- language="en",
- group_id="group_id",
- type=TextType.VERSION,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="test@example.com",
- categories=[],
- views=0
- )
-
- with patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=mock_source_text), \
- patch("pecha_api.sheets.sheets_service.generate_presigned_access_url", return_value="https://presigned-image-url.com"), \
- patch("pecha_api.sheets.sheets_service.get", return_value="test-bucket"):
-
- result = await _generate_sheet_section_(segments=segments, segments_dict=segments_dict)
-
- assert isinstance(result, SheetSection)
- assert result.section_number == 1
- assert len(result.segments) == 3
-
- # Check content segment
- content_seg = next(seg for seg in result.segments if seg.segment_id == "content_segment")
- assert content_seg.content == "Test content"
- assert content_seg.type == SegmentType.CONTENT
-
- # Check image segment
- image_seg = next(seg for seg in result.segments if seg.segment_id == "image_segment")
- assert image_seg.content == "https://presigned-image-url.com"
- assert image_seg.type == SegmentType.IMAGE
-
- # Check source segment
- source_seg = next(seg for seg in result.segments if seg.segment_id == "source_segment")
- assert source_seg.content == "source_content"
- assert source_seg.type == SegmentType.SOURCE
- assert source_seg.language == "en"
- assert source_seg.text_title == "Source Text"
-
-
-
-def test_get_all_segment_ids_empty_sections():
- #Test _get_all_segment_ids_in_table_of_content_ with empty sections#
- result = _get_all_segment_ids_in_table_of_content_(sheet_sections=[])
- assert result == []
-
-
-# Test cases for _update_text_details_ function
-@pytest.mark.asyncio
-async def test_update_text_details():
- #Test _update_text_details_#
- sheet_id = "test_sheet_id"
- update_request = CreateSheetRequest(
- title="Updated Title",
- source=[],
- is_published=True
- )
-
- with patch("pecha_api.sheets.sheets_service.update_text_details", new_callable=AsyncMock) as mock_update:
- await _update_text_details_(sheet_id=sheet_id, update_sheet_request=update_request)
-
- mock_update.assert_called_once()
- call_args = mock_update.call_args
- assert call_args[1]['text_id'] == sheet_id
- assert call_args[1]['update_text_request'].title == "Updated Title"
- assert call_args[1]['update_text_request'].is_published == True
-
-
-# Test cases for _generate_and_upload_sheet_table_of_content function
-@pytest.mark.asyncio
-async def test_generate_and_upload_sheet_table_of_content():
- #Test _generate_and_upload_sheet_table_of_content#
- create_request = CreateSheetRequest(
- title="Test Sheet",
- source=[
- Source(position=1, type=SegmentType.CONTENT, content="test content")
- ],
- is_published=True
- )
- text_id = "test_text_id"
- segment_dict = {hashlib.sha256("test content".encode()).hexdigest(): "segment_id_123"}
- token = "valid_token"
-
- mock_table_of_content = TableOfContent(
- text_id=text_id,
- type=TableOfContentType.SHEET,
- sections=[]
- )
-
- with patch("pecha_api.sheets.sheets_service.create_table_of_content", new_callable=AsyncMock, return_value=mock_table_of_content) as mock_create:
- result = await _generate_and_upload_sheet_table_of_content(
- create_sheet_request=create_request,
- text_id=text_id,
- segment_dict=segment_dict,
- token=token
- )
-
- mock_create.assert_called_once()
- # The function returns the generated table of content, not the mock
- assert isinstance(result, TableOfContent)
- assert result.text_id == text_id
- assert len(result.sections) == 1
- assert len(result.sections[0].segments) == 1
-
-
-# Test cases for _process_and_upload_sheet_segments function
-@pytest.mark.asyncio
-async def test_process_and_upload_sheet_segments():
- #Test _process_and_upload_sheet_segments#
- create_request = CreateSheetRequest(
- title="Test Sheet",
- source=[
- Source(position=1, type=SegmentType.CONTENT, content="test content"),
- Source(position=2, type=SegmentType.IMAGE, content="image_url")
- ],
- is_published=True
- )
- text_id = "test_text_id"
- token = "valid_token"
-
- mock_segments = SegmentResponse(
- segments=[
- SegmentDTO(
- id="seg_1",
- text_id=text_id,
- content="test content",
- type=SegmentType.CONTENT
- ),
- SegmentDTO(
- id="seg_2",
- text_id=text_id,
- content="image_url",
- type=SegmentType.IMAGE
- )
- ]
- )
-
- with patch("pecha_api.sheets.sheets_service.create_new_segment", new_callable=AsyncMock, return_value=mock_segments):
- result = await _process_and_upload_sheet_segments(
- create_sheet_request=create_request,
- text_id=text_id,
- token=token
- )
-
- assert isinstance(result, dict)
- # Should contain hash keys for non-source segments
- assert len(result) >= 0 # Could be empty if all segments are SOURCE type
-
-
-# Test cases for _generate_sheet_table_of_content_ function
-def test_generate_sheet_table_of_content():
- #Test _generate_sheet_table_of_content_#
- create_request = CreateSheetRequest(
- title="Test Sheet",
- source=[
- Source(position=1, type=SegmentType.SOURCE, content="source_segment_id"),
- Source(position=2, type=SegmentType.CONTENT, content="test content"),
- Source(position=3, type=SegmentType.IMAGE, content="image_url")
- ],
- is_published=True
- )
- text_id = "test_text_id"
- segment_dict = {
- hashlib.sha256("test content".encode()).hexdigest(): "content_segment_id",
- hashlib.sha256("image_url".encode()).hexdigest(): "image_segment_id"
- }
-
- result = _generate_sheet_table_of_content_(
- create_sheet_request=create_request,
- text_id=text_id,
- segment_dict=segment_dict
- )
-
- assert isinstance(result, TableOfContent)
- assert result.text_id == text_id
- assert len(result.sections) == 1
- assert result.sections[0].section_number == 1
- assert len(result.sections[0].segments) == 3
-
-
-# Test cases for _generate_segment_dictionary_ function
-def test_generate_segment_dictionary():
- #Test _generate_segment_dictionary_#
- segments_response = SegmentResponse(
- segments=[
- SegmentDTO(
- id="seg_1",
- text_id="text_id",
- content="content 1",
- type=SegmentType.CONTENT
- ),
- SegmentDTO(
- id="seg_2",
- text_id="text_id",
- content="source_id",
- type=SegmentType.SOURCE
- ),
- SegmentDTO(
- id="seg_3",
- text_id="text_id",
- content="image_url",
- type=SegmentType.IMAGE
- )
- ]
- )
-
- result = _generate_segment_dictionary_(new_segments=segments_response)
-
- # Should contain entries for non-SOURCE segments only
- expected_content_hash = hashlib.sha256("content 1".encode()).hexdigest()
- expected_image_hash = hashlib.sha256("image_url".encode()).hexdigest()
-
- assert expected_content_hash in result
- assert expected_image_hash in result
- assert result[expected_content_hash] == "seg_1"
- assert result[expected_image_hash] == "seg_3"
-
- # SOURCE segments should not be in dictionary
- source_hash = hashlib.sha256("source_id".encode()).hexdigest()
- assert source_hash not in result
-
-
-# Test cases for _generate_segment_creation_request_payload_ function
-def test_generate_segment_creation_request_payload():
- #Test _generate_segment_creation_request_payload_#
- create_request = CreateSheetRequest(
- title="Test Sheet",
- source=[
- Source(position=1, type=SegmentType.SOURCE, content="source_segment_id"),
- Source(position=2, type=SegmentType.CONTENT, content="test content"),
- Source(position=3, type=SegmentType.IMAGE, content="image_url")
- ],
- is_published=True
- )
- text_id = "test_text_id"
-
- result = _generate_segment_creation_request_payload_(
- create_sheet_request=create_request,
- text_id=text_id
- )
-
- assert isinstance(result, CreateSegmentRequest)
- assert result.text_id == text_id
- # Should exclude SOURCE type segments
- assert len(result.segments) == 2
- assert all(seg.type != SegmentType.SOURCE for seg in result.segments)
-
-
-# Test cases for _create_sheet_text_ function
-@pytest.mark.asyncio
-async def test_create_sheet_text():
- #Test _create_sheet_text_#
- title = "Test Sheet"
- token = "valid_token"
- group_id = "test_group_id"
-
- mock_user = Users(email="test@example.com")
- mock_text = TextDTO(
- id="new_text_id",
- title=title,
- group_id=group_id,
- type=TextType.SHEET,
- language=None,
- is_published=False,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="test@example.com",
- categories=[],
- views=0
- )
-
- with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user), \
- patch("pecha_api.sheets.sheets_service.create_new_text", new_callable=AsyncMock, return_value=mock_text):
-
- result = await _create_sheet_text_(title=title, token=token, group_id=group_id)
-
- assert result == "new_text_id"
-
-
-@pytest.mark.asyncio
-async def test_create_sheet_text_title_required_empty():
- #Test _create_sheet_text_ should fail when title is empty
- mock_user = type("User", (), {"email": "test@example.com"})
- with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user):
- with pytest.raises(HTTPException) as exc_info:
- await _create_sheet_text_(title="", token="valid_token", group_id="group_id")
- assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
- assert exc_info.value.detail == ErrorConstants.SHEET_TITLE_REQUIRED_MESSAGE
-
-
-@pytest.mark.asyncio
-async def test_create_sheet_text_title_required_none():
- #Test _create_sheet_text_ should fail when title is None
- mock_user = type("User", (), {"email": "test@example.com"})
- with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", return_value=mock_user):
- with pytest.raises(HTTPException) as exc_info:
- await _create_sheet_text_(title=None, token="valid_token", group_id="group_id") # type: ignore[arg-type]
- assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
- assert exc_info.value.detail == ErrorConstants.SHEET_TITLE_REQUIRED_MESSAGE
-
-
-# Test cases for _create_sheet_group_ function
-@pytest.mark.asyncio
-async def test_create_sheet_group():
- #Test _create_sheet_group_#
- token = "valid_token"
-
- mock_group = GroupDTO(
- id="new_group_id",
- type=GroupType.SHEET
- )
-
- with patch("pecha_api.sheets.sheets_service.create_new_group", new_callable=AsyncMock, return_value=mock_group):
- result = await _create_sheet_group_(token=token)
-
- assert result == "new_group_id"
-
-
-# Test cases for _generate_sheet_detail_dto_ function (the one with views parameter)
-@pytest.mark.asyncio
-async def test_generate_sheet_detail_dto_with_views():
- #Test _generate_sheet_detail_dto_ with views parameter#
- sheet_details = TextDTO(
- id="sheet_id",
- title="Test Sheet",
- language="en",
- group_id="group_id",
- type=TextType.SHEET,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="test@example.com",
- categories=[],
- views=100
- )
-
- user_details = UserInfoResponse(
- firstname="Test",
- lastname="User",
- username="testuser",
- email="test@example.com",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
-
- sheet_sections = [
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ]
- )
- ]
-
- segments_dict = {
- "seg_1": SegmentDTO(
- id="seg_1",
- text_id="sheet_id",
- content="test content",
- type=SegmentType.CONTENT
- )
- }
-
- with patch("pecha_api.sheets.sheets_service.get_segments_details_by_ids", new_callable=AsyncMock, return_value=segments_dict):
- # Note: Testing the version with views parameter by calling it directly
- # Since there appear to be two versions of this function in the code
- result = await _generate_sheet_detail_dto_(
- sheet_details=sheet_details,
- user_details=user_details,
- sheet_sections=sheet_sections,
- skip=0,
- limit=10
- )
-
- assert isinstance(result, SheetDetailDTO)
- assert result.id == "sheet_id"
- assert result.sheet_title == "Test Sheet"
- assert result.views == 0 # The imported function version uses default value
- assert result.publisher.name == "Test User"
- assert result.total == 1
-
-# Test cases for clean_text function
-def test_clean_text_success():
- #Test clean_text with content under max words#
- content = "This is a test content with less than thirty words total."
- result = clean_text(content)
- assert result == "This is a test content with less than thirty words total."
-
-def test_clean_text_exceeds_max_words():
- #Test clean_text with content exceeding max words#
- content = " ".join([f"word{i}" for i in range(1, 35)]) # 34 words
- result = clean_text(content)
- words = result.split()
- assert len(words) == 30
- assert result.endswith("...")
-
-def test_clean_text_with_html():
- #Test clean_text with HTML content#
- content = "This is bold text with italics .
"
- result = clean_text(content)
- assert result == "This is bold text with italics."
-
-def test_clean_text_empty_content():
- #Test clean_text with empty content#
- result = clean_text("")
- assert result == ""
-
-def test_clean_text_whitespace_only():
- #Test clean_text with whitespace only content#
- result = clean_text(" ")
- assert result == ""
-
-
-# Test cases for _delete_sheet_table_of_content_cache_ function
-@pytest.mark.asyncio
-async def test_delete_sheet_table_of_content_cache_success():
- #Test _delete_sheet_table_of_content_cache_ with valid table of content#
- sheet_id = "test_sheet_id"
- mock_table_of_content = TableOfContent(
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[]
- )
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.sheets.sheets_service.delete_table_of_content_by_sheet_id_cache", new_callable=AsyncMock) as mock_delete_cache:
-
- from pecha_api.sheets.sheets_service import _delete_sheet_table_of_content_cache_
- result = await _delete_sheet_table_of_content_cache_(sheet_id=sheet_id)
-
- assert result == mock_table_of_content
- mock_delete_cache.assert_called_once()
-
-@pytest.mark.asyncio
-async def test_delete_sheet_table_of_content_cache_no_content():
- #Test _delete_sheet_table_of_content_cache_ with no table of content#
- sheet_id = "test_sheet_id"
-
- with patch("pecha_api.sheets.sheets_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=None), \
- patch("pecha_api.sheets.sheets_service.delete_table_of_content_by_sheet_id_cache", new_callable=AsyncMock) as mock_delete_cache:
-
- from pecha_api.sheets.sheets_service import _delete_sheet_table_of_content_cache_
- result = await _delete_sheet_table_of_content_cache_(sheet_id=sheet_id)
-
- assert result is None
- mock_delete_cache.assert_not_called()
-
-
-# Test cases for _delete_sheet_segments_cache_ function
-@pytest.mark.asyncio
-async def test_delete_sheet_segments_cache_with_content():
- #Test _delete_sheet_segments_cache_ with valid table of content#
- mock_table_of_content = TableOfContent(
- text_id="test_id",
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1),
- TextSegment(segment_id="seg_2", segment_number=2)
- ]
- )
- ]
- )
-
- with patch("pecha_api.sheets.sheets_service.delete_segments_details_by_ids_cache", new_callable=AsyncMock) as mock_delete_cache:
- from pecha_api.sheets.sheets_service import _delete_sheet_segments_cache_
- await _delete_sheet_segments_cache_(sheet_table_of_content=mock_table_of_content)
-
- mock_delete_cache.assert_called_once()
- call_args = mock_delete_cache.call_args
- assert "seg_1" in call_args[1]['segment_ids']
- assert "seg_2" in call_args[1]['segment_ids']
-
-@pytest.mark.asyncio
-async def test_delete_sheet_segments_cache_no_content():
- #Test _delete_sheet_segments_cache_ with None table of content#
- with patch("pecha_api.sheets.sheets_service.delete_segments_details_by_ids_cache", new_callable=AsyncMock) as mock_delete_cache:
- from pecha_api.sheets.sheets_service import _delete_sheet_segments_cache_
- await _delete_sheet_segments_cache_(sheet_table_of_content=None)
-
- mock_delete_cache.assert_called_once()
- call_args = mock_delete_cache.call_args
- assert call_args[1]['segment_ids'] == []
-
-
-# Test cases for delete_sheet_by_id unauthorized access
-@pytest.mark.asyncio
-async def test_delete_sheet_forbidden_access():
- #Test delete_sheet_by_id when user tries to delete another user's sheet#
- sheet_id = "test_sheet_id"
- token = "valid_token"
-
- text_details = TextDTO(
- id=sheet_id,
- title="sheet_title",
- language="language",
- group_id="group_id",
- type=TextType.SHEET,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="owner@gmail.com", # Different user
- categories=[],
- views=10
- )
-
- current_user_details = UserInfoResponse(
- firstname="Current",
- lastname="User",
- username="currentuser",
- email="current@gmail.com", # Different email
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
-
- with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \
- patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, return_value=text_details), \
- patch("pecha_api.sheets.sheets_service.get_user_info", new_callable=AsyncMock, return_value=current_user_details):
-
- with pytest.raises(HTTPException) as exc_info:
- await delete_sheet_by_id(sheet_id=sheet_id, token=token)
-
- assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
- assert exc_info.value.detail == ErrorConstants.FORBIDDEN_ERROR_MESSAGE
-
-
-# Test cases for _get_sheet_first_content_details_by_type_ function
-@pytest.mark.asyncio
-async def test_get_sheet_first_content_details_by_type_success():
- #Test _get_sheet_first_content_details_by_type_ with valid segment IDs#
- segment_ids = ["seg_1", "seg_2", "seg_3"]
- mock_segment = SegmentDTO(
- id="seg_1",
- text_id="text_id",
- content="first content",
- type=SegmentType.CONTENT
- )
-
- with patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=mock_segment):
- from pecha_api.sheets.sheets_service import _get_sheet_first_content_details_by_type_
- result = await _get_sheet_first_content_details_by_type_(
- segment_ids=segment_ids,
- segment_type=SegmentType.CONTENT
- )
-
- assert result == mock_segment
-
-@pytest.mark.asyncio
-async def test_get_sheet_first_content_details_by_type_no_result():
- #Test _get_sheet_first_content_details_by_type_ with no matching segments#
- segment_ids = ["seg_1", "seg_2", "seg_3"]
-
- with patch("pecha_api.sheets.sheets_service.get_sheet_first_content_by_ids", new_callable=AsyncMock, return_value=None):
- from pecha_api.sheets.sheets_service import _get_sheet_first_content_details_by_type_
- result = await _get_sheet_first_content_details_by_type_(
- segment_ids=segment_ids,
- segment_type=SegmentType.CONTENT
- )
-
- assert result is None
-
-
-# Test cases for _get_all_segment_ids_in_table_of_content_ edge cases
-def test_get_all_segment_ids_empty_segments():
- #Test _get_all_segment_ids_in_table_of_content_ with sections containing no segments#
- sheet_sections = [
- Section(
- id="section_1",
- section_number=1,
- segments=[]
- ),
- Section(
- id="section_2",
- section_number=2,
- segments=[]
- )
- ]
-
- result = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_sections)
- assert result == []
-
-def test_get_all_segment_ids_multiple_sections():
- #Test _get_all_segment_ids_in_table_of_content_ with multiple sections#
- sheet_sections = [
- Section(
- id="section_1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1),
- TextSegment(segment_id="seg_2", segment_number=2)
- ]
- ),
- Section(
- id="section_2",
- section_number=2,
- segments=[
- TextSegment(segment_id="seg_3", segment_number=1),
- TextSegment(segment_id="seg_4", segment_number=2)
- ]
- )
- ]
-
- result = _get_all_segment_ids_in_table_of_content_(sheet_sections=sheet_sections)
- assert result == ["seg_1", "seg_2", "seg_3", "seg_4"]
-
-
-# Test cases for fetch_sheets with specific parameters
-@pytest.mark.asyncio
-async def test_fetch_sheets_with_sort_parameters():
- #Test fetch_sheets with sorting parameters#
- mock_user = UserInfoResponse(
- firstname="Test",
- lastname="User",
- username="testuser",
- email="test@example.com",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
- mock_sheets = _generate_mock_sheets_response_()
-
- with patch("pecha_api.sheets.sheets_service.get_sheet", new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheet, \
- patch("pecha_api.sheets.sheets_service.Utils.time_passed", return_value="1 day ago"), \
- patch("pecha_api.sheets.sheets_service.fetch_user_by_email", return_value=mock_user), \
- patch("pecha_api.texts.texts_models.Text.get_published_sheets_count_from_db", new_callable=AsyncMock, return_value=5):
-
- result = await fetch_sheets(
- token="valid_token",
- language="en",
- email=None,
- sort_by=SortBy.CREATED_DATE,
- sort_order=SortOrder.ASC,
- skip=5,
- limit=20
- )
-
- # Verify get_sheet was called with correct parameters
- mock_get_sheet.assert_called_once_with(
- is_published=True,
- sort_by=SortBy.CREATED_DATE,
- sort_order=SortOrder.ASC,
- skip=5,
- limit=20
- )
-
- assert isinstance(result, SheetDTOResponse)
- assert result.skip == 5
- assert result.limit == 20
-
-
-# Additional test for upload_sheet_image_request error handling
-def test_upload_sheet_image_request_validation_error():
- #Test upload_sheet_image_request when image validation fails#
- file_content = io.BytesIO(b"invalid_image_data")
- mock_file = MagicMock(spec=UploadFile)
- mock_file.filename = "test.jpg"
- mock_file.file = file_content
- mock_file.content_type = "image/jpeg"
-
- with patch("pecha_api.sheets.sheets_service.ImageUtils.validate_and_compress_image", side_effect=HTTPException(status_code=400, detail="Invalid image")):
- with pytest.raises(HTTPException) as exc_info:
- upload_sheet_image_request(sheet_id="test_id", file=mock_file)
-
- assert exc_info.value.status_code == 400
- assert exc_info.value.detail == "Invalid image"
-
-
-# Test case for _generate_sheet_detail_dto_ with different publisher name scenarios
-@pytest.mark.asyncio
-async def test_generate_sheet_detail_dto_empty_publisher_name():
- #Test _generate_sheet_detail_dto_ with empty publisher first/last name#
- sheet_details = TextDTO(
- id="sheet_id",
- title="Test Sheet",
- language="en",
- group_id="group_id",
- type=TextType.SHEET,
- is_published=True,
- created_date="2021-01-01",
- updated_date="2021-01-01",
- published_date="2021-01-01",
- published_by="test@example.com",
- categories=[],
- views=50
- )
-
- user_details = UserInfoResponse(
- firstname="", # Empty first name
- lastname="", # Empty last name
- username="testuser",
- email="test@example.com",
- educations=[],
- followers=0,
- following=0,
- social_profiles=[]
- )
-
- sheet_sections = []
-
- with patch("pecha_api.sheets.sheets_service.get_segments_details_by_ids", new_callable=AsyncMock, return_value={}):
- result = await _generate_sheet_detail_dto_(
- sheet_details=sheet_details,
- user_details=user_details,
- sheet_sections=sheet_sections,
- skip=0,
- limit=10
- )
-
- assert isinstance(result, SheetDetailDTO)
- assert result.id == "sheet_id"
- assert result.publisher.name == " " # Empty first and last name results in a space character
-
-
-# Test case for _generate_sheet_section_ with SOURCE segment error handling
-@pytest.mark.asyncio
-async def test_generate_sheet_section_source_segment_error():
- #Test _generate_sheet_section_ when source text details cannot be retrieved#
- segments = [
- TextSegment(segment_id="source_segment", segment_number=1)
- ]
-
- segments_dict = {
- "source_segment": SegmentDTO(
- id="source_segment",
- text_id="invalid_text_id",
- content="source_content",
- type=SegmentType.SOURCE
- )
- }
-
- with patch("pecha_api.sheets.sheets_service.TextUtils.get_text_details_by_id", new_callable=AsyncMock, side_effect=HTTPException(status_code=404, detail="Text not found")):
-
- with pytest.raises(HTTPException):
- await _generate_sheet_section_(segments=segments, segments_dict=segments_dict)
-
-
-# Test case for create_new_sheet with invalid token in internal function
-@pytest.mark.asyncio
-async def test_create_new_sheet_group_creation_error():
- #Test create_new_sheet when group creation fails#
- mock_create_sheet_request = CreateSheetRequest(
- title="Test Sheet",
- source=[],
- is_published=True
- )
-
- with patch("pecha_api.sheets.sheets_service.create_new_group", new_callable=AsyncMock, side_effect=HTTPException(status_code=401, detail="Unauthorized")):
-
- with pytest.raises(HTTPException) as exc_info:
- await create_new_sheet(
- create_sheet_request=mock_create_sheet_request,
- token="invalid_token"
- )
-
- assert exc_info.value.status_code == 401
-
-
-# Test case for update_sheet_by_id cache deletion error handling
-@pytest.mark.asyncio
-async def test_update_sheet_cache_deletion_error():
- #Test update_sheet_by_id when cache deletion fails - should propagate the error#
- sheet_id = str(uuid.uuid4())
- mock_update_request = CreateSheetRequest(
- title="Updated Title",
- source=[
- Source(position=1, type=SegmentType.CONTENT, content="test content")
- ],
- is_published=True
- )
-
- with patch("pecha_api.sheets.sheets_service.validate_user_exists", return_value=True), \
- patch("pecha_api.sheets.sheets_service.delete_text_details_by_id_cache", new_callable=AsyncMock, side_effect=Exception("Cache error")):
-
- # Should propagate the cache error since there's no error handling in the function
- with pytest.raises(Exception) as exc_info:
- await update_sheet_by_id(
- sheet_id=sheet_id,
- update_sheet_request=mock_update_request,
- token="valid_token"
- )
-
- assert str(exc_info.value) == "Cache error"
-
-
-# Test case for _generate_segment_dictionary_ with mixed segment types
-def test_generate_segment_dictionary_mixed_types():
- #Test _generate_segment_dictionary_ with mixed segment types including SOURCE#
- segments_response = SegmentResponse(
- segments=[
- SegmentDTO(
- id="content_seg",
- text_id="text_id",
- content="content text",
- type=SegmentType.CONTENT
- ),
- SegmentDTO(
- id="source_seg",
- text_id="text_id",
- content="source_id",
- type=SegmentType.SOURCE
- ),
- SegmentDTO(
- id="image_seg",
- text_id="text_id",
- content="image_url",
- type=SegmentType.IMAGE
- )
- ]
- )
-
- result = _generate_segment_dictionary_(new_segments=segments_response)
-
- # Should only contain non-SOURCE segments
- content_hash = hashlib.sha256("content text".encode()).hexdigest()
- image_hash = hashlib.sha256("image_url".encode()).hexdigest()
- source_hash = hashlib.sha256("source_id".encode()).hexdigest()
-
- assert content_hash in result
- assert image_hash in result
- assert source_hash not in result
- assert result[content_hash] == "content_seg"
- assert result[image_hash] == "image_seg"
-
-
-# Test case for _generate_segment_creation_request_payload_ with only SOURCE segments
-def test_generate_segment_creation_request_payload_only_sources():
- #Test _generate_segment_creation_request_payload_ with only SOURCE type segments#
- create_request = CreateSheetRequest(
- title="Test Sheet",
- source=[
- Source(position=1, type=SegmentType.SOURCE, content="source_id_1"),
- Source(position=2, type=SegmentType.SOURCE, content="source_id_2")
- ],
- is_published=True
- )
-
- result = _generate_segment_creation_request_payload_(
- create_sheet_request=create_request,
- text_id="test_text_id"
- )
-
- assert isinstance(result, CreateSegmentRequest)
- assert result.text_id == "test_text_id"
- assert len(result.segments) == 0 # Should exclude all SOURCE segments
-
-
-# Test case for _create_sheet_text_ with invalid token
-@pytest.mark.asyncio
-async def test_create_sheet_text_invalid_token():
- #Test _create_sheet_text_ with invalid token#
- with patch("pecha_api.sheets.sheets_service.validate_and_extract_user_details", side_effect=HTTPException(status_code=401, detail="Invalid token")):
-
- with pytest.raises(HTTPException) as exc_info:
- await _create_sheet_text_(
- title="Test Sheet",
- token="invalid_token",
- group_id="group_id"
- )
-
- assert exc_info.value.status_code == 401
-
-
-# Test case for _create_sheet_group_ with invalid token
-@pytest.mark.asyncio
-async def test_create_sheet_group_invalid_token():
- #Test _create_sheet_group_ with invalid token#
- with patch("pecha_api.sheets.sheets_service.create_new_group", new_callable=AsyncMock, side_effect=HTTPException(status_code=401, detail="Invalid token")):
-
- with pytest.raises(HTTPException) as exc_info:
- await _create_sheet_group_(token="invalid_token")
-
- assert exc_info.value.status_code == 401
\ No newline at end of file
diff --git a/tests/sheets/test_sheets_views.py b/tests/sheets/test_sheets_views.py
deleted file mode 100644
index b0884998a..000000000
--- a/tests/sheets/test_sheets_views.py
+++ /dev/null
@@ -1,149 +0,0 @@
-import pytest
-from unittest.mock import patch, AsyncMock
-
-
-from pecha_api.sheets.sheets_views import (
- create_sheet,
- get_sheet
-)
-from pecha_api.sheets.sheets_response_models import (
- CreateSheetRequest,
- SheetDetailDTO,
- SheetSection,
- Publisher
-)
-
-from pecha_api.texts.texts_response_models import TableOfContent, TableOfContentType, Section, TextSegment
-
-@pytest.mark.asyncio
-async def test_create_sheet_success():
- mock_source = []
- mock_create_sheet_request = CreateSheetRequest(
- title="sheet_title",
- source=mock_source
- )
-
- mock_table_of_content_response = TableOfContent(
- id="table_of_content_id",
- text_id="text_id",
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_id",
- section_number=1,
- segments=[
- TextSegment(
- segment_id="segment_id_1",
- segment_number=1
- ),
- TextSegment(
- segment_id="segment_id_2",
- segment_number=2
- ),
- TextSegment(
- segment_id="segment_id_3",
- segment_number=3
- )
- ]
- )
- ]
- )
-
- with patch("pecha_api.sheets.sheets_views.create_new_sheet", new_callable=AsyncMock, return_value=mock_table_of_content_response):
-
- response = await create_sheet(
- create_sheet_request=mock_create_sheet_request,
- authentication_credential=type("HTTPAuthorizationCredentials", (), {"credentials": "valid_token"})()
- )
-
- assert response is not None
- assert isinstance(response, TableOfContent)
- assert response.id == "table_of_content_id"
- assert response.text_id == "text_id"
-
-
-@pytest.mark.asyncio
-async def test_get_sheet_success():
- sheet_id = "test_sheet_id"
- skip = 0
- limit = 10
-
- mock_sheet_detail = SheetDetailDTO(
- id=sheet_id,
- sheet_title="Test Sheet Title",
- created_date="2024-01-01T00:00:00Z",
- publisher=Publisher(
- name="Test User",
- username="testuser",
- email="test@example.com"
- ),
- content=SheetSection(
- section_number=1,
- segments=[]
- ),
- views=0,
- is_published=True,
- skip=skip,
- limit=limit,
- total=1
- )
-
- with patch("pecha_api.sheets.sheets_views.get_sheet_by_id", new_callable=AsyncMock, return_value=mock_sheet_detail):
- response = await get_sheet(
- sheet_id=sheet_id,
- skip=skip,
- limit=limit
- )
-
- assert response is not None
- assert isinstance(response, SheetDetailDTO)
- assert response.id == sheet_id
- assert response.sheet_title == "Test Sheet Title"
- assert response.publisher.name == "Test User"
- assert response.publisher.email == "test@example.com"
- assert response.skip == skip
- assert response.limit == limit
- assert response.total == 1
-
-
-@pytest.mark.asyncio
-async def test_get_sheet_with_custom_pagination():
- sheet_id = "test_sheet_id"
- skip = 5
- limit = 20
-
- mock_sheet_detail = SheetDetailDTO(
- id=sheet_id,
- sheet_title="Test Sheet Title",
- created_date="2024-01-01T00:00:00Z",
- publisher=Publisher(
- name="Test User",
- username="testuser",
- email="test@example.com"
- ),
- content=SheetSection(
- section_number=1,
- segments=[]
- ),
- views=0,
- is_published=True,
- skip=skip,
- limit=limit,
- total=1
- )
-
- with patch("pecha_api.sheets.sheets_views.get_sheet_by_id", new_callable=AsyncMock, return_value=mock_sheet_detail) as mock_service:
- response = await get_sheet(
- sheet_id=sheet_id,
- skip=skip,
- limit=limit
- )
-
- # Verify the service function was called with correct parameters
- mock_service.assert_called_once_with(sheet_id=sheet_id, skip=skip, limit=limit)
-
- assert response is not None
- assert isinstance(response, SheetDetailDTO)
- assert response.id == sheet_id
- assert response.skip == skip
- assert response.limit == limit
\ No newline at end of file
diff --git a/tests/text_uploader/__init__.py b/tests/text_uploader/__init__.py
deleted file mode 100644
index a83913065..000000000
--- a/tests/text_uploader/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Tests for text uploader module."""
-
-
-
-
-
diff --git a/tests/text_uploader/collections/__init__.py b/tests/text_uploader/collections/__init__.py
deleted file mode 100644
index 869be8990..000000000
--- a/tests/text_uploader/collections/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Collection-related tests for text uploader."""
-
-
-
-
-
diff --git a/tests/text_uploader/collections/test_collection_service.py b/tests/text_uploader/collections/test_collection_service.py
deleted file mode 100644
index dac7550da..000000000
--- a/tests/text_uploader/collections/test_collection_service.py
+++ /dev/null
@@ -1,701 +0,0 @@
-import pytest
-from unittest.mock import AsyncMock, patch
-
-from pecha_api.text_uploader.collections.collection_service import CollectionService
-
-
-def test_build_multilingual_payload_merges_languages_and_sets_slug_from_en_title():
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "_id": {"$oid": "c1"},
- "title": "Liturgy",
- "description": "Prayers",
- "parent_id": None,
- "has_sub_child": False,
- }
- ],
- },
- {
- "language": "bo",
- "collections": [
- {
- "_id": {"$oid": "c1"},
- "title": "ཁ་འདོན།",
- "description": "ཆོ་ག",
- "parent_id": None,
- "has_sub_child": False,
- }
- ],
- },
- ]
-
- payloads = service.build_multilingual_payload(collections_by_language)
- assert len(payloads) == 1
-
- payload = payloads[0]
- assert payload["pecha_collection_id"] == "c1"
- assert payload["slug"] == "Liturgy"
- assert payload["titles"]["en"] == "Liturgy"
- assert payload["titles"]["bo"] == "ཁ་འདོན།"
- assert payload["descriptions"]["en"] == "Prayers"
- assert payload["descriptions"]["bo"] == "ཆོ་ག"
- # configured languages should always exist as keys
- assert "zh" in payload["descriptions"]
-
-
-@pytest.mark.asyncio
-async def test_build_recursive_multilingual_payloads_recurse_children_uploads_all_levels():
- service = CollectionService()
-
- root_level = [
- {
- "language": "en",
- "collections": [
- {
- "_id": {"$oid": "c1"},
- "title": "Root",
- "description": "Root desc",
- "parent_id": None,
- "has_sub_child": True,
- }
- ],
- },
- {"language": "bo", "collections": [{"_id": {"$oid": "c1"}, "title": "རྩ་བ།"}]},
- {"language": "zh", "collections": [{"_id": {"$oid": "c1"}, "title": "根"}]},
- ]
- child_level = [
- {
- "language": "en",
- "collections": [
- {
- "_id": {"$oid": "c2"},
- "title": "Child",
- "description": "Child desc",
- "parent_id": {"$oid": "c1"},
- "has_sub_child": False,
- }
- ],
- },
- {"language": "bo", "collections": [{"_id": {"$oid": "c2"}, "title": "བུ།"}]},
- {"language": "zh", "collections": [{"_id": {"$oid": "c2"}, "title": "子"}]},
- ]
-
- async def fake_get_collections_service(*, openpecha_api_url: str, parent_id=None):
- if parent_id is None:
- return root_level
- if parent_id == "c1":
- return child_level
- return [{"language": "en", "collections": []}]
-
- with patch.object(
- service,
- "get_collections_service",
- new=AsyncMock(side_effect=fake_get_collections_service),
- ), patch(
- "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id",
- new_callable=AsyncMock,
- return_value=None,
- ), patch(
- "pecha_api.text_uploader.collections.collection_service.post_collections",
- new_callable=AsyncMock,
- side_effect=[
- {"id": {"$oid": "local_c1"}},
- {"id": "local_c2"},
- ],
- ) as mock_post:
- payloads = await service.build_recursive_multilingual_payloads(
- destination_url="https://dest.example/api/v1",
- openpecha_api_url="https://openpecha.example",
- access_token="tok",
- )
-
- assert mock_post.await_count == 2
-
- assert len(payloads) == 1
- root_payload = payloads[0]
- assert root_payload["pecha_collection_id"] == "c1"
- assert root_payload["local_id"] == "local_c1"
-
- assert "children" in root_payload
- assert len(root_payload["children"]) == 1
- child_payload = root_payload["children"][0]
- assert child_payload["pecha_collection_id"] == "c2"
- assert child_payload["local_id"] == "local_c2"
-
- # Ensure recursion used the root's local_id as the child's parent_id in POST.
- first_call = mock_post.await_args_list[0]
- second_call = mock_post.await_args_list[1]
- assert first_call.kwargs["collection_model"].parent_id is None
- assert second_call.kwargs["collection_model"].parent_id == "local_c1"
-
-import pytest
-from unittest.mock import AsyncMock, patch, MagicMock
-
-from pecha_api.text_uploader.collections.collection_service import CollectionService
-from pecha_api.text_uploader.constants import COLLECTION_LANGUAGES
-from pecha_api.text_uploader.collections.collection_model import CollectionPayload
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize("parent_id", [None, "parent_1"])
-async def test_get_collections_service_delegates_to_repository(parent_id: str | None):
- expected = [{"language": "en", "collections": [{"id": "c1"}]}]
-
- with patch(
- "pecha_api.text_uploader.collections.collection_service.get_collections",
- new_callable=AsyncMock,
- return_value=expected,
- ) as mock_get_collections:
- service = CollectionService()
-
- result = await service.get_collections_service(
- openpecha_api_url="https://openpecha.example",
- parent_id=parent_id,
- )
-
- assert result == expected
- mock_get_collections.assert_awaited_once_with(
- openpecha_api_url="https://openpecha.example",
- languages=COLLECTION_LANGUAGES,
- parent_id=parent_id,
- )
-
-
-@pytest.mark.asyncio
-async def test_upload_collections_success():
- """Test upload_collections calls build_recursive_multilingual_payloads"""
- service = CollectionService()
-
- text_upload_request = TextUploadRequest(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- text_id="test_text_id"
- )
- token = "test_token"
-
- with patch.object(
- service,
- "build_recursive_multilingual_payloads",
- new_callable=AsyncMock
- ) as mock_build:
- await service.upload_collections(
- text_upload_request=text_upload_request,
- token=token
- )
-
- mock_build.assert_awaited_once_with(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- access_token=token
- )
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_payload_single_language():
- """Test build_multilingual_payload with single language collections"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "collection_id_1",
- "slug": "test-collection",
- "title": "Test Collection",
- "description": "Test Description",
- "parent_id": None,
- "has_sub_child": False
- }
- ]
- }
- ]
-
- result = service.build_multilingual_payload(collections_by_language)
-
- assert len(result) == 1
- assert result[0]["pecha_collection_id"] == "collection_id_1"
- assert result[0]["slug"] == "Test Collection"
- assert result[0]["titles"] == {"en": "Test Collection"}
- assert result[0]["descriptions"]["en"] == "Test Description"
- assert result[0]["parent_id"] is None
- assert result[0]["has_sub_child"] is False
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_payload_multiple_languages():
- """Test build_multilingual_payload with multiple languages for same collection"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "collection_id_1",
- "slug": "madhyamaka",
- "title": "Madhyamaka",
- "description": "Madhyamaka treatises",
- "parent_id": None,
- "has_sub_child": True
- }
- ]
- },
- {
- "language": "bo",
- "collections": [
- {
- "id": "collection_id_1",
- "slug": "madhyamaka",
- "title": "དབུ་མ།",
- "description": "དབུ་མའི་གཞུང་སྣ་ཚོགས།",
- "parent_id": None,
- "has_sub_child": True
- }
- ]
- }
- ]
-
- result = service.build_multilingual_payload(collections_by_language)
-
- assert len(result) == 1
- assert result[0]["pecha_collection_id"] == "collection_id_1"
- assert result[0]["slug"] == "Madhyamaka" # English title is used as slug
- assert result[0]["titles"] == {"en": "Madhyamaka", "bo": "དབུ་མ།"}
- assert result[0]["descriptions"]["en"] == "Madhyamaka treatises"
- assert result[0]["descriptions"]["bo"] == "དབུ་མའི་གཞུང་སྣ་ཚོགས།"
- assert result[0]["has_sub_child"] is True
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_payload_with_oid_format():
- """Test build_multilingual_payload with MongoDB ObjectId format"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "_id": {"$oid": "507f1f77bcf86cd799439011"},
- "slug": "test",
- "title": "Test Collection",
- "description": "Test Description",
- "parent_id": {"$oid": "507f1f77bcf86cd799439012"},
- "has_child": True
- }
- ]
- }
- ]
-
- result = service.build_multilingual_payload(collections_by_language)
-
- assert len(result) == 1
- assert result[0]["pecha_collection_id"] == "507f1f77bcf86cd799439011"
- assert result[0]["parent_id"] == {"$oid": "507f1f77bcf86cd799439012"}
- assert result[0]["has_sub_child"] is True
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_payload_multiple_collections():
- """Test build_multilingual_payload with multiple different collections"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "collection_1",
- "title": "Collection 1",
- "description": "Description 1",
- "has_sub_child": False
- },
- {
- "id": "collection_2",
- "title": "Collection 2",
- "description": "Description 2",
- "has_sub_child": True
- }
- ]
- }
- ]
-
- result = service.build_multilingual_payload(collections_by_language)
-
- assert len(result) == 2
- collection_ids = [r["pecha_collection_id"] for r in result]
- assert "collection_1" in collection_ids
- assert "collection_2" in collection_ids
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_payload_with_titles_dict():
- """Test build_multilingual_payload when titles/descriptions are already dicts"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "collection_1",
- "slug": "test",
- "titles": {"en": "English Title", "bo": "བོད་ཡིག"},
- "descriptions": {"en": "English Description"},
- "has_sub_child": False
- }
- ]
- }
- ]
-
- result = service.build_multilingual_payload(collections_by_language)
-
- assert len(result) == 1
- assert result[0]["titles"]["en"] == "English Title"
- assert result[0]["slug"] == "English Title"
-
-
-@pytest.mark.asyncio
-async def test_build_recursive_multilingual_payloads_no_children():
- """Test build_recursive_multilingual_payloads with collections that have no children"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "collection_1",
- "title": "Test Collection",
- "description": "Test Description",
- "parent_id": None,
- "has_sub_child": False
- }
- ]
- }
- ]
-
- with patch.object(
- service,
- "get_collections_service",
- new_callable=AsyncMock,
- return_value=collections_by_language
- ) as mock_get_collections, \
- patch(
- "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id",
- new_callable=AsyncMock,
- return_value=None
- ) as mock_get_existing, \
- patch(
- "pecha_api.text_uploader.collections.collection_service.post_collections",
- new_callable=AsyncMock,
- return_value={"id": "new_local_id_1"}
- ) as mock_post:
- result = await service.build_recursive_multilingual_payloads(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- access_token="test_token"
- )
-
- assert len(result) == 1
- assert result[0]["pecha_collection_id"] == "collection_1"
- assert result[0]["children"] == []
- assert result[0]["local_id"] == "new_local_id_1"
- mock_post.assert_awaited_once()
-
-
-@pytest.mark.asyncio
-async def test_build_recursive_multilingual_payloads_multiple_collections_no_children():
- """Test build_recursive_multilingual_payloads with multiple collections without children"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "collection_1",
- "title": "Collection 1",
- "description": "Description 1",
- "has_sub_child": False
- },
- {
- "id": "collection_2",
- "title": "Collection 2",
- "description": "Description 2",
- "has_sub_child": False
- }
- ]
- }
- ]
-
- with patch.object(
- service,
- "get_collections_service",
- new_callable=AsyncMock,
- return_value=collections_by_language
- ), \
- patch(
- "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id",
- new_callable=AsyncMock,
- return_value=None
- ), \
- patch(
- "pecha_api.text_uploader.collections.collection_service.post_collections",
- new_callable=AsyncMock,
- side_effect=[
- {"id": "local_1"},
- {"id": "local_2"}
- ]
- ) as mock_post:
- result = await service.build_recursive_multilingual_payloads(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- access_token="test_token"
- )
-
- assert len(result) == 2
- assert result[0]["pecha_collection_id"] == "collection_1"
- assert result[0]["local_id"] == "local_1"
- assert result[0]["children"] == []
- assert result[1]["pecha_collection_id"] == "collection_2"
- assert result[1]["local_id"] == "local_2"
- assert result[1]["children"] == []
- assert mock_post.await_count == 2
-
-
-@pytest.mark.asyncio
-async def test_build_recursive_multilingual_payloads_existing_collection():
- """Test build_recursive_multilingual_payloads skips existing collections"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "existing_collection",
- "title": "Existing Collection",
- "description": "Already exists",
- "has_sub_child": False
- }
- ]
- }
- ]
-
- with patch.object(
- service,
- "get_collections_service",
- new_callable=AsyncMock,
- return_value=collections_by_language
- ), \
- patch(
- "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id",
- new_callable=AsyncMock,
- return_value="existing_local_id"
- ) as mock_get_existing, \
- patch(
- "pecha_api.text_uploader.collections.collection_service.post_collections",
- new_callable=AsyncMock
- ) as mock_post:
- result = await service.build_recursive_multilingual_payloads(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- access_token="test_token"
- )
-
- assert len(result) == 1
- mock_get_existing.assert_awaited_once()
- mock_post.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_build_recursive_multilingual_payloads_with_oid_local_id():
- """Test build_recursive_multilingual_payloads handles ObjectId format for local_id"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "collection_1",
- "title": "Test Collection",
- "has_sub_child": False
- }
- ]
- }
- ]
-
- with patch.object(
- service,
- "get_collections_service",
- new_callable=AsyncMock,
- return_value=collections_by_language
- ), \
- patch(
- "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id",
- new_callable=AsyncMock,
- return_value=None
- ), \
- patch(
- "pecha_api.text_uploader.collections.collection_service.post_collections",
- new_callable=AsyncMock,
- return_value={"_id": {"$oid": "507f1f77bcf86cd799439011"}}
- ):
- result = await service.build_recursive_multilingual_payloads(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- access_token="test_token"
- )
-
- assert len(result) == 1
- assert result[0]["local_id"] == "507f1f77bcf86cd799439011"
-
-
-@pytest.mark.asyncio
-async def test_build_recursive_multilingual_payloads_with_local_parent_id():
- """Test build_recursive_multilingual_payloads uses provided local_parent_id"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "child_collection",
- "title": "Child Collection",
- "parent_id": "remote_parent_id",
- "has_sub_child": False
- }
- ]
- }
- ]
-
- with patch.object(
- service,
- "get_collections_service",
- new_callable=AsyncMock,
- return_value=collections_by_language
- ), \
- patch(
- "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id",
- new_callable=AsyncMock,
- return_value=None
- ), \
- patch(
- "pecha_api.text_uploader.collections.collection_service.post_collections",
- new_callable=AsyncMock,
- return_value={"id": "new_local_id"}
- ) as mock_post:
- result = await service.build_recursive_multilingual_payloads(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- access_token="test_token",
- local_parent_id="local_parent_id_123"
- )
-
- assert len(result) == 1
- # Verify that the collection was posted with the correct parent_id
- call_args = mock_post.call_args
- assert call_args[1]["collection_model"].parent_id == "local_parent_id_123"
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_payload_empty_collections():
- """Test build_multilingual_payload with empty collections"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": []
- }
- ]
-
- result = service.build_multilingual_payload(collections_by_language)
-
- assert len(result) == 0
- assert result == []
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_payload_none_descriptions():
- """Test build_multilingual_payload handles None descriptions"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "collection_1",
- "title": "Test Collection",
- "description": None,
- "has_sub_child": False
- }
- ]
- }
- ]
-
- result = service.build_multilingual_payload(collections_by_language)
-
- assert len(result) == 1
- assert result[0]["titles"]["en"] == "Test Collection"
- # Description for 'en' should be empty string (from initialization)
- assert "descriptions" in result[0]
-
-
-@pytest.mark.asyncio
-async def test_build_recursive_multilingual_payloads_no_remote_parent_id():
- """Test build_recursive_multilingual_payloads handles missing remote parent id"""
- service = CollectionService()
-
- collections_by_language = [
- {
- "language": "en",
- "collections": [
- {
- "id": "valid_id", # Valid ID but will test edge case
- "title": "Test Collection",
- "has_sub_child": False # No children to avoid recursion bug
- }
- ]
- }
- ]
-
- with patch.object(
- service,
- "get_collections_service",
- new_callable=AsyncMock,
- return_value=collections_by_language
- ), \
- patch(
- "pecha_api.text_uploader.collections.collection_service.get_collection_by_pecha_collection_id",
- new_callable=AsyncMock,
- return_value=None
- ), \
- patch(
- "pecha_api.text_uploader.collections.collection_service.post_collections",
- new_callable=AsyncMock,
- return_value={"id": "new_id"}
- ):
- result = await service.build_recursive_multilingual_payloads(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- access_token="test_token"
- )
-
- assert len(result) == 1
- assert result[0]["pecha_collection_id"] == "valid_id"
- # Should have empty children since has_sub_child is False
- assert result[0]["children"] == []
-
-
diff --git a/tests/text_uploader/mapping/test_mapping_service.py b/tests/text_uploader/mapping/test_mapping_service.py
deleted file mode 100644
index 0e8e46b9a..000000000
--- a/tests/text_uploader/mapping/test_mapping_service.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import pytest
-from unittest.mock import AsyncMock, patch
-
-from pecha_api.text_uploader.mapping.mapping_services import MappingService
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-
-
-@pytest.mark.asyncio
-async def test_mapping_service_triggers_repo_with_text_ids_and_urls():
- service = MappingService()
- request = TextUploadRequest(
- destination_url="https://dest.example/api/v1",
- openpecha_api_url="https://openpecha.example",
- text_id="T1",
- )
-
- with patch(
- "pecha_api.text_uploader.mapping.mapping_services.trigger_mapping_repo",
- new_callable=AsyncMock,
- ) as mock_trigger:
- await service.trigger_mapping(
- text_ids={"a": "t1", "b": "t2"},
- text_upload_request=request,
- )
-
- mock_trigger.assert_awaited_once_with(
- ["t1", "t2"],
- "https://openpecha.example",
- "https://dest.example/api/v1",
- )
diff --git a/tests/text_uploader/mapping/test_mapping_services.py b/tests/text_uploader/mapping/test_mapping_services.py
deleted file mode 100644
index f9587d3e4..000000000
--- a/tests/text_uploader/mapping/test_mapping_services.py
+++ /dev/null
@@ -1,31 +0,0 @@
-import pytest
-from unittest.mock import AsyncMock, patch
-
-from pecha_api.text_uploader.mapping.mapping_services import MappingService
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-
-
-@pytest.mark.asyncio
-async def test_trigger_mapping_passes_text_ids_and_urls_to_repo():
- service = MappingService()
- request = TextUploadRequest(
- destination_url="https://dest.example",
- openpecha_api_url="https://openpecha.example",
- text_id="t1",
- )
-
- with patch(
- "pecha_api.text_uploader.mapping.mapping_services.trigger_mapping_repo",
- new_callable=AsyncMock,
- ) as mock_trigger:
- await service.trigger_mapping(
- text_ids={"a": "i1", "b": "i2"},
- text_upload_request=request,
- )
-
- mock_trigger.assert_awaited_once_with(
- ["i1", "i2"],
- "https://openpecha.example",
- "https://dest.example",
- )
-
diff --git a/tests/text_uploader/segments/__init__.py b/tests/text_uploader/segments/__init__.py
deleted file mode 100644
index edaedb4e7..000000000
--- a/tests/text_uploader/segments/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Segment-related tests for text uploader."""
-
-
-
-
-
diff --git a/tests/text_uploader/segments/test_segment_model.py b/tests/text_uploader/segments/test_segment_model.py
deleted file mode 100644
index 096c7083f..000000000
--- a/tests/text_uploader/segments/test_segment_model.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from pecha_api.text_uploader.segments.segment_model import (
- ManifestationModel,
- Segment,
- SegmentModel,
-)
-
-
-def test_segment_models_validate_and_dump():
- model = SegmentModel(
- text_id="t1",
- segments=[
- Segment(content="a", type="source"),
- Segment(content="b", type="source"),
- ],
- )
-
- dumped = model.model_dump()
- assert dumped["text_id"] == "t1"
- assert dumped["segments"][0] == {"content": "a", "type": "source"}
-
-
-def test_manifestation_model_defaults_none():
- model = ManifestationModel()
- assert model.job_id is None
- assert model.status is None
- assert model.message is None
-
diff --git a/tests/text_uploader/segments/test_segment_repository.py b/tests/text_uploader/segments/test_segment_repository.py
deleted file mode 100644
index 718c52112..000000000
--- a/tests/text_uploader/segments/test_segment_repository.py
+++ /dev/null
@@ -1,218 +0,0 @@
-import requests
-import pytest
-from unittest.mock import AsyncMock, patch
-
-
-class DummyResponse:
- def __init__(
- self,
- payload,
- *,
- ok: bool = True,
- status_code: int = 200,
- text: str = "",
- raise_for_status_exc: Exception | None = None,
- ):
- self._payload = payload
- self.ok = ok
- self.status_code = status_code
- self.text = text
- self._raise_for_status_exc = raise_for_status_exc
-
- def raise_for_status(self):
- if self._raise_for_status_exc:
- raise self._raise_for_status_exc
-
- def json(self):
- return self._payload
-
-
-@pytest.mark.asyncio
-async def test_get_segments_annotation_calls_instances_endpoint():
- from pecha_api.text_uploader.segments import segment_respository as repo
-
- response = DummyResponse([{"ok": True}])
- with patch(
- "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread",
- new_callable=AsyncMock,
- return_value=response,
- ) as mock_to_thread:
- result = await repo.get_segments_annotation(
- pecha_text_id="P1", openpecha_api_url="https://openpecha.example"
- )
-
- assert result == [{"ok": True}]
- assert mock_to_thread.await_count == 1
- call = mock_to_thread.await_args
- assert call.args[0] is repo.requests.get
- assert call.args[1] == "https://openpecha.example/v2/instances/P1"
- assert call.kwargs["params"] == {"annotation": "true", "content": "true"}
-
-
-@pytest.mark.asyncio
-async def test_get_segments_id_by_annotation_id_calls_annotations_endpoint():
- from pecha_api.text_uploader.segments import segment_respository as repo
-
- response = DummyResponse([{"id": "a1"}])
- with patch(
- "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread",
- new_callable=AsyncMock,
- return_value=response,
- ) as mock_to_thread:
- result = await repo.get_segments_id_by_annotation_id(
- annotation_id="ann_1", openpecha_api_url="https://openpecha.example"
- )
-
- assert result == [{"id": "a1"}]
- call = mock_to_thread.await_args
- assert call.args[0] is repo.requests.get
- assert call.args[1] == "https://openpecha.example/v2/annotations/ann_1"
-
-@pytest.mark.asyncio
-async def test_get_segments_by_id_calls_annotations_endpoint():
- from pecha_api.text_uploader.segments import segment_respository as repo
-
- response = DummyResponse({"data": [{"id": "seg"}]})
- with patch(
- "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread",
- new_callable=AsyncMock,
- return_value=response,
- ) as mock_to_thread:
- result = await repo.get_segments_by_id(
- annotation_id="ann_2",
- openpecha_api_url="https://openpecha.example",
- )
-
- assert result == {"data": [{"id": "seg"}]}
- call = mock_to_thread.await_args
- assert call.args[0] is repo.requests.get
- assert call.args[1] == "https://openpecha.example/v2/annotations/ann_2"
-
-
-@pytest.mark.asyncio
-async def test_get_segment_content_success_posts_segment_ids_payload():
- from pecha_api.text_uploader.segments import segment_respository as repo
-
- response = DummyResponse([{"segment_id": "s1", "content": "c1"}])
- with patch(
- "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread",
- new_callable=AsyncMock,
- return_value=response,
- ) as mock_to_thread:
- result = await repo.get_segment_content(
- segment_id=["s1", "s2"],
- pecha_text_id="P1",
- openpecha_api_url="https://openpecha.example",
- )
-
- assert result == [{"segment_id": "s1", "content": "c1"}]
- call = mock_to_thread.await_args
- assert call.args[0] is repo.requests.post
- assert call.args[1] == "https://openpecha.example/v2/instances/P1/segment-content"
- assert call.kwargs["json"] == {"segment_ids": ["s1", "s2"]}
-
-
-@pytest.mark.asyncio
-async def test_get_segment_content_raises_on_http_error():
- from pecha_api.text_uploader.segments import segment_respository as repo
-
- http_error = requests.exceptions.HTTPError("boom")
- response = DummyResponse(
- {"detail": "bad"},
- ok=False,
- status_code=400,
- text="bad",
- raise_for_status_exc=http_error,
- )
- with patch(
- "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread",
- new_callable=AsyncMock,
- return_value=response,
- ):
- with pytest.raises(requests.exceptions.HTTPError):
- await repo.get_segment_content(
- segment_id=["s1"],
- pecha_text_id="P1",
- openpecha_api_url="https://openpecha.example",
- )
-
-@pytest.mark.asyncio
-async def test_get_segment_content_raises_on_request_exception():
- from pecha_api.text_uploader.segments import segment_respository as repo
-
- with patch(
- "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread",
- new_callable=AsyncMock,
- side_effect=requests.exceptions.RequestException("boom"),
- ):
- with pytest.raises(requests.exceptions.RequestException):
- await repo.get_segment_content(
- segment_id=["s1"],
- pecha_text_id="P1",
- openpecha_api_url="https://openpecha.example",
- )
-
-
-@pytest.mark.asyncio
-async def test_get_segment_content_raises_on_unexpected_exception():
- from pecha_api.text_uploader.segments import segment_respository as repo
-
- with patch(
- "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread",
- new_callable=AsyncMock,
- side_effect=RuntimeError("boom"),
- ):
- with pytest.raises(RuntimeError):
- await repo.get_segment_content(
- segment_id=["s1"],
- pecha_text_id="P1",
- openpecha_api_url="https://openpecha.example",
- )
-
-
-@pytest.mark.asyncio
-async def test_post_segments_returns_json_when_ok():
- from pecha_api.text_uploader.segments import segment_respository as repo
-
- response = DummyResponse({"id": "seg_1"}, ok=True)
- with patch(
- "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread",
- new_callable=AsyncMock,
- return_value=response,
- ) as mock_to_thread:
- result = await repo.post_segments(
- segments_payload={"text_id": "t1", "segments": []},
- destination_url="https://dest.example",
- token="tok",
- )
-
- assert result == {"id": "seg_1"}
- call = mock_to_thread.await_args
- assert call.args[0] is repo.requests.post
- assert call.args[1] == "https://dest.example/segments"
- assert call.kwargs["headers"]["Authorization"] == "Bearer tok"
-
-
-@pytest.mark.asyncio
-async def test_post_segments_raises_for_non_ok_response():
- from pecha_api.text_uploader.segments import segment_respository as repo
-
- response = DummyResponse(
- {"detail": "bad"},
- ok=False,
- status_code=400,
- text="bad",
- raise_for_status_exc=requests.exceptions.HTTPError("boom"),
- )
- with patch(
- "pecha_api.text_uploader.segments.segment_respository.asyncio.to_thread",
- new_callable=AsyncMock,
- return_value=response,
- ):
- with pytest.raises(requests.exceptions.HTTPError):
- await repo.post_segments(
- segments_payload={"text_id": "t1", "segments": []},
- destination_url="https://dest.example",
- token="tok",
- )
-
diff --git a/tests/text_uploader/segments/test_segment_service.py b/tests/text_uploader/segments/test_segment_service.py
deleted file mode 100644
index 012f57ff7..000000000
--- a/tests/text_uploader/segments/test_segment_service.py
+++ /dev/null
@@ -1,221 +0,0 @@
-import pytest
-from unittest.mock import AsyncMock, patch
-
-from pecha_api.text_uploader.segments.segment_service import SegmentService
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-
-
-def test_segment_service_parse_segments_content_slices_base_text():
- service = SegmentService()
- base_text = "abcdef"
- segments = [
- {"id": "s1", "span": {"start": 0, "end": 3}},
- {"id": "s2", "span": {"start": 3, "end": 6}},
- ]
-
- parsed = service.parse_segments_content(segments_annotation=segments, base_text=base_text)
- assert parsed == [
- {"segment_id": "s1", "content": "abc"},
- {"segment_id": "s2", "content": "def"},
- ]
-
-
-def test_segment_service_get_annotation_ids_filters_segmentation_only():
- service = SegmentService()
- instance = {
- "annotations": [
- {"type": "topic", "annotation_id": "x"},
- {"type": "segmentation", "annotation_id": "seg_1"},
- {"type": "segmentation", "annotation_id": "seg_2"},
- ]
- }
-
- assert service.get_annotation_ids(instance) == ["seg_1", "seg_2"]
-
-
-@pytest.mark.asyncio
-async def test_segment_service_is_text_segments_uploaded_true_when_any_segments_exist():
- service = SegmentService()
- with patch(
- "pecha_api.text_uploader.segments.segment_service.get_segments_by_text_id",
- new_callable=AsyncMock,
- return_value=[{"id": "1"}],
- ):
- assert await service.is_text_segments_uploaded("t1") is True
-
-
-@pytest.mark.asyncio
-async def test_segment_service_upload_bulk_segments_batches_and_posts():
- service = SegmentService()
- request = TextUploadRequest(
- destination_url="https://dest.example/api/v1",
- openpecha_api_url="https://openpecha.example",
- text_id="T1",
- )
- segments_content = [{"segment_id": f"s{i}", "content": f"c{i}"} for i in range(401)]
-
- with patch(
- "pecha_api.text_uploader.segments.segment_service.post_segments",
- new_callable=AsyncMock,
- return_value={"ok": True},
- ) as mock_post:
- await service.upload_bulk_segments(
- text_id="wb_text_1",
- segments_content=segments_content,
- text_upload_request=request,
- token="tok",
- )
-
- assert mock_post.await_count == 2
- first_payload = mock_post.await_args_list[0].args[0]
- second_payload = mock_post.await_args_list[1].args[0]
- assert first_payload["text_id"] == "wb_text_1"
- assert len(first_payload["segments"]) == 400
- assert len(second_payload["segments"]) == 1
- assert first_payload["segments"][0]["type"] == "source"
-
-
-@pytest.mark.asyncio
-async def test_segment_service_get_segments_by_id_list_calls_repo():
- service = SegmentService()
- request = TextUploadRequest(
- destination_url="https://dest.example/api/v1",
- openpecha_api_url="https://openpecha.example",
- text_id="T1",
- )
-
- with patch(
- "pecha_api.text_uploader.segments.segment_service.get_segments_by_id",
- new_callable=AsyncMock,
- return_value={"data": [{"id": "ann"}]},
- ) as mock_get:
- result = await service.get_segments_by_id_list("ann_1", request)
-
- assert result == {"data": [{"id": "ann"}]}
- mock_get.assert_awaited_once_with("ann_1", "https://openpecha.example")
-
-
-@pytest.mark.asyncio
-async def test_segment_service_upload_segments_skips_when_already_uploaded():
- service = SegmentService()
- request = TextUploadRequest(
- destination_url="https://dest.example/api/v1",
- openpecha_api_url="https://openpecha.example",
- text_id="T1",
- )
-
- with patch.object(
- service, "is_text_segments_uploaded", new_callable=AsyncMock, return_value=True
- ), patch(
- "pecha_api.text_uploader.segments.segment_service.get_segments_annotation",
- new_callable=AsyncMock,
- ) as mock_get_annotation:
- await service.upload_segments(
- text_upload_request=request,
- text_ids={"wb_text_1": "pecha_instance_1"},
- token="tok",
- )
-
- assert mock_get_annotation.called is False
-
-import pytest
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
-
-from pecha_api.text_uploader.segments.segment_service import SegmentService
-
-
-def test_get_annotation_ids_filters_segmentation_only():
- service = SegmentService()
- instance = {
- "annotations": [
- {"type": "note", "annotation_id": "a0"},
- {"type": "segmentation", "annotation_id": "a1"},
- {"type": "segmentation", "annotation_id": "a2"},
- ]
- }
-
- assert service.get_annotation_ids(instance) == ["a1", "a2"]
-
-
-def test_get_annotation_ids_handles_none_annotations():
- service = SegmentService()
- assert service.get_annotation_ids({"annotations": None}) == []
-
-
-def test_parse_segments_content_slices_base_text():
- service = SegmentService()
- base_text = "abcdef"
- segments_annotation = [
- {"id": "s1", "span": {"start": 0, "end": 2}},
- {"id": "s2", "span": {"start": 2, "end": 6}},
- ]
-
- assert service.parse_segments_content(segments_annotation, base_text) == [
- {"segment_id": "s1", "content": "ab"},
- {"segment_id": "s2", "content": "cdef"},
- ]
-
-
-@pytest.mark.asyncio
-async def test_is_text_segments_uploaded_true_when_any_segments_exist():
- service = SegmentService()
- with patch(
- "pecha_api.text_uploader.segments.segment_service.get_segments_by_text_id",
- new_callable=AsyncMock,
- return_value=[{"id": "seg_1"}],
- ):
- assert await service.is_text_segments_uploaded("text_1") is True
-
-
-@pytest.mark.asyncio
-async def test_upload_bulk_segments_batches_and_posts_payload():
- service = SegmentService()
- text_upload_request = SimpleNamespace(destination_url="https://dest.example")
- token = "t0k3n"
-
- # 401 segments -> two batches (400 + 1)
- segments_content = [
- {"segment_id": f"s{i}", "content": f"c{i}"} for i in range(401)
- ]
-
- with patch(
- "pecha_api.text_uploader.segments.segment_service.post_segments",
- new_callable=AsyncMock,
- return_value={"ok": True},
- ) as mock_post_segments:
- await service.upload_bulk_segments(
- text_id="text_1",
- segments_content=segments_content,
- text_upload_request=text_upload_request,
- token=token,
- )
-
- assert mock_post_segments.await_count == 2
-
- first_payload = mock_post_segments.await_args_list[0].args[0]
- second_payload = mock_post_segments.await_args_list[1].args[0]
-
- assert first_payload["text_id"] == "text_1"
- assert len(first_payload["segments"]) == 400
- assert first_payload["segments"][0] == {
- "pecha_segment_id": "s0",
- "content": "c0",
- "type": "source",
- }
-
- assert second_payload["text_id"] == "text_1"
- assert len(second_payload["segments"]) == 1
- assert second_payload["segments"][0] == {
- "pecha_segment_id": "s400",
- "content": "c400",
- "type": "source",
- }
-
- # Verify destination/token plumbing (payload, destination_url, token)
- assert mock_post_segments.await_args_list[0].args[1:] == (
- "https://dest.example",
- token,
- )
-
-
diff --git a/tests/text_uploader/test_pipeline.py b/tests/text_uploader/test_pipeline.py
deleted file mode 100644
index aa97b258a..000000000
--- a/tests/text_uploader/test_pipeline.py
+++ /dev/null
@@ -1,158 +0,0 @@
-import pytest
-from fastapi import HTTPException
-from unittest.mock import AsyncMock, patch
-
-from pecha_api.error_contants import ErrorConstants
-from pecha_api.text_uploader.pipeline import pipeline
-from pecha_api.text_uploader.text_metadata.text_metadata_model import TextInstanceIds
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-
-
-@pytest.mark.asyncio
-async def test_pipeline_raises_403_when_not_admin():
- request = TextUploadRequest(
- destination_url="LOCAL",
- openpecha_api_url="DEVELOPMENT",
- text_id="T1",
- )
-
- with patch("pecha_api.text_uploader.pipeline.verify_admin_access", return_value=False):
- with pytest.raises(HTTPException) as exc:
- await pipeline(text_upload_request=request, token="tok")
-
- assert exc.value.status_code == 403
- assert exc.value.detail == ErrorConstants.ADMIN_ERROR_MESSAGE
-
-
-@pytest.mark.asyncio
-async def test_pipeline_runs_segment_toc_and_returns_new_texts_when_any_new():
- request = TextUploadRequest(
- destination_url="LOCAL",
- openpecha_api_url="DEVELOPMENT",
- text_id="T1",
- )
- instance_ids = TextInstanceIds(
- new_text={"wb_text_1": "pecha_instance_1"},
- all_text={"T1": "pecha_instance_1"},
- )
-
- with patch("pecha_api.text_uploader.pipeline.verify_admin_access", return_value=True), patch(
- "pecha_api.text_uploader.pipeline.CollectionService"
- ) as MockCollectionService, patch(
- "pecha_api.text_uploader.pipeline.TextMetadataService"
- ) as MockTextMetadataService, patch(
- "pecha_api.text_uploader.pipeline.SegmentService"
- ) as MockSegmentService, patch(
- "pecha_api.text_uploader.pipeline.TocService"
- ) as MockTocService, patch(
- "pecha_api.text_uploader.pipeline.MappingService"
- ) as MockMappingService:
- MockCollectionService.return_value.upload_collections = AsyncMock()
- MockTextMetadataService.return_value.upload_text_metadata_service = AsyncMock(
- return_value=instance_ids
- )
- MockSegmentService.return_value.upload_segments = AsyncMock()
- MockTocService.return_value.upload_toc = AsyncMock()
- MockMappingService.return_value.trigger_mapping = AsyncMock()
-
- response = await pipeline(text_upload_request=request, token="tok")
-
- assert response.message == {"wb_text_1": "pecha_instance_1"}
-
- # Ensure services are wired with the resolved destination/openpecha URLs.
- payload = MockCollectionService.return_value.upload_collections.await_args.kwargs[
- "text_upload_request"
- ]
- assert payload.destination_url.startswith("http")
- assert payload.openpecha_api_url.startswith("https://")
- assert payload.text_id == "T1"
-
- MockSegmentService.return_value.upload_segments.assert_awaited_once()
- MockTocService.return_value.upload_toc.assert_awaited_once()
- MockMappingService.return_value.trigger_mapping.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_pipeline_skips_segment_and_toc_when_no_new_texts():
- request = TextUploadRequest(
- destination_url="LOCAL",
- openpecha_api_url="DEVELOPMENT",
- text_id="T1",
- )
- instance_ids = TextInstanceIds(new_text={}, all_text={"T1": "pecha_instance_1"})
-
- with patch("pecha_api.text_uploader.pipeline.verify_admin_access", return_value=True), patch(
- "pecha_api.text_uploader.pipeline.CollectionService"
- ) as MockCollectionService, patch(
- "pecha_api.text_uploader.pipeline.TextMetadataService"
- ) as MockTextMetadataService, patch(
- "pecha_api.text_uploader.pipeline.SegmentService"
- ) as MockSegmentService, patch(
- "pecha_api.text_uploader.pipeline.TocService"
- ) as MockTocService, patch(
- "pecha_api.text_uploader.pipeline.MappingService"
- ) as MockMappingService:
- MockCollectionService.return_value.upload_collections = AsyncMock()
- MockTextMetadataService.return_value.upload_text_metadata_service = AsyncMock(
- return_value=instance_ids
- )
- MockMappingService.return_value.trigger_mapping = AsyncMock()
-
- response = await pipeline(text_upload_request=request, token="tok")
-
- assert response.message == "All texts are already uploaded"
- assert MockSegmentService.called is False
- assert MockTocService.called is False
- MockMappingService.return_value.trigger_mapping.assert_not_awaited()
-
- payload = MockCollectionService.return_value.upload_collections.await_args.kwargs[
- "text_upload_request"
- ]
- # Mapping is intentionally skipped when destination_url is LOCAL.
-
-
-@pytest.mark.asyncio
-async def test_pipeline_triggers_mapping_when_destination_is_not_local():
- request = TextUploadRequest(
- destination_url="DEVELOPMENT",
- openpecha_api_url="DEVELOPMENT",
- text_id="T1",
- )
- instance_ids = TextInstanceIds(
- new_text={"wb_text_1": "pecha_instance_1"},
- all_text={"T1": "pecha_instance_1"},
- )
-
- with patch("pecha_api.text_uploader.pipeline.verify_admin_access", return_value=True), patch(
- "pecha_api.text_uploader.pipeline.CollectionService"
- ) as MockCollectionService, patch(
- "pecha_api.text_uploader.pipeline.TextMetadataService"
- ) as MockTextMetadataService, patch(
- "pecha_api.text_uploader.pipeline.SegmentService"
- ) as MockSegmentService, patch(
- "pecha_api.text_uploader.pipeline.TocService"
- ) as MockTocService, patch(
- "pecha_api.text_uploader.pipeline.MappingService"
- ) as MockMappingService:
- MockCollectionService.return_value.upload_collections = AsyncMock()
- MockTextMetadataService.return_value.upload_text_metadata_service = AsyncMock(
- return_value=instance_ids
- )
- MockSegmentService.return_value.upload_segments = AsyncMock()
- MockTocService.return_value.upload_toc = AsyncMock()
- MockMappingService.return_value.trigger_mapping = AsyncMock()
-
- response = await pipeline(text_upload_request=request, token="tok")
-
- assert response.message == {"wb_text_1": "pecha_instance_1"}
- MockMappingService.return_value.trigger_mapping.assert_awaited_once()
-
- trigger_kwargs = MockMappingService.return_value.trigger_mapping.await_args.kwargs
- assert trigger_kwargs["text_ids"] == {"T1": "pecha_instance_1"}
-
- called_payload = trigger_kwargs["text_upload_request"]
- # Mapping is triggered with the original (enum) request values, not the resolved URL payload.
- assert called_payload.destination_url == "DEVELOPMENT"
- assert called_payload.openpecha_api_url == "DEVELOPMENT"
- assert called_payload.text_id == "T1"
-
diff --git a/tests/text_uploader/test_text_uploader_views.py b/tests/text_uploader/test_text_uploader_views.py
deleted file mode 100644
index a3b4b2042..000000000
--- a/tests/text_uploader/test_text_uploader_views.py
+++ /dev/null
@@ -1,181 +0,0 @@
-from fastapi.testclient import TestClient
-from unittest.mock import patch, AsyncMock
-import pytest
-
-from pecha_api.app import api
-from pecha_api.error_contants import ErrorConstants
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadResponse
-
-client = TestClient(api)
-
-
-class TestTextUploaderViews:
- """Test cases for text uploader views."""
-
- @pytest.fixture
- def mock_text_upload_request(self):
- """Fixture for text upload request data."""
- return {
- "destination_url": "DEVELOPMENT",
- "openpecha_api_url": "DEVELOPMENT",
- "text_id": "test_text_123"
- }
-
- @pytest.fixture
- def admin_token(self):
- """Fixture for admin authentication token."""
- return "Bearer admin_test_token"
-
- @pytest.fixture
- def non_admin_token(self):
- """Fixture for non-admin authentication token."""
- return "Bearer non_admin_test_token"
-
- def test_upload_text_success_with_admin_token(self, mock_text_upload_request, admin_token):
- """Test successful text upload with admin token."""
- with patch("pecha_api.text_uploader.text_uploader_views.pipeline", new_callable=AsyncMock) as mock_pipeline:
- mock_pipeline.return_value = TextUploadResponse(message={"text_1": "instance_1"})
-
- response = client.post(
- "/text-uploader",
- json=mock_text_upload_request,
- headers={"Authorization": admin_token}
- )
-
- assert response.status_code == 200
- assert response.json() == {"message": {"text_1": "instance_1"}}
- mock_pipeline.assert_called_once()
-
- def test_upload_text_forbidden_with_non_admin_token(self, mock_text_upload_request, non_admin_token):
- """Test text upload fails with non-admin token (403 Forbidden)."""
- with patch("pecha_api.text_uploader.pipeline.verify_admin_access") as mock_verify_admin:
- mock_verify_admin.return_value = False
-
- response = client.post(
- "/text-uploader",
- json=mock_text_upload_request,
- headers={"Authorization": non_admin_token}
- )
-
- assert response.status_code == 403
- assert response.json()["detail"] == ErrorConstants.ADMIN_ERROR_MESSAGE
-
- def test_upload_text_unauthorized_without_token(self, mock_text_upload_request):
- """Test text upload fails without authentication token (401 Unauthorized)."""
- response = client.post(
- "/text-uploader",
- json=mock_text_upload_request
- )
-
- assert response.status_code == 403
-
- def test_upload_text_invalid_request_missing_destination_url(self, admin_token):
- """Test text upload fails with missing destination_url."""
- invalid_request = {
- "openpecha_api_url": "DEVELOPMENT",
- "text_id": "test_text_123"
- }
-
- response = client.post(
- "/text-uploader",
- json=invalid_request,
- headers={"Authorization": admin_token}
- )
-
- assert response.status_code == 422
-
- def test_upload_text_invalid_request_missing_openpecha_api_url(self, admin_token):
- """Test text upload fails with missing openpecha_api_url."""
- invalid_request = {
- "destination_url": "DEVELOPMENT",
- "text_id": "test_text_123"
- }
-
- response = client.post(
- "/text-uploader",
- json=invalid_request,
- headers={"Authorization": admin_token}
- )
-
- assert response.status_code == 422
-
- def test_upload_text_invalid_request_missing_text_id(self, admin_token):
- """Test text upload fails with missing text_id."""
- invalid_request = {
- "destination_url": "DEVELOPMENT",
- "openpecha_api_url": "DEVELOPMENT"
- }
-
- response = client.post(
- "/text-uploader",
- json=invalid_request,
- headers={"Authorization": admin_token}
- )
-
- assert response.status_code == 422
-
- def test_upload_text_empty_request_body(self, admin_token):
- """Test text upload fails with empty request body."""
- response = client.post(
- "/text-uploader",
- json={},
- headers={"Authorization": admin_token}
- )
-
- assert response.status_code == 422
-
- def test_upload_text_pipeline_exception_handling(self, mock_text_upload_request, admin_token):
- """Test that exceptions from pipeline are properly propagated."""
- with patch("pecha_api.text_uploader.text_uploader_views.pipeline", new_callable=AsyncMock) as mock_pipeline:
- mock_pipeline.side_effect = Exception("Pipeline processing failed")
-
- with pytest.raises(Exception) as exc_info:
- response = client.post(
- "/text-uploader",
- json=mock_text_upload_request,
- headers={"Authorization": admin_token}
- )
-
- assert str(exc_info.value) == "Pipeline processing failed"
-
- def test_upload_text_with_special_characters_in_text_id(self, admin_token):
- """Test text upload with special characters in text_id."""
- request_with_special_chars = {
- "destination_url": "DEVELOPMENT",
- "openpecha_api_url": "DEVELOPMENT",
- "text_id": "test_text_!@#$%^&*()"
- }
-
- with patch("pecha_api.text_uploader.text_uploader_views.pipeline", new_callable=AsyncMock) as mock_pipeline:
- mock_pipeline.return_value = TextUploadResponse(message={"text_special": "instance_special"})
-
- response = client.post(
- "/text-uploader",
- json=request_with_special_chars,
- headers={"Authorization": admin_token}
- )
-
- assert response.status_code == 200
- assert response.json() == {"message": {"text_special": "instance_special"}}
-
- def test_upload_text_with_extra_fields(self, admin_token):
- """Test text upload with extra fields in request (should be ignored by Pydantic)."""
- request_with_extra_fields = {
- "destination_url": "DEVELOPMENT",
- "openpecha_api_url": "DEVELOPMENT",
- "text_id": "test_text_123",
- "extra_field": "should_be_ignored"
- }
-
- with patch("pecha_api.text_uploader.text_uploader_views.pipeline", new_callable=AsyncMock) as mock_pipeline:
- mock_pipeline.return_value = TextUploadResponse(message={"text_extra": "instance_extra"})
-
- response = client.post(
- "/text-uploader",
- json=request_with_extra_fields,
- headers={"Authorization": admin_token}
- )
-
- assert response.status_code == 200
- assert response.json() == {"message": {"text_extra": "instance_extra"}}
-
diff --git a/tests/text_uploader/texts/__init__.py b/tests/text_uploader/texts/__init__.py
deleted file mode 100644
index cc5244580..000000000
--- a/tests/text_uploader/texts/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Text-related tests for text uploader."""
-
-
-
-
-
diff --git a/tests/text_uploader/texts/test_text_metadata_service.py b/tests/text_uploader/texts/test_text_metadata_service.py
deleted file mode 100644
index 7781ed6d5..000000000
--- a/tests/text_uploader/texts/test_text_metadata_service.py
+++ /dev/null
@@ -1,573 +0,0 @@
-import pytest
-from unittest.mock import AsyncMock, patch, MagicMock
-
-from pecha_api.text_uploader.text_metadata.text_metadata_service import TextMetadataService
-from pecha_api.text_uploader.text_metadata.text_metadata_model import (
- CriticalInstance,
- CriticalInstanceResponse,
- TextInstanceIds
-)
-from pecha_api.text_uploader.text_uploader_response_model import TextUploadRequest
-
-
-@pytest.mark.asyncio
-async def test_create_textmetada_payload_version_sets_group_and_category_from_collection():
- service = TextMetadataService()
- service.version_group_id = "group_v1"
-
- text_metadata = {
- "language": "en",
- "title": {"en": "A Title"},
- "category_id": "pecha_cat_1",
- "isPublished": True,
- "views": 12,
- "ranking": 3,
- "license": "CC0",
- }
-
- text_upload_request = TextUploadRequest(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- text_id="t1"
- )
-
- with patch.object(
- service,
- "get_text_critical_instance",
- new_callable=AsyncMock,
- return_value=CriticalInstanceResponse(
- critical_instances=[
- CriticalInstance(
- id="inst_1",
- type="critical",
- source="https://source.example",
- alt_incipit_titles=None,
- )
- ]
- ),
- ), patch.object(
- service,
- "get_wb_collection_id",
- new_callable=AsyncMock,
- return_value="wb_cat_1"
- ):
- payload = await service.create_textmetada_payload(
- text_id="t1",
- text_metadata=text_metadata,
- type="version",
- text_upload_request=text_upload_request
- )
-
- assert payload.pecha_text_id == "inst_1"
- assert payload.title == "A Title"
- assert payload.language == "en"
- assert payload.group_id == "group_v1"
- assert payload.categories == ["wb_cat_1"]
- assert payload.type == "version"
- assert payload.source_link == "https://source.example"
-
-
-@pytest.mark.asyncio
-async def test_create_textmetada_payload_commentary_sets_group_and_category_from_version_group():
- service = TextMetadataService()
- service.version_group_id = "group_v1"
- service.commentary_group_id = "group_c1"
-
- text_metadata = {
- "language": "bo",
- "title": {"bo": "མཚན་བྱང་"},
- }
-
- text_upload_request = TextUploadRequest(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- text_id="t_commentary"
- )
-
- with patch.object(
- service,
- "get_text_critical_instance",
- new_callable=AsyncMock,
- return_value=CriticalInstanceResponse(
- critical_instances=[
- CriticalInstance(
- id="inst_c1",
- type="critical",
- source="s",
- alt_incipit_titles=None,
- )
- ]
- ),
- ):
- payload = await service.create_textmetada_payload(
- text_id="t_commentary",
- text_metadata=text_metadata,
- type="commentary",
- text_upload_request=text_upload_request
- )
-
- assert payload.group_id == "group_c1"
- assert payload.categories == ["group_v1"]
- assert payload.type == "commentary"
-
-
-@pytest.mark.asyncio
-async def test_get_uploaded_texts_returns_uploaded_text_ids_and_instances_mapping():
- service = TextMetadataService()
-
- async def _get_critical_instance_side_effect(text_id: str, openpecha_api_url: str):
- instance_id = {"t1": "inst_1", "t2": "inst_2"}[text_id]
- return CriticalInstanceResponse(
- critical_instances=[
- CriticalInstance(
- id=instance_id,
- type="critical",
- source="src",
- alt_incipit_titles=None,
- )
- ]
- )
-
- # Only inst_2 is already uploaded in the destination DB
- uploaded_text_row = {"pecha_text_id": "inst_2", "group_id": "g"}
-
- with patch.object(
- service,
- "get_text_critical_instance",
- new_callable=AsyncMock,
- side_effect=_get_critical_instance_side_effect,
- ), patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_texts_by_pecha_text_ids",
- new_callable=AsyncMock,
- return_value=[uploaded_text_row],
- ):
- texts, uploaded_text_ids, instances = await service.get_uploaded_texts(
- text_ids=["t1", "t2"],
- openpecha_api_url="https://openpecha.example",
- destination_url="https://destination.example"
- )
-
- assert texts == [uploaded_text_row]
- assert uploaded_text_ids == ["t2"]
- assert instances == {"t1": "inst_1", "t2": "inst_2"}
-
-
-@pytest.mark.asyncio
-async def test_get_wb_collection_id_raises_when_collection_missing():
- service = TextMetadataService()
- with patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_collection_by_pecha_collection_id",
- new_callable=AsyncMock,
- return_value=None,
- ):
- with pytest.raises(
- ValueError, match="Collection with pecha_collection_id pecha_missing not found"
- ):
- await service.get_wb_collection_id(
- pecha_collection_id="pecha_missing",
- destination_url="https://destination.example"
- )
-
-
-@pytest.mark.asyncio
-async def test_get_wb_collection_id_returns_collection_id():
- """Test get_wb_collection_id returns the collection ID when found"""
- service = TextMetadataService()
-
- with patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_collection_by_pecha_collection_id",
- new_callable=AsyncMock,
- return_value="wb_collection_123"
- ):
- result = await service.get_wb_collection_id(
- pecha_collection_id="pecha_collection_456",
- destination_url="https://destination.example"
- )
-
- assert result == "wb_collection_123"
-
-
-@pytest.mark.asyncio
-async def test_upload_text_metadata_service_success():
- """Test upload_text_metadata_service with translation texts"""
- service = TextMetadataService()
-
- text_upload_request = TextUploadRequest(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- text_id="text_1"
- )
-
- text_metadata = {
- "type": "translation",
- "language": "en",
- "title": {"en": "Test Text"}
- }
-
- text_related_by_work = {
- "work_1": {
- "relation": "translation",
- "expression_ids": ["text_2", "text_3"]
- }
- }
-
- with patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata",
- new_callable=AsyncMock,
- return_value=text_metadata
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_related_by_work",
- new_callable=AsyncMock,
- return_value=text_related_by_work
- ), \
- patch.object(
- service,
- "get_text_meta_data_service",
- new_callable=AsyncMock,
- return_value={"new_text_id_1": "inst_1", "new_text_id_2": "inst_2"}
- ) as mock_get_texts:
- result = await service.upload_text_metadata_service(
- text_upload_request=text_upload_request,
- token="test_token"
- )
-
- assert isinstance(result, TextInstanceIds)
- assert len(result.new_text) > 0
- # Verify the service was called for both translations and commentaries
- assert mock_get_texts.call_count == 2
- # all_text reflects only what the real method accumulated;
- # since get_text_meta_data_service is fully mocked here, all_instance_ids stays {}
- assert result.all_text == {}
-
-
-@pytest.mark.asyncio
-async def test_upload_text_metadata_service_with_commentary():
- """Test upload_text_metadata_service with commentary texts"""
- service = TextMetadataService()
-
- text_upload_request = TextUploadRequest(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- text_id="commentary_1"
- )
-
- text_metadata = {
- "type": "commentary",
- "language": "bo",
- "title": {"bo": "མཚན་བྱང་"}
- }
-
- text_related_by_work = {
- "work_1": {
- "relation": "sibling_commentary",
- "expression_ids": ["commentary_2"]
- }
- }
-
- with patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata",
- new_callable=AsyncMock,
- return_value=text_metadata
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_related_by_work",
- new_callable=AsyncMock,
- return_value=text_related_by_work
- ), \
- patch.object(
- service,
- "get_text_meta_data_service",
- new_callable=AsyncMock,
- return_value={"commentary_id_1": "inst_c1"}
- ):
- result = await service.upload_text_metadata_service(
- text_upload_request=text_upload_request,
- token="test_token"
- )
-
- assert isinstance(result, TextInstanceIds)
- assert result.new_text is not None
-
-
-@pytest.mark.asyncio
-async def test_get_text_meta_data_service_creates_group_for_first_text():
- """Test get_text_meta_data_service creates a new group for translations"""
- service = TextMetadataService()
-
- text_upload_request = TextUploadRequest(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- text_id="text_1"
- )
-
- text_metadata = {
- "language": "en",
- "title": {"en": "Test Text"},
- "category_id": "cat_1"
- }
-
- critical_instance = CriticalInstanceResponse(
- critical_instances=[
- CriticalInstance(
- id="inst_1",
- type="critical",
- source="https://source.example",
- alt_incipit_titles=None,
- )
- ]
- )
-
- with patch.object(
- service,
- "get_uploaded_texts",
- new_callable=AsyncMock,
- return_value=([], [], {"text_1": "inst_1"})
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata",
- new_callable=AsyncMock,
- return_value=text_metadata
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.post_group",
- new_callable=AsyncMock,
- return_value={"id": "new_group_id"}
- ) as mock_post_group, \
- patch.object(
- service,
- "create_textmetada_payload",
- new_callable=AsyncMock,
- return_value=MagicMock()
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.post_text",
- new_callable=AsyncMock,
- return_value={"id": "new_text_id", "title": "Test Text"}
- ):
- result = await service.get_text_meta_data_service(
- text_ids=["text_1"],
- type="translation",
- text_upload_request=text_upload_request,
- token="test_token"
- )
-
- mock_post_group.assert_awaited_once()
- assert service.version_group_id == "new_group_id"
- assert "new_text_id" in result
- assert service.all_instance_ids == {"text_1": "inst_1"}
-
-
-@pytest.mark.asyncio
-async def test_get_text_meta_data_service_skips_uploaded_texts():
- """Test get_text_meta_data_service skips already uploaded texts"""
- service = TextMetadataService()
-
- text_upload_request = TextUploadRequest(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- text_id="text_1"
- )
-
- text_metadata = {
- "language": "en",
- "title": {"en": "Already Uploaded Text"},
- "category_id": "cat_1"
- }
-
- # Mock that text_1 is already uploaded
- with patch.object(
- service,
- "get_uploaded_texts",
- new_callable=AsyncMock,
- return_value=(
- [{"id": "existing_id", "pecha_text_id": "inst_1", "group_id": "existing_group_id"}],
- ["text_1"], # text_1 is already uploaded
- {"text_1": "inst_1"}
- )
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata",
- new_callable=AsyncMock,
- return_value=text_metadata
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.post_text",
- new_callable=AsyncMock
- ) as mock_post_text:
- result = await service.get_text_meta_data_service(
- text_ids=["text_1"],
- type="translation",
- text_upload_request=text_upload_request,
- token="test_token"
- )
-
- # Should not post text since it's already uploaded
- mock_post_text.assert_not_awaited()
- assert result == {}
- # Version group should be set from uploaded text
- assert service.version_group_id == "existing_group_id"
- assert service.all_instance_ids == {"text_1": "inst_1"}
-
-
-@pytest.mark.asyncio
-async def test_get_text_critical_instance_returns_critical_instances():
- """Test get_text_critical_instance returns CriticalInstanceResponse"""
- service = TextMetadataService()
-
- expected_instances = [
- CriticalInstance(
- id="inst_1",
- type="critical",
- source="https://source1.example",
- alt_incipit_titles=None,
- ),
- CriticalInstance(
- id="inst_2",
- type="critical",
- source="https://source2.example",
- alt_incipit_titles=None,
- )
- ]
-
- with patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_critical_instances",
- new_callable=AsyncMock,
- return_value=CriticalInstanceResponse(critical_instances=expected_instances)
- ):
- result = await service.get_text_critical_instance(
- text_id="text_1",
- openpecha_api_url="https://openpecha.example"
- )
-
- assert isinstance(result, CriticalInstanceResponse)
- assert len(result.critical_instances) == 2
- assert result.critical_instances[0].id == "inst_1"
-
-
-@pytest.mark.asyncio
-async def test_get_text_meta_data_service_commentary_creates_separate_group():
- """Test get_text_meta_data_service creates separate group for commentary"""
- service = TextMetadataService()
- service.version_group_id = "version_group_123"
-
- text_upload_request = TextUploadRequest(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- text_id="commentary_1"
- )
-
- text_metadata = {
- "language": "bo",
- "title": {"bo": "མཚན་བྱང་"},
- "category_id": "cat_1"
- }
-
- with patch.object(
- service,
- "get_uploaded_texts",
- new_callable=AsyncMock,
- return_value=([], [], {"commentary_1": "inst_c1"})
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata",
- new_callable=AsyncMock,
- return_value=text_metadata
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.post_group",
- new_callable=AsyncMock,
- return_value={"id": "commentary_group_id"}
- ) as mock_post_group, \
- patch.object(
- service,
- "create_textmetada_payload",
- new_callable=AsyncMock,
- return_value=MagicMock()
- ), \
- patch(
- "pecha_api.text_uploader.text_metadata.text_metadata_service.post_text",
- new_callable=AsyncMock,
- return_value={"id": "commentary_text_id", "title": "Commentary"}
- ):
- result = await service.get_text_meta_data_service(
- text_ids=["commentary_1"],
- type="commentary",
- text_upload_request=text_upload_request,
- token="test_token"
- )
-
- # Should create commentary group
- mock_post_group.assert_awaited_once_with('commentary', text_upload_request.destination_url, "test_token")
- assert service.commentary_group_id == "commentary_group_id"
- assert "commentary_text_id" in result
- assert service.all_instance_ids == {"commentary_1": "inst_c1"}
-
-
-@pytest.mark.asyncio
-async def test_all_instance_ids_merges_translation_and_commentary_instances():
- """all_instance_ids must accumulate across both translation and commentary calls.
-
- The old bug used `=` (overwrite) instead of `.update()`, so only the last
- batch's instances survived. With the fix, both batches are merged.
- """
- service = TextMetadataService()
- service.version_group_id = "version_group_123"
-
- text_upload_request = TextUploadRequest(
- destination_url="https://destination.example",
- openpecha_api_url="https://openpecha.example",
- text_id="text_1"
- )
-
- translation_instances = {"text_1": "inst_t1", "text_2": "inst_t2"}
- commentary_instances = {"commentary_1": "inst_c1"}
-
- translation_metadata = {"language": "en", "title": {"en": "T"}, "category_id": "cat_1"}
- commentary_metadata = {"language": "bo", "title": {"bo": "C"}, "category_id": "cat_1"}
-
- # ── First call: translations ──────────────────────────────────────────────
- with patch.object(service, "get_uploaded_texts", new_callable=AsyncMock,
- return_value=([], [], translation_instances)), \
- patch("pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata",
- new_callable=AsyncMock, return_value=translation_metadata), \
- patch("pecha_api.text_uploader.text_metadata.text_metadata_service.post_group",
- new_callable=AsyncMock, return_value={"id": "grp_t"}), \
- patch.object(service, "create_textmetada_payload",
- new_callable=AsyncMock, return_value=MagicMock()), \
- patch("pecha_api.text_uploader.text_metadata.text_metadata_service.post_text",
- new_callable=AsyncMock, return_value={"id": "t_id", "title": "T"}):
- await service.get_text_meta_data_service(
- text_ids=["text_1", "text_2"],
- type="translation",
- text_upload_request=text_upload_request,
- token="test_token"
- )
-
- assert service.all_instance_ids == translation_instances
-
- # ── Second call: commentaries ─────────────────────────────────────────────
- with patch.object(service, "get_uploaded_texts", new_callable=AsyncMock,
- return_value=([], [], commentary_instances)), \
- patch("pecha_api.text_uploader.text_metadata.text_metadata_service.get_text_metadata",
- new_callable=AsyncMock, return_value=commentary_metadata), \
- patch("pecha_api.text_uploader.text_metadata.text_metadata_service.post_group",
- new_callable=AsyncMock, return_value={"id": "grp_c"}), \
- patch.object(service, "create_textmetada_payload",
- new_callable=AsyncMock, return_value=MagicMock()), \
- patch("pecha_api.text_uploader.text_metadata.text_metadata_service.post_text",
- new_callable=AsyncMock, return_value={"id": "c_id", "title": "C"}):
- await service.get_text_meta_data_service(
- text_ids=["commentary_1"],
- type="commentary",
- text_upload_request=text_upload_request,
- token="test_token"
- )
-
- # Both translation AND commentary instances must be present.
- # The old `self.all_instance_ids = instances` (overwrite) would have left
- # only {"commentary_1": "inst_c1"} here, missing the 2 translation entries.
- expected = {**translation_instances, **commentary_instances}
- assert service.all_instance_ids == expected
-
-
diff --git a/tests/text_uploader/texts/test_toc_service.py b/tests/text_uploader/texts/test_toc_service.py
deleted file mode 100644
index b48a3ff23..000000000
--- a/tests/text_uploader/texts/test_toc_service.py
+++ /dev/null
@@ -1,105 +0,0 @@
-import pytest
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
-
-from pecha_api.text_uploader.table_of_content.toc_service import TocService
-
-
-@pytest.mark.asyncio
-async def test_order_segments_by_annotation_span_sorts_and_numbers():
- service = TocService()
- annotation_segments = {
- "data": [
- {"id": "s2", "span": {"start": 5, "end": 7}},
- {"id": "s1", "span": {"start": 0, "end": 2}},
- {"id": "s3", "span": {"start": 8, "end": 9}},
- ]
- }
-
- result = await service.order_segments_by_annotation_span(annotation_segments)
-
- assert result == [
- {"segment_id": "s1", "segment_number": 1},
- {"segment_id": "s2", "segment_number": 2},
- {"segment_id": "s3", "segment_number": 3},
- ]
-
-
-def test_create_toc_payload_uses_uuid_and_wraps_segments():
- service = TocService()
- ordered_segments = [
- {"segment_id": "s1", "segment_number": 1},
- {"segment_id": "s2", "segment_number": 2},
- ]
-
- with patch(
- "pecha_api.text_uploader.table_of_content.toc_service.uuid.uuid4",
- return_value="fixed-uuid",
- ):
- payload = service.create_toc_payload(ordered_segments, text_id="t1")
-
- assert payload == {
- "text_id": "t1",
- "type": "text",
- "sections": [
- {
- "id": "fixed-uuid",
- "title": "1",
- "section_number": 1,
- "segments": ordered_segments,
- }
- ],
- }
-
-
-@pytest.mark.asyncio
-async def test_upload_toc_builds_payload_and_posts():
- service = TocService()
- text_upload_request = SimpleNamespace(
- destination_url="https://dest.example",
- openpecha_api_url="https://openpecha.example",
- text_id="ignored",
- )
-
- # Mocks for SegmentService instance methods used by TocService
- service.segment_service.get_segments_annotation_by_pecha_text_id = AsyncMock(
- return_value={"annotations": [{"type": "segmentation", "annotation_id": "ann_1"}]}
- )
- # SegmentService.get_annotation_ids is synchronous.
- service.segment_service.get_annotation_ids = lambda _instance: ["ann_1"]
- service.segment_service.get_segments_by_id_list = AsyncMock(
- return_value={
- "data": [
- {"id": "s2", "span": {"start": 2, "end": 3}},
- {"id": "s1", "span": {"start": 0, "end": 1}},
- ]
- }
- )
-
- with patch(
- "pecha_api.text_uploader.table_of_content.toc_service.uuid.uuid4",
- return_value="fixed-uuid",
- ), patch(
- "pecha_api.text_uploader.table_of_content.toc_service.post_toc",
- new_callable=AsyncMock,
- return_value={"ok": True},
- ) as mock_post_toc:
- await service.upload_toc(
- text_ids={"local_text_1": "pecha_text_1"},
- text_upload_request=text_upload_request,
- token="tok",
- )
-
- mock_post_toc.assert_awaited_once()
- posted_payload, posted_destination, posted_token = mock_post_toc.await_args.args
-
- assert posted_destination == "https://dest.example"
- assert posted_token == "tok"
- assert posted_payload["text_id"] == "local_text_1"
- assert posted_payload["sections"][0]["id"] == "fixed-uuid"
- assert posted_payload["sections"][0]["segments"] == [
- {"segment_id": "s1", "segment_number": 1},
- {"segment_id": "s2", "segment_number": 2},
- ]
-
-
diff --git a/tests/texts/mappings/__init__.py b/tests/texts/mappings/__init__.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/tests/texts/mappings/test_delete_mappings.py b/tests/texts/mappings/test_delete_mappings.py
deleted file mode 100644
index 3f9f3d5c4..000000000
--- a/tests/texts/mappings/test_delete_mappings.py
+++ /dev/null
@@ -1,157 +0,0 @@
-import json
-import pytest
-from unittest.mock import patch, MagicMock
-
-from fastapi.testclient import TestClient
-from fastapi import HTTPException, status
-from fastapi.routing import APIRoute
-
-from pecha_api.app import api
-from pecha_api.texts.segments.segments_response_models import SegmentResponse, MappingResponse, SegmentDTO
-from pecha_api.error_contants import ErrorConstants
-from pecha_api.texts.mappings.mappings_response_models import TextMappingRequest
-
-client = TestClient(api)
-
-# Test data
-text_mapping_delete_request = {
- "text_mappings": [
- {
- "text_id": "2ff4215e-bc9e-4d16-8d7e-b4adea3c6ef9",
- "segment_id": "cce14575-ebc3-43aa-bcce-777676f3b2e2",
- "mappings": [
- {
- "parent_text_id": "e55d66bc-0b2c-4575-afe1-c357856b1592",
- "segments": [
- "5bbe24b9-625e-41bf-b6aa-a949f26a7c05",
- "83311e49-7e8b-413d-95c3-80d2cdea5158"
- ]
- }
- ]
- }
- ]
-}
-
-@pytest.mark.asyncio
-@patch("pecha_api.texts.mappings.mappings_views.delete_segment_mapping")
-async def test_delete_text_mapping_success(mock_delete_mapping):
- # Arrange
- test_token = "test_token"
- # The delete_segment_mapping function returns None for successful deletion
- mock_delete_mapping.return_value = None
-
- # We need to directly test the endpoint handler function rather than using the TestClient
- # for DELETE requests with a body
- from pecha_api.texts.mappings.mappings_views import delete_text_mapping
-
- # Create a mock request object
- request = TextMappingRequest(**text_mapping_delete_request)
- auth_credentials = MagicMock()
- auth_credentials.credentials = test_token
-
- # Call the endpoint handler directly
- result = await delete_text_mapping(auth_credentials, request)
-
- # Assert
- assert result is None
- mock_delete_mapping.assert_called_once_with(token=test_token, text_mapping_request=request)
-
-def test_delete_text_mapping_unauthorized():
- # For unauthorized test, we can use the TestClient without a body
- # since we're just testing the auth middleware
- response = client.delete(
- "/mappings"
- )
-
- # Assert
- assert response.status_code == 403
-
-@pytest.mark.asyncio
-@patch("pecha_api.texts.mappings.mappings_views.delete_segment_mapping")
-async def test_delete_text_mapping_forbidden(mock_delete_mapping):
- # Arrange
- mock_delete_mapping.side_effect = HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=ErrorConstants.ADMIN_ERROR_MESSAGE
- )
-
- # Test directly with the handler function
- from pecha_api.texts.mappings.mappings_views import delete_text_mapping
-
- # Create a mock request object
- request = TextMappingRequest(**text_mapping_delete_request)
- auth_credentials = MagicMock()
- auth_credentials.credentials = "token"
-
- # Act & Assert
- with pytest.raises(HTTPException) as exc_info:
- await delete_text_mapping(auth_credentials, request)
-
- # Verify the exception details
- assert exc_info.value.status_code == 403
- assert exc_info.value.detail == ErrorConstants.ADMIN_ERROR_MESSAGE
-
-@pytest.mark.asyncio
-@patch("pecha_api.texts.mappings.mappings_views.delete_segment_mapping")
-async def test_delete_text_mapping_not_found(mock_delete_mapping):
- # Arrange
- mock_delete_mapping.side_effect = HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Segment not found"
- )
-
- # Test directly with the handler function
- from pecha_api.texts.mappings.mappings_views import delete_text_mapping
-
- # Create a mock request object
- request = TextMappingRequest(**text_mapping_delete_request)
- auth_credentials = MagicMock()
- auth_credentials.credentials = "token"
-
- # Act & Assert
- with pytest.raises(HTTPException) as exc_info:
- await delete_text_mapping(auth_credentials, request)
-
- # Verify the exception details
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == "Segment not found"
-
-@pytest.mark.asyncio
-@patch("pecha_api.texts.mappings.mappings_views.delete_segment_mapping")
-async def test_delete_text_mapping_bad_request(mock_delete_mapping):
- # Arrange
- mock_delete_mapping.side_effect = HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorConstants.SEGMENT_MAPPING_ERROR_MESSAGE
- )
-
- # Test directly with the handler function
- from pecha_api.texts.mappings.mappings_views import delete_text_mapping
-
- # Create a mock request object
- request = TextMappingRequest(**text_mapping_delete_request)
- auth_credentials = MagicMock()
- auth_credentials.credentials = "token"
-
- # Act & Assert
- with pytest.raises(HTTPException) as exc_info:
- await delete_text_mapping(auth_credentials, request)
-
- # Verify the exception details
- assert exc_info.value.status_code == 400
- assert exc_info.value.detail == ErrorConstants.SEGMENT_MAPPING_ERROR_MESSAGE
-
-def test_delete_text_mapping_invalid_request():
- # For validation errors, we can use a POST request to test the validation
- # since the validation happens before the request reaches the handler
- response = client.post(
- "/mappings",
- headers={
- "Authorization": "Bearer token",
- "Content-Type": "application/json"
- },
- json={"invalid": "request"}
- )
-
- # Assert - validation errors return 422
- assert response.status_code == 422
diff --git a/tests/texts/mappings/test_mapping_updates.py b/tests/texts/mappings/test_mapping_updates.py
deleted file mode 100644
index 81338460d..000000000
--- a/tests/texts/mappings/test_mapping_updates.py
+++ /dev/null
@@ -1,164 +0,0 @@
-import uuid
-import pytest
-from typing import Dict, List
-from unittest.mock import AsyncMock, Mock, patch
-
-from pecha_api.texts.mappings.mappings_service import (
- _merge_segment_mappings,
- _get_existing_mappings,
- _process_new_mappings,
- _construct_update_segments
-)
-from pecha_api.texts.segments.segments_models import Mapping, Segment
-
-@pytest.fixture
-def mock_segment():
- """Create a mock segment that doesn't require database initialization"""
- def _create_segment(id_str: str, text_id: str, content: str, mapping: List[Mapping] = None):
- segment = Mock(spec=Segment)
- segment.id = uuid.UUID(id_str) if isinstance(id_str, str) else id_str
- segment.text_id = text_id
- segment.content = content
- segment.mapping = mapping or []
- return segment
- return _create_segment
-
-
-def test_merge_segment_mappings():
- """Test merging of two segment mappings"""
- # Arrange
- existing_mapping = Mapping(text_id="text1", segments=["seg1", "seg2"])
- new_mapping = Mapping(text_id="text1", segments=["seg2", "seg3"])
-
- # Act
- result = _merge_segment_mappings(existing_mapping, new_mapping)
-
- # Assert
- assert result.text_id == "text1"
- assert set(result.segments) == {"seg1", "seg2", "seg3"}
- assert result is existing_mapping # Should modify existing mapping
-
-
-def test_get_existing_mappings(mock_segment):
- """Test getting dictionary of existing mappings"""
- # Arrange
- mappings = [
- Mapping(text_id="text1", segments=["seg1"]),
- Mapping(text_id="text2", segments=["seg2"])
- ]
- segment = mock_segment(
- id_str="12345678-1234-5678-1234-567812345678",
- text_id="source",
- content="content",
- mapping=mappings
- )
-
- # Act
- result = _get_existing_mappings(segment)
-
- # Assert
- assert isinstance(result, dict)
- assert len(result) == 2
- assert "text1" in result
- assert "text2" in result
- assert result["text1"].segments == ["seg1"]
- assert result["text2"].segments == ["seg2"]
-
-
-def test_get_existing_mappings_empty(mock_segment):
- """Test getting mappings from segment with no mappings"""
- # Arrange
- segment = mock_segment(
- id_str="12345678-1234-5678-1234-567812345678",
- text_id="source",
- content="content",
- mapping=[]
- )
-
- # Act
- result = _get_existing_mappings(segment)
-
- # Assert
- assert isinstance(result, dict)
- assert len(result) == 0
-
-
-def test_process_new_mappings_all_new():
- """Test processing entirely new mappings"""
- # Arrange
- new_mappings = [
- Mapping(text_id="text1", segments=["seg1"]),
- Mapping(text_id="text2", segments=["seg2"])
- ]
- existing_mappings: Dict[str, Mapping] = {}
-
- # Act
- result = _process_new_mappings(new_mappings, existing_mappings)
-
- # Assert
- assert len(result) == 2
- assert result == new_mappings
-
-
-def test_process_new_mappings_merge():
- """Test processing mappings that need merging"""
- # Arrange
- existing_mappings = {
- "text1": Mapping(text_id="text1", segments=["seg1", "seg2"]),
- "text2": Mapping(text_id="text2", segments=["seg3"])
- }
- new_mappings = [
- Mapping(text_id="text1", segments=["seg2", "seg4"]),
- Mapping(text_id="text3", segments=["seg5"])
- ]
-
- # Act
- result = _process_new_mappings(new_mappings, existing_mappings)
-
- # Assert
- assert len(result) == 3
- text1_mapping = next(m for m in result if m.text_id == "text1")
- assert set(text1_mapping.segments) == {"seg1", "seg2", "seg4"}
- assert any(m.text_id == "text2" and m.segments == ["seg3"] for m in result)
- assert any(m.text_id == "text3" and m.segments == ["seg5"] for m in result)
-
-
-@pytest.mark.asyncio
-async def test_construct_update_segments(mock_segment):
- """Test construction of updated segments"""
- # Arrange
- segments = [
- mock_segment(
- id_str="12345678-1234-5678-1234-567812345678",
- text_id="source1",
- content="content1",
- mapping=[Mapping(text_id="text1", segments=["a", "b"])]
- ),
- mock_segment(
- id_str="87654321-4321-8765-4321-876543210987",
- text_id="source2",
- content="content2",
- mapping=[Mapping(text_id="text2", segments=["c"])]
- )
- ]
- update_dict = {
- "12345678-1234-5678-1234-567812345678": [
- Mapping(text_id="text1", segments=["b", "d"]),
- Mapping(text_id="text3", segments=["e"])
- ]
- }
-
- # Act
- result = await _construct_update_segments(segments, update_dict)
-
- # Assert
- assert len(result) == 1
- updated_segment = result[0]
- assert str(updated_segment.id) == "12345678-1234-5678-1234-567812345678"
- assert len(updated_segment.mapping) == 2
-
- text1_mapping = next(m for m in updated_segment.mapping if m.text_id == "text1")
- assert set(text1_mapping.segments) == {"a", "b", "d"}
-
- text3_mapping = next(m for m in updated_segment.mapping if m.text_id == "text3")
- assert text3_mapping.segments == ["e"]
diff --git a/tests/texts/mappings/test_mappings_service.py b/tests/texts/mappings/test_mappings_service.py
deleted file mode 100644
index 4fe0b5db8..000000000
--- a/tests/texts/mappings/test_mappings_service.py
+++ /dev/null
@@ -1,199 +0,0 @@
-from unittest.mock import AsyncMock, patch
-import pytest
-from fastapi import HTTPException, status
-import uuid
-
-from pecha_api.error_contants import ErrorConstants
-from pecha_api.texts.mappings.mappings_response_models import TextMappingRequest, MappingsModel, TextMapping
-from pecha_api.texts.mappings.mappings_service import update_segment_mapping
-from pecha_api.texts.segments.segments_models import Mapping
-from pecha_api.texts.segments.segments_response_models import SegmentResponse
-from pecha_api.texts.segments.segments_enum import SegmentType
-
-@pytest.mark.asyncio
-async def test_update_segment_mapping_non_admin():
- """Test update fails when user is not admin"""
- # Arrange
- mapping_request = TextMappingRequest(
- text_mappings=[
- TextMapping(
- text_id="text-1",
- segment_id="segment-1",
- mappings=[MappingsModel(parent_text_id="parent-1", segments=["seg-1"])]
- )
- ]
- )
-
- with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=False):
- # Act & Assert
- with pytest.raises(HTTPException) as exc_info:
- await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token")
-
- assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
- assert exc_info.value.detail == ErrorConstants.ADMIN_ERROR_MESSAGE
-
-
-@pytest.mark.asyncio
-async def test_update_segment_mapping_invalid_text():
- """Test update fails when text ID doesn't exist"""
- # Arrange
- text_id = "invalid-text"
- segment_id = "f7e14876-a3af-4652-8c84-8df2c046a105"
- parent_text_id = "c87aae38-ea7a-4d2b-ba0e-fd7dc61e68d1"
-
- mapping_request = TextMappingRequest(
- text_mappings=[
- TextMapping(
- text_id=text_id,
- segment_id=segment_id,
- mappings=[MappingsModel(parent_text_id=parent_text_id, segments=["seg-1"])]
- )
- ]
- )
-
- # Create mock objects for valid IDs only (text_id is invalid)
- mock_parent_text = AsyncMock()
- mock_parent_text.id = uuid.UUID(parent_text_id)
- mock_parent_text.pecha_text_id = parent_text_id
-
- mock_segment_obj = AsyncMock()
- mock_segment_obj.id = uuid.UUID(segment_id)
- mock_segment_obj.pecha_segment_id = segment_id
-
- with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=True), \
- patch('pecha_api.texts.mappings.mappings_service.get_texts_by_pecha_text_ids', new_callable=AsyncMock) as mock_get_texts, \
- patch('pecha_api.texts.mappings.mappings_service.get_segments_by_pecha_segment_ids', new_callable=AsyncMock) as mock_get_segments:
-
- # Return only valid texts (missing text_id)
- mock_get_texts.return_value = [mock_parent_text]
- mock_get_segments.return_value = [mock_segment_obj]
-
- # Act & Assert
- with pytest.raises(KeyError):
- await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token")
-
-
-@pytest.mark.asyncio
-async def test_update_segment_mapping_invalid_segment():
- """Test update fails when segment ID doesn't exist"""
- # Arrange
- text_id = "8749b360-a55e-441c-b541-f7c6ba2f3c61"
- segment_id = "invalid-segment"
- parent_text_id = "c87aae38-ea7a-4d2b-ba0e-fd7dc61e68d1"
-
- mapping_request = TextMappingRequest(
- text_mappings=[
- TextMapping(
- text_id=text_id,
- segment_id=segment_id,
- mappings=[MappingsModel(parent_text_id=parent_text_id, segments=["seg-1"])]
- )
- ]
- )
-
- # Create mock text objects
- mock_text = AsyncMock()
- mock_text.id = uuid.UUID(text_id)
- mock_text.pecha_text_id = text_id
-
- mock_parent_text = AsyncMock()
- mock_parent_text.id = uuid.UUID(parent_text_id)
- mock_parent_text.pecha_text_id = parent_text_id
-
- with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=True), \
- patch('pecha_api.texts.mappings.mappings_service.get_texts_by_pecha_text_ids', new_callable=AsyncMock) as mock_get_texts, \
- patch('pecha_api.texts.mappings.mappings_service.get_segments_by_pecha_segment_ids', new_callable=AsyncMock) as mock_get_segments:
-
- # Return texts but no segments (segment_id is invalid)
- mock_get_texts.return_value = [mock_text, mock_parent_text]
- mock_get_segments.return_value = []
-
- # Act & Assert
- with pytest.raises(KeyError):
- await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token")
-
-
-@pytest.mark.asyncio
-async def test_update_segment_mapping_invalid_parent_text():
- """Test update fails when parent text ID doesn't exist"""
- # Arrange
- text_id = "8749b360-a55e-441c-b541-f7c6ba2f3c61"
- segment_id = "f7e14876-a3af-4652-8c84-8df2c046a105"
- invalid_parent_text_id = "invalid-parent"
-
- mapping_request = TextMappingRequest(
- text_mappings=[
- TextMapping(
- text_id=text_id,
- segment_id=segment_id,
- mappings=[MappingsModel(parent_text_id=invalid_parent_text_id, segments=["seg-1"])]
- )
- ]
- )
-
- # Create mock text object
- mock_text = AsyncMock()
- mock_text.id = uuid.UUID(text_id)
- mock_text.pecha_text_id = text_id
-
- # Create mock segment object
- mock_segment_obj = AsyncMock()
- mock_segment_obj.id = uuid.UUID(segment_id)
- mock_segment_obj.pecha_segment_id = segment_id
-
- with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=True), \
- patch('pecha_api.texts.mappings.mappings_service.get_texts_by_pecha_text_ids', new_callable=AsyncMock) as mock_get_texts, \
- patch('pecha_api.texts.mappings.mappings_service.get_segments_by_pecha_segment_ids', new_callable=AsyncMock) as mock_get_segments:
-
- # Return only valid text (missing parent_text_id)
- mock_get_texts.return_value = [mock_text]
- mock_get_segments.return_value = [mock_segment_obj]
-
- # Act & Assert
- with pytest.raises(KeyError):
- await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token")
-
-
-@pytest.mark.asyncio
-async def test_update_segment_mapping_invalid_parent_segment():
- """Test update fails when parent segment ID doesn't exist"""
- # Arrange
- text_id = "8749b360-a55e-441c-b541-f7c6ba2f3c61"
- segment_id = "f7e14876-a3af-4652-8c84-8df2c046a105"
- parent_text_id = "c87aae38-ea7a-4d2b-ba0e-fd7dc61e68d1"
- invalid_parent_segment = "invalid-seg"
-
- mapping_request = TextMappingRequest(
- text_mappings=[
- TextMapping(
- text_id=text_id,
- segment_id=segment_id,
- mappings=[MappingsModel(parent_text_id=parent_text_id, segments=[invalid_parent_segment])]
- )]
- )
-
- # Create mock text objects
- mock_text = AsyncMock()
- mock_text.id = uuid.UUID(text_id)
- mock_text.pecha_text_id = text_id
-
- mock_parent_text = AsyncMock()
- mock_parent_text.id = uuid.UUID(parent_text_id)
- mock_parent_text.pecha_text_id = parent_text_id
-
- # Create mock segment object
- mock_segment_obj = AsyncMock()
- mock_segment_obj.id = uuid.UUID(segment_id)
- mock_segment_obj.pecha_segment_id = segment_id
-
- with patch('pecha_api.texts.mappings.mappings_service.verify_admin_access', return_value=True), \
- patch('pecha_api.texts.mappings.mappings_service.get_texts_by_pecha_text_ids', new_callable=AsyncMock) as mock_get_texts, \
- patch('pecha_api.texts.mappings.mappings_service.get_segments_by_pecha_segment_ids', new_callable=AsyncMock) as mock_get_segments:
-
- # Return texts and segment but not parent segment (invalid_parent_segment is missing)
- mock_get_texts.return_value = [mock_text, mock_parent_text]
- mock_get_segments.return_value = [mock_segment_obj]
-
- # Act & Assert
- with pytest.raises(KeyError):
- await update_segment_mapping(text_mapping_request=mapping_request, token="Bearer token")
diff --git a/tests/texts/mappings/test_mappings_views.py b/tests/texts/mappings/test_mappings_views.py
deleted file mode 100644
index b711a2d66..000000000
--- a/tests/texts/mappings/test_mappings_views.py
+++ /dev/null
@@ -1,142 +0,0 @@
-from unittest.mock import patch
-
-from fastapi.testclient import TestClient
-from pecha_api.app import api
-from fastapi import HTTPException
-
-from pecha_api.texts.segments.segments_response_models import SegmentResponse, MappingResponse,SegmentDTO
-from pecha_api.texts.segments.segments_enum import SegmentType
-
-client = TestClient(api)
-
-text_mapping_request = {
- "text_mappings": [
- {
- "text_id": "2ff4215e-bc9e-4d16-8d7e-b4adea3c6ef9",
- "segment_id": "cce14575-ebc3-43aa-bcce-777676f3b2e2",
- "mappings": [
- {
- "parent_text_id": "e55d66bc-0b2c-4575-afe1-c357856b1592",
- "segments": [
- "5bbe24b9-625e-41bf-b6aa-a949f26a7c05",
- "83311e49-7e8b-413d-95c3-80d2cdea5158"
- ]
- }
- ]
- }
- ]
-}
-
-
-@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping")
-def test_create_text_mapping(mock_update_mapping):
- # Arrange
- test_token = "test_token"
- mapping_response = MappingResponse(
- text_id="e55d66bc-0b2c-4575-afe1-c357856b1592",
- segments=[
- "5bbe24b9-625e-41bf-b6aa-a949f26a7c05",
- "83311e49-7e8b-413d-95c3-80d2cdea5158"
- ]
- )
- section_response = SegmentResponse(
- segments=[
- SegmentDTO(
- id="cce14575-ebc3-43aa-bcce-777676f3b2e2",
- text_id="2ff4215e-bc9e-4d16-8d7e-b4adea3c6ef9",
- content="content pf the segment",
- type=SegmentType.SOURCE,
- mapping=[mapping_response]
-
- )
- ]
- )
- mock_update_mapping.return_value = section_response
- response = client.post(
- "/mappings",
- headers={"Authorization": f"Bearer {test_token}"},
- json=text_mapping_request
- )
-
- # Assert
- assert response.status_code == 201
- assert response.json() == section_response.model_dump(mode="json")
-
-
-def test_create_text_mapping_403():
- # Act
- response = client.post(
- "/mappings",
- json={"text_id": "123"}
- )
-
- # Assert
- assert response.status_code == 403
-
-
-@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping")
-def test_create_text_mapping_404_text_not_found(mock_update_mapping):
- mock_update_mapping.side_effect = HTTPException(
- status_code=404,
- detail="Text not found"
- )
-
- response = client.post(
- "/mappings",
- headers={"Authorization": "Bearer token"},
- json=text_mapping_request
- )
-
- assert response.status_code == 404
- assert response.json()["detail"] == "Text not found"
-
-
-@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping")
-def test_create_text_mapping_404_segment_not_found(mock_update_mapping):
- mock_update_mapping.side_effect = HTTPException(
- status_code=404,
- detail="Segment not found"
- )
-
- response = client.post(
- "/mappings",
- headers={"Authorization": "Bearer token"},
- json=text_mapping_request
- )
-
- assert response.status_code == 404
- assert response.json()["detail"] == "Segment not found"
-
-
-@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping")
-def test_create_text_mapping_404_parent_text_not_found(mock_update_mapping):
- mock_update_mapping.side_effect = HTTPException(
- status_code=404,
- detail="Parent text not found"
- )
-
- response = client.post(
- "/mappings",
- headers={"Authorization": "Bearer token"},
- json=text_mapping_request
- )
-
- assert response.status_code == 404
- assert response.json()["detail"] == "Parent text not found"
-
-
-@patch("pecha_api.texts.mappings.mappings_views.update_segment_mapping")
-def test_create_text_mapping_404_parent_segment_not_found(mock_update_mapping):
- mock_update_mapping.side_effect = HTTPException(
- status_code=404,
- detail="Parent segment not found"
- )
-
- response = client.post(
- "/mappings",
- headers={"Authorization": "Bearer token"},
- json=text_mapping_request
- )
-
- assert response.status_code == 404
- assert response.json()["detail"] == "Parent segment not found"
diff --git a/tests/texts/test_text_cache_service.py b/tests/texts/test_text_cache_service.py
index b1de68d62..d5d73ea8b 100644
--- a/tests/texts/test_text_cache_service.py
+++ b/tests/texts/test_text_cache_service.py
@@ -12,9 +12,6 @@
set_text_versions_by_group_id_cache,
update_text_details_cache,
invalidate_text_cache_on_update,
- get_table_of_content_by_sheet_id_cache,
- set_table_of_content_by_sheet_id_cache,
- delete_table_of_content_by_sheet_id_cache,
set_text_details_by_id_cache,
get_text_details_by_id_cache,
delete_text_details_by_id_cache
@@ -683,67 +680,6 @@ async def test_get_text_versions_by_group_id_cache_with_dict_response():
assert response is not None
assert isinstance(response, TextVersionResponse)
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_sheet_id_cache_empty_cache():
- #Test get_table_of_content_by_sheet_id_cache with empty cache#
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None):
- response = await get_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT)
-
- assert response is None
-
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_sheet_id_cache_with_data():
- #Test get_table_of_content_by_sheet_id_cache with cached data#
- mock_cache_data = TableOfContent(
- id="table_of_content_id",
- text_id="text_id_1",
- type=TableOfContentType.SHEET,
- sections=[]
- )
-
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data):
- response = await get_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT)
-
- assert response is not None
- assert isinstance(response, TableOfContent)
- assert response.id == "table_of_content_id"
-
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_sheet_id_cache_with_dict_response():
- #Test get_table_of_content_by_sheet_id_cache when cache returns dict and needs conversion#
- mock_cache_dict = {
- "id": "table_of_content_id",
- "text_id": "text_id_1",
- "type": "sheet",
- "sections": []
- }
-
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict):
- response = await get_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT)
-
- assert response is not None
- assert isinstance(response, TableOfContent)
- assert response.id == "table_of_content_id"
-
-@pytest.mark.asyncio
-async def test_set_table_of_content_by_sheet_id_cache_success():
- #Test set_table_of_content_by_sheet_id_cache success#
- mock_cache_data = TableOfContent(
- id="table_of_content_id",
- text_id="text_id_1",
- type=TableOfContentType.SHEET,
- sections=[]
- )
-
- with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock):
- await set_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT, data=mock_cache_data)
-
-@pytest.mark.asyncio
-async def test_delete_table_of_content_by_sheet_id_cache_success():
- #Test delete_table_of_content_by_sheet_id_cache success#
- with patch("pecha_api.texts.texts_cache_service.clear_cache", new_callable=AsyncMock):
- await delete_table_of_content_by_sheet_id_cache(sheet_id="sheet_id", cache_type=CacheType.SHEET_TABLE_OF_CONTENT)
-
@pytest.mark.asyncio
async def test_set_text_details_by_id_cache_success():
#Test set_text_details_by_id_cache success#
@@ -828,112 +764,3 @@ async def test_delete_text_details_by_id_cache_success():
#Test delete_text_details_by_id_cache success#
with patch("pecha_api.texts.texts_cache_service.clear_cache", new_callable=AsyncMock):
await delete_text_details_by_id_cache(text_id="text_id", cache_type=CacheType.TEXT_DETAIL)
-
-@pytest.mark.asyncio
-async def test_update_text_details_cache_for_sheet_success():
- #Test update_text_details_cache for sheet with successful table of content update#
- updated_text_data = TextDTO(
- id="sheet_id_1",
- title="Updated Sheet Title",
- language="en",
- group_id="group_id_1",
- type="sheet",
- is_published=True,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 05:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="user_1",
- categories=["category_1"],
- views=10
- )
-
- mock_table_of_content = TableOfContent(
- id="toc_id",
- text_id="sheet_id_1",
- type=TableOfContentType.SHEET,
- sections=[]
- )
-
- with patch("pecha_api.texts.texts_cache_service.update_cache", new_callable=AsyncMock, return_value=True) as mock_update_cache, \
- patch("pecha_api.texts.texts_cache_service.Utils.generate_hash_key", return_value="test_hash_key"), \
- patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=mock_table_of_content):
-
- result = await update_text_details_cache(text_id="sheet_id_1", updated_text_data=updated_text_data, cache_type=CacheType.SHEET_DETAIL)
-
- assert result is True
- assert mock_update_cache.call_count == 3 # Called for primary cache, texts_by_id cache, and table of content cache
-
-@pytest.mark.asyncio
-async def test_update_text_details_cache_for_sheet_with_toc_exception():
- #Test update_text_details_cache for sheet when table of content update raises exception#
- updated_text_data = TextDTO(
- id="sheet_id_1",
- title="Updated Sheet Title",
- language="en",
- group_id="group_id_1",
- type="sheet",
- is_published=True,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 05:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="user_1",
- categories=["category_1"],
- views=10
- )
-
- with patch("pecha_api.texts.texts_cache_service.update_cache", new_callable=AsyncMock, return_value=True) as mock_update_cache, \
- patch("pecha_api.texts.texts_cache_service.Utils.generate_hash_key", return_value="test_hash_key"), \
- patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, side_effect=Exception("TOC error")), \
- patch("pecha_api.texts.texts_cache_service.logging.warning") as mock_log_warning:
-
- result = await update_text_details_cache(text_id="sheet_id_1", updated_text_data=updated_text_data, cache_type=CacheType.SHEET_DETAIL)
-
- assert result is True
- assert mock_update_cache.call_count == 2 # Called for primary cache and texts_by_id cache only
- mock_log_warning.assert_called_once()
-
-@pytest.mark.asyncio
-async def test_update_text_details_cache_for_sheet_with_none_toc():
- #Test update_text_details_cache for sheet when table of content returns None#
- updated_text_data = TextDTO(
- id="sheet_id_1",
- title="Updated Sheet Title",
- language="en",
- group_id="group_id_1",
- type="sheet",
- is_published=True,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 05:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="user_1",
- categories=["category_1"],
- views=10
- )
-
- with patch("pecha_api.texts.texts_cache_service.update_cache", new_callable=AsyncMock, return_value=True) as mock_update_cache, \
- patch("pecha_api.texts.texts_cache_service.Utils.generate_hash_key", return_value="test_hash_key"), \
- patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id", new_callable=AsyncMock, return_value=None):
-
- result = await update_text_details_cache(text_id="sheet_id_1", updated_text_data=updated_text_data, cache_type=CacheType.SHEET_DETAIL)
-
- assert result is True
- assert mock_update_cache.call_count == 2 # Called for primary cache and texts_by_id cache only
-
-@pytest.mark.asyncio
-async def test_invalidate_text_cache_on_update_for_sheet():
- #Test invalidate_text_cache_on_update for sheet with sheet-specific cache types#
- with patch("pecha_api.texts.texts_cache_service.invalidate_multiple_cache_keys", new_callable=AsyncMock) as mock_invalidate_multiple, \
- patch("pecha_api.texts.texts_cache_service.invalidate_text_related_cache", new_callable=AsyncMock) as mock_invalidate_text, \
- patch("pecha_api.texts.texts_cache_service.Utils.generate_hash_key", return_value="test_hash_key"):
-
- result = await invalidate_text_cache_on_update(text_id="sheet_id_1", cache_type=CacheType.SHEET_DETAIL)
-
- assert result is True
- mock_invalidate_multiple.assert_called_once()
- mock_invalidate_text.assert_called_once_with(text_id="sheet_id_1")
-
- # Verify that hash keys are generated for sheet-specific cache types
- args, kwargs = mock_invalidate_multiple.call_args
- assert "hash_keys" in kwargs
- assert len(kwargs["hash_keys"]) == 4 # Four different cache types for sheets
-
diff --git a/tests/texts/test_text_repository.py b/tests/texts/test_text_repository.py
deleted file mode 100644
index 090bfec49..000000000
--- a/tests/texts/test_text_repository.py
+++ /dev/null
@@ -1,261 +0,0 @@
-from unittest.mock import AsyncMock, patch, MagicMock
-import pytest
-from uuid import uuid4
-
-from pecha_api.texts.texts_repository import fetch_sheets_from_db
-from pecha_api.texts.texts_models import Text
-from pecha_api.texts.texts_enums import TextType
-from pecha_api.sheets.sheets_enum import SortBy, SortOrder
-
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_from_db_default_parameters():
- """Test fetch_sheets_from_db with default parameters"""
- # Mock Text objects
- mock_text_1 = MagicMock()
- mock_text_1.id = str(uuid4())
- mock_text_1.title = "Test Sheet 1"
- mock_text_1.language = "bo"
- mock_text_1.group_id = "group_1"
- mock_text_1.type = TextType.SHEET
- mock_text_1.is_published = True
- mock_text_1.created_date = "2024-01-01 10:00:00"
- mock_text_1.updated_date = "2024-01-01 10:00:00"
- mock_text_1.published_date = "2024-01-01 10:00:00"
- mock_text_1.published_by = "test@example.com"
- mock_text_1.categories = []
- mock_text_1.views = 100
-
- mock_text_2 = MagicMock()
- mock_text_2.id = str(uuid4())
- mock_text_2.title = "Test Sheet 2"
- mock_text_2.language = "en"
- mock_text_2.group_id = "group_2"
- mock_text_2.type = TextType.SHEET
- mock_text_2.is_published = True
- mock_text_2.created_date = "2024-01-02 15:30:00"
- mock_text_2.updated_date = "2024-01-02 15:30:00"
- mock_text_2.published_date = "2024-01-02 15:30:00"
- mock_text_2.published_by = "another@example.com"
- mock_text_2.categories = []
- mock_text_2.views = 250
-
- mock_sheets = [mock_text_1, mock_text_2]
-
- with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets:
- result = await fetch_sheets_from_db()
-
- # Verify the result
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 2
- assert result == mock_sheets
-
- # Verify Text.get_sheets was called with correct default parameters
- mock_get_sheets.assert_called_once_with(
- published_by=None,
- is_published=None,
- sort_by=None,
- sort_order=None,
- skip=0,
- limit=10
- )
-
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_from_db_with_all_parameters():
- """Test fetch_sheets_from_db with all parameters specified"""
- mock_sheets = []
-
- with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets:
- result = await fetch_sheets_from_db(
- published_by="test@example.com",
- is_published=True,
- sort_by=SortBy.PUBLISHED_DATE,
- sort_order=SortOrder.DESC,
- skip=20,
- limit=5
- )
-
- # Verify the result
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 0
- assert result == mock_sheets
-
- # Verify Text.get_sheets was called with correct parameters
- mock_get_sheets.assert_called_once_with(
- published_by="test@example.com",
- is_published=True,
- sort_by=SortBy.PUBLISHED_DATE,
- sort_order=SortOrder.DESC,
- skip=20,
- limit=5
- )
-
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_from_db_with_partial_parameters():
- """Test fetch_sheets_from_db with partial parameters"""
- mock_text = MagicMock()
- mock_text.id = str(uuid4())
- mock_text.title = "Filtered Sheet"
- mock_text.language = "bo"
- mock_text.group_id = "group_1"
- mock_text.type = TextType.SHEET
- mock_text.is_published = False
- mock_text.created_date = "2024-01-01 10:00:00"
- mock_text.updated_date = "2024-01-01 10:00:00"
- mock_text.published_date = "2024-01-01 10:00:00"
- mock_text.published_by = "specific@example.com"
- mock_text.categories = []
- mock_text.views = 50
-
- mock_sheets = [mock_text]
-
- with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets:
- result = await fetch_sheets_from_db(
- published_by="specific@example.com",
- is_published=False,
- skip=5
- )
-
- # Verify the result
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 1
- assert result[0] == mock_text
-
- # Verify Text.get_sheets was called with correct parameters
- mock_get_sheets.assert_called_once_with(
- published_by="specific@example.com",
- is_published=False,
- sort_by=None,
- sort_order=None,
- skip=5,
- limit=10
- )
-
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_from_db_empty_result():
- """Test fetch_sheets_from_db when no sheets are found"""
- with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=[]) as mock_get_sheets:
- result = await fetch_sheets_from_db()
-
- # Verify the result
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 0
-
- # Verify Text.get_sheets was called
- mock_get_sheets.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_from_db_with_sort_by_created_date():
- """Test fetch_sheets_from_db with sort by created date"""
- mock_sheets = []
-
- with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets:
- result = await fetch_sheets_from_db(
- sort_by=SortBy.CREATED_DATE,
- sort_order=SortOrder.ASC
- )
-
- # Verify the result
- assert result is not None
- assert isinstance(result, list)
-
- # Verify Text.get_sheets was called with correct sorting parameters
- mock_get_sheets.assert_called_once_with(
- published_by=None,
- is_published=None,
- sort_by=SortBy.CREATED_DATE,
- sort_order=SortOrder.ASC,
- skip=0,
- limit=10
- )
-
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_from_db_large_skip_and_limit():
- """Test fetch_sheets_from_db with large skip and limit values"""
- mock_sheets = []
-
- with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets:
- result = await fetch_sheets_from_db(skip=100, limit=50)
-
- # Verify the result
- assert result is not None
- assert isinstance(result, list)
-
- # Verify Text.get_sheets was called with correct pagination parameters
- mock_get_sheets.assert_called_once_with(
- published_by=None,
- is_published=None,
- sort_by=None,
- sort_order=None,
- skip=100,
- limit=50
- )
-
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_from_db_published_only():
- """Test fetch_sheets_from_db filtering for published sheets only"""
- mock_text = MagicMock()
- mock_text.id = str(uuid4())
- mock_text.title = "Published Sheet"
- mock_text.is_published = True
- mock_text.type = TextType.SHEET
-
- mock_sheets = [mock_text]
-
- with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets:
- result = await fetch_sheets_from_db(is_published=True)
-
- # Verify the result
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 1
-
- # Verify Text.get_sheets was called with is_published=True
- mock_get_sheets.assert_called_once_with(
- published_by=None,
- is_published=True,
- sort_by=None,
- sort_order=None,
- skip=0,
- limit=10
- )
-
-
-@pytest.mark.asyncio
-async def test_fetch_sheets_from_db_unpublished_only():
- """Test fetch_sheets_from_db filtering for unpublished sheets only"""
- mock_text = MagicMock()
- mock_text.id = str(uuid4())
- mock_text.title = "Draft Sheet"
- mock_text.is_published = False
- mock_text.type = TextType.SHEET
-
- mock_sheets = [mock_text]
-
- with patch.object(Text, 'get_sheets', new_callable=AsyncMock, return_value=mock_sheets) as mock_get_sheets:
- result = await fetch_sheets_from_db(is_published=False)
-
- # Verify the result
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 1
-
- # Verify Text.get_sheets was called with is_published=False
- mock_get_sheets.assert_called_once_with(
- published_by=None,
- is_published=False,
- sort_by=None,
- sort_order=None,
- skip=0,
- limit=10
- )
\ No newline at end of file
diff --git a/tests/texts/test_texts_service.py b/tests/texts/test_texts_service.py
index 643d17a42..d8097484d 100644
--- a/tests/texts/test_texts_service.py
+++ b/tests/texts/test_texts_service.py
@@ -15,8 +15,6 @@
update_text_details,
remove_table_of_content_by_text_id,
delete_text_by_text_id,
- get_sheet,
- get_table_of_content_by_sheet_id,
get_table_of_content_by_type,
_validate_text_detail_request,
get_root_text_by_collection_id,
@@ -57,7 +55,6 @@
from pecha_api.recitations.recitations_response_models import RecitationDTO, RecitationsResponse
from pecha_api.texts.texts_enums import TextType, PaginationDirection, LANGUAGE_ORDERS
-from pecha_api.sheets.sheets_enum import SortBy, SortOrder
from pecha_api.error_contants import ErrorConstants
from typing import List
@@ -138,13 +135,9 @@ async def test_get_text_by_collection_id():
]
with patch('pecha_api.texts.texts_service.get_collection', new_callable=AsyncMock, return_value=mock_collection), \
- patch('pecha_api.texts.texts_service.get_texts_by_collection', new_callable=AsyncMock) as mock_get_texts_by_category, \
patch('pecha_api.texts.texts_service.get_all_texts_by_collection', new_callable=AsyncMock) as mock_get_all_texts, \
patch('pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache', new_callable=AsyncMock, return_value=None), \
- patch('pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference', new_callable=AsyncMock) as mock_filter_text_base_on_group_id_type:
- mock_filter_text_base_on_group_id_type.return_value = {"root_text": mock_texts_by_category[1], "commentary": [mock_texts_by_category[0]]}
- mock_get_texts_by_category.return_value = mock_texts_by_category
- # Return only the root text for total count calculation
+ patch('pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache', new_callable=AsyncMock, return_value=None):
mock_get_all_texts.return_value = [mock_texts_by_category[1]]
response = await get_text_by_text_id_or_collection(text_id=None, collection_id="id_1", language="bo", skip=0, limit=10)
assert response is not None
@@ -246,7 +239,6 @@ async def test_get_versions_by_group_id():
)
language = "en"
with patch('pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id', new_callable=AsyncMock) as mock_text_detail, \
- patch("pecha_api.texts.texts_service.get_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\
patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\
patch('pecha_api.texts.texts_service.get_texts_by_group_id', new_callable=AsyncMock) as mock_get_texts_by_group_id,\
patch('pecha_api.texts.texts_service.get_contents_by_id', new_callable=AsyncMock) as mock_get_contents_by_id:
@@ -575,8 +567,6 @@ async def test_get_table_of_contents_by_text_id_success():
]
with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.get_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\
- patch("pecha_api.texts.texts_service.set_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\
patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \
patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents):
@@ -681,8 +671,6 @@ async def test_get_table_of_contents_by_text_id_success_language_is_none():
]
with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.get_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\
- patch("pecha_api.texts.texts_service.set_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\
patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \
patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents):
@@ -770,8 +758,6 @@ async def test_get_table_of_contents_by_text_id_root_text_is_none():
]
with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.get_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\
- patch("pecha_api.texts.texts_service.set_table_of_contents_by_text_id_cache", new_callable=MagicMock, return_value=None),\
patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \
patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents), \
@@ -870,35 +856,6 @@ async def test_delete_text_by_text_id_invalid_text_id():
assert exc_info.value.status_code == 404
assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-@pytest.mark.asyncio
-async def test_get_sheet_success_user_viewing_own_sheets():
- email = "test_user@gmail.com"
- mock_sheets = [
- type("Text", (), {
- "id": f"sheet_id_{i}",
- "title": "Test Sheet",
- "language": "en",
- "group_id": "group_id",
- "type": "sheet",
- "is_published": True if i % 2 == 0 else False,
- "created_date": "2021-01-01",
- "updated_date": "2021-01-01",
- "published_date": "2021-01-01",
- "published_by": email,
- "categories": [],
- "views": 10
- })()
- for i in range(1,11)
- ]
- with patch("pecha_api.texts.texts_service.fetch_sheets_from_db", new_callable=AsyncMock, return_value=mock_sheets):
-
- response = await get_sheet(published_by=email, is_published=None, sort_by=None, sort_order=None, skip=0, limit=10)
-
- assert response is not None
- assert len(response) == 10
- for sheet in response:
- assert sheet.published_by == email
-
@pytest.mark.asyncio
async def test_get_text_details_by_text_id_with_text_id_content_id_segment_id_success():
text_id = "text_id_1"
@@ -1358,56 +1315,6 @@ async def test_get_text_details_by_text_id_with_content_id_only_success():
assert response.pagination_direction == PaginationDirection.NEXT
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_sheet_id_success():
- sheet_id = "sheet_id_1"
- mock_table_of_contents = [
- TableOfContent(
- id="content_id_1",
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- TextSegment(
- segment_id="segment_id_1",
- segment_number=1
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
- ]
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents), \
- patch("pecha_api.texts.texts_service.set_table_of_content_by_sheet_id_cache", new_callable=AsyncMock, return_value=None):
-
- response = await get_table_of_content_by_sheet_id(sheet_id=sheet_id)
-
- assert response is not None
- assert isinstance(response, TableOfContent)
- assert response.id == "content_id_1"
-
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_sheet_id_invalid_sheet_id():
- sheet_id = "invalid_sheet_id"
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_table_of_content_by_sheet_id(sheet_id=sheet_id)
-
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-
@pytest.mark.asyncio
async def test_validate_text_detail_request_success():
text_id = "text_id_1"
@@ -1591,7 +1498,6 @@ async def test_get_versions_by_group_id_language_is_none():
)
language = "en"
with patch('pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id', new_callable=AsyncMock) as mock_text_detail, \
- patch("pecha_api.texts.texts_service.get_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\
patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\
patch('pecha_api.texts.texts_service.get_texts_by_group_id', new_callable=AsyncMock) as mock_get_texts_by_group_id,\
patch('pecha_api.texts.texts_service.get_contents_by_id', new_callable=AsyncMock) as mock_get_contents_by_id:
@@ -2789,55 +2695,6 @@ async def test_update_text_details_cache_update_fails():
# Verify that invalidate was called as fallback
mock_invalidate.assert_called_once_with(text_id="text_id_1")
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_sheet_id_with_cache():
- """Test get_table_of_content_by_sheet_id when data is in cache"""
- sheet_id = "sheet_id_1"
- cached_toc = TableOfContent(
- id="content_id_1",
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- TextSegment(
- segment_id="segment_id_1",
- segment_number=1
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id_cache", new_callable=AsyncMock, return_value=cached_toc):
- response = await get_table_of_content_by_sheet_id(sheet_id=sheet_id)
-
- assert response is not None
- assert isinstance(response, TableOfContent)
- assert response.id == "content_id_1"
-
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_sheet_id_no_content():
- """Test get_table_of_content_by_sheet_id when no content exists"""
- sheet_id = "sheet_id_1"
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[]), \
- patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id_cache", new_callable=AsyncMock, return_value=None):
-
- response = await get_table_of_content_by_sheet_id(sheet_id=sheet_id)
-
- assert response is None
-
-
@pytest.mark.asyncio
async def test_get_text_by_text_id_or_collection_with_cache():
"""Test get_text_by_text_id_or_collection fetches text (cache is currently disabled in code)"""
@@ -2868,42 +2725,6 @@ async def test_get_text_by_text_id_or_collection_with_cache():
assert response.title == "Test Text"
-@pytest.mark.asyncio
-async def test_get_sheet_with_filters():
- """Test get_sheet with various filter parameters"""
- mock_sheets = [
- TextDTO(
- id="sheet_id_1",
- title="Sheet 1",
- language="bo",
- group_id="group_id_1",
- type="sheet",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="user_1",
- categories=[],
- views=10
- )
- ]
-
- with patch("pecha_api.texts.texts_service.fetch_sheets_from_db", new_callable=AsyncMock, return_value=mock_sheets):
- result = await get_sheet(
- published_by="user_1",
- is_published=True,
- sort_by=SortBy.CREATED_DATE,
- sort_order=SortOrder.DESC,
- skip=0,
- limit=10
- )
-
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 1
- assert result[0].id == "sheet_id_1"
-
-
@pytest.mark.asyncio
async def test_update_text_details_with_cache_invalidation():
"""Test update_text_details updates text successfully"""
@@ -2952,25 +2773,6 @@ async def test_remove_table_of_content_by_text_id_success():
mock_delete.assert_called_once_with(text_id=text_id)
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_sheet_id_with_cache():
- """Test get_table_of_content_by_sheet_id returns cached data"""
- sheet_id = "sheet_id_1"
- cached_toc = TableOfContent(
- id="toc_id_1",
- text_id=sheet_id,
- type=TableOfContentType.SHEET,
- sections=[]
- )
-
- with patch("pecha_api.texts.texts_service.get_table_of_content_by_sheet_id_cache", new_callable=AsyncMock, return_value=cached_toc):
- result = await get_table_of_content_by_sheet_id(sheet_id=sheet_id)
-
- assert result is not None
- assert isinstance(result, TableOfContent)
- assert result.id == "toc_id_1"
-
-
@pytest.mark.asyncio
async def test_get_text_by_collection_id_empty_result():
"""Test get_text_by_text_id_or_collection with collection_id returns empty when no texts"""
diff --git a/tests/texts/test_texts_views.py b/tests/texts/test_texts_views.py
index 99f0bb1d4..e03358fb5 100644
--- a/tests/texts/test_texts_views.py
+++ b/tests/texts/test_texts_views.py
@@ -833,298 +833,6 @@ async def test_create_text_service_error(mocker):
assert response.json()["detail"] == "Invalid group_id"
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_success(mocker):
- """Test POST /text-uploader/list with valid pecha_text_ids"""
- mock_text_1 = TextDTO(
- id="text-1-uuid",
- pecha_text_id="pecha_text_id_1",
- title="Test Text 1",
- language="bo",
- group_id="group-1-uuid",
- type="root_text",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
- categories=[],
- views=10,
- source_link="https://test-source-1.com",
- ranking=1,
- license="CC0"
- )
-
- mock_text_2 = TextDTO(
- id="text-2-uuid",
- pecha_text_id="pecha_text_id_2",
- title="Test Text 2",
- language="en",
- group_id="group-2-uuid",
- type="version",
- is_published=True,
- created_date="2025-01-02T00:00:00",
- updated_date="2025-01-02T00:00:00",
- published_date="2025-01-02T00:00:00",
- published_by="test_user_2",
- categories=["category-1"],
- views=20,
- source_link="https://test-source-2.com",
- ranking=2,
- license="CC BY"
- )
-
- mock_texts = [mock_text_1, mock_text_2]
-
- mock_get_texts = mocker.patch(
- 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service',
- new_callable=AsyncMock,
- return_value=mock_texts
- )
-
- request_data = {
- "pecha_text_ids": ["pecha_text_id_1", "pecha_text_id_2"]
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/text-uploader/list",
- json=request_data
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data) == 2
- assert data[0]["pecha_text_id"] == "pecha_text_id_1"
- assert data[0]["title"] == "Test Text 1"
- assert data[1]["pecha_text_id"] == "pecha_text_id_2"
- assert data[1]["title"] == "Test Text 2"
-
- mock_get_texts.assert_called_once()
- call_args = mock_get_texts.call_args[1]
- assert isinstance(call_args["texts_by_pecha_text_ids_request"], TextsByPechaTextIdsRequest)
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_empty_list(mocker):
- """Test POST /text-uploader/list with empty pecha_text_ids list"""
- mock_get_texts = mocker.patch(
- 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service',
- new_callable=AsyncMock,
- return_value=[]
- )
-
- request_data = {
- "pecha_text_ids": []
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/text-uploader/list",
- json=request_data
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data) == 0
- assert data == []
-
- mock_get_texts.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_single_text(mocker):
- """Test POST /text-uploader/list with single pecha_text_id"""
- mock_text = TextDTO(
- id="single-text-uuid",
- pecha_text_id="pecha_text_id_single",
- title="Single Test Text",
- language="bo",
- group_id="group-uuid",
- type="root_text",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
- categories=[],
- views=5,
- source_link="https://test-source.com",
- ranking=1,
- license="CC0"
- )
-
- mock_get_texts = mocker.patch(
- 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service',
- new_callable=AsyncMock,
- return_value=[mock_text]
- )
-
- request_data = {
- "pecha_text_ids": ["pecha_text_id_single"]
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/text-uploader/list",
- json=request_data
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data) == 1
- assert data[0]["pecha_text_id"] == "pecha_text_id_single"
- assert data[0]["title"] == "Single Test Text"
-
- mock_get_texts.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_none_result(mocker):
- """Test POST /text-uploader/list when service returns None"""
- mock_get_texts = mocker.patch(
- 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service',
- new_callable=AsyncMock,
- return_value=None
- )
-
- request_data = {
- "pecha_text_ids": ["nonexistent_pecha_id"]
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/text-uploader/list",
- json=request_data
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data is None
-
- mock_get_texts.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_invalid_request():
- """Test POST /text-uploader/list with invalid request data"""
- invalid_data = {
- "invalid_field": ["pecha_text_id_1"]
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/text-uploader/list",
- json=invalid_data
- )
-
- assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_service_error(mocker):
- """Test POST /text-uploader/list when service raises an error"""
- mock_get_texts = mocker.patch(
- 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service',
- new_callable=AsyncMock,
- side_effect=HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal server error"
- )
- )
-
- request_data = {
- "pecha_text_ids": ["pecha_text_id_1"]
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/text-uploader/list",
- json=request_data
- )
-
- assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
- assert response.json()["detail"] == "Internal server error"
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_multiple_texts_with_optional_fields(mocker):
- """Test POST /text-uploader/list with texts having various optional fields"""
- mock_text_with_all_fields = TextDTO(
- id="text-all-fields-uuid",
- pecha_text_id="pecha_text_id_all",
- title="Text with All Fields",
- language="bo",
- group_id="group-uuid",
- type="root_text",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
- categories=["cat1", "cat2"],
- views=100,
- source_link="https://source.com",
- ranking=5,
- license="CC BY-SA"
- )
-
- mock_text_with_minimal_fields = TextDTO(
- id="text-minimal-uuid",
- pecha_text_id=None,
- title="Text with Minimal Fields",
- language="en",
- group_id="group-uuid-2",
- type="version",
- is_published=False,
- created_date="2025-01-02T00:00:00",
- updated_date="2025-01-02T00:00:00",
- published_date="2025-01-02T00:00:00",
- published_by="test_user_2",
- categories=None,
- views=0,
- source_link=None,
- ranking=None,
- license=None
- )
-
- mock_texts = [mock_text_with_all_fields, mock_text_with_minimal_fields]
-
- mock_get_texts = mocker.patch(
- 'pecha_api.text_uploader.text_metadata.text_metadata_views.get_text_by_pecha_text_ids_service',
- new_callable=AsyncMock,
- return_value=mock_texts
- )
-
- request_data = {
- "pecha_text_ids": ["pecha_text_id_all", "pecha_text_id_minimal"]
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/text-uploader/list",
- json=request_data
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data) == 2
-
- # Verify first text with all fields
- assert data[0]["pecha_text_id"] == "pecha_text_id_all"
- assert data[0]["categories"] == ["cat1", "cat2"]
- assert data[0]["views"] == 100
- assert data[0]["ranking"] == 5
-
- # Verify second text with minimal fields
- assert data[1]["pecha_text_id"] is None
- assert data[1]["categories"] is None
- assert data[1]["views"] == 0
- assert data[1]["source_link"] is None
- assert data[1]["ranking"] is None
- assert data[1]["license"] is None
-
-
@pytest.mark.asyncio
async def test_search_titles_success(mocker):
"""Test GET /texts/title-search returns title search results"""
From 0ace4737e9d346adebbbb4adc728226edf1d02ee Mon Sep 17 00:00:00 2001
From: Jajin <102473656+Lungsangg@users.noreply.github.com>
Date: Thu, 28 May 2026 16:47:13 +0530
Subject: [PATCH 02/29] =?UTF-8?q?refactor:=20remove=20deprecated=20collect?=
=?UTF-8?q?ions=20endpoints=20and=20unused=20function=E2=80=A6=20(#634)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor: remove deprecated collections endpoints and unused functionality
* refactor: remove duplicate collection upload function and unused import
---------
Co-authored-by: TenzDelek
---
pecha_api/app.py | 2 -
.../collections/collections_cache_service.py | 27 +-
.../collections/collections_repository.py | 70 ----
.../collections_response_models.py | 13 +-
pecha_api/collections/collections_service.py | 118 +-----
pecha_api/collections/collections_views.py | 98 -----
tests/collection/test_collection_service.py | 352 -----------------
tests/collection/test_collection_views.py | 277 -------------
.../test_collections_cache_service.py | 367 ------------------
9 files changed, 6 insertions(+), 1318 deletions(-)
delete mode 100644 pecha_api/collections/collections_views.py
delete mode 100644 tests/collection/test_collection_service.py
delete mode 100644 tests/collection/test_collection_views.py
delete mode 100644 tests/collection/test_collections_cache_service.py
diff --git a/pecha_api/app.py b/pecha_api/app.py
index 19cb940c1..1b4602e69 100644
--- a/pecha_api/app.py
+++ b/pecha_api/app.py
@@ -6,7 +6,6 @@
from pecha_api.db.mongo_database import lifespan
from pecha_api.auth import auth_views
-from pecha_api.collections import collections_views
from pecha_api.terms import terms_views
from pecha_api.texts import texts_views
from pecha_api.topics import topics_views
@@ -46,7 +45,6 @@
lifespan=lifespan
)
api.include_router(auth_views.auth_router)
-api.include_router(collections_views.collections_router)
api.include_router(terms_views.terms_router)
api.include_router(texts_views.text_router)
api.include_router(groups_views.group_router)
diff --git a/pecha_api/collections/collections_cache_service.py b/pecha_api/collections/collections_cache_service.py
index 9b9303903..301195bab 100644
--- a/pecha_api/collections/collections_cache_service.py
+++ b/pecha_api/collections/collections_cache_service.py
@@ -3,30 +3,11 @@
from pecha_api.cache.cache_repository import (
get_cache_data,
set_cache,
- clear_cache,
)
from pecha_api import config
-from .collections_response_models import (
- CollectionsResponse,
- CollectionModel
-)
+from .collections_response_models import CollectionModel
from pecha_api.cache.cache_enums import CacheType
-async def get_collections_cache(parent_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> CollectionsResponse:
- """Get collections cache asynchronously."""
- payload = [parent_id, language, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload=payload)
- cache_data: CollectionsResponse = await get_cache_data(hash_key=hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = CollectionsResponse(**cache_data)
- return cache_data
-
-async def set_collections_cache(parent_id: str = None, language: str = None, skip: int = None, limit: int = None, data: CollectionsResponse = None, cache_type: CacheType = None):
- """Set collections cache asynchronously."""
- payload = [parent_id, language, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload=payload)
- cache_time_out = config.get_int("CACHE_COLLECTION_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
async def get_collection_detail_cache(collection_id: str = None, language: str = None, cache_type: CacheType = None) -> CollectionModel:
"""Get collection detail cache asynchronously."""
@@ -43,9 +24,3 @@ async def set_collection_detail_cache(collection_id: str = None, language: str =
hashed_key: str = Utils.generate_hash_key(payload=payload)
cache_time_out = config.get_int("CACHE_COLLECTION_TIMEOUT")
await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def delete_collection_cache(collection_id: str = None, cache_type: CacheType = None):
- """Delete collection cache asynchronously."""
- payload = [collection_id, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload=payload)
- await clear_cache(hash_key=hashed_key)
\ No newline at end of file
diff --git a/pecha_api/collections/collections_repository.py b/pecha_api/collections/collections_repository.py
index 2a556b4be..ffa6ad4fe 100644
--- a/pecha_api/collections/collections_repository.py
+++ b/pecha_api/collections/collections_repository.py
@@ -2,27 +2,10 @@
from typing import Optional
from beanie.exceptions import CollectionWasNotInitialized
-from fastapi import HTTPException
-from starlette import status
from pecha_api.utils import Utils
from ..collections.collections_models import Collection
-from ..collections.collections_response_models import CreateCollectionRequest, UpdateCollectionRequest
-COLLECTION_NOT_FOUND = 'Collection not found'
-
-
-async def get_collections_by_parent(
- parent_id: Optional[str],
- skip: int,
- limit: int) -> list[Collection]:
- try:
- topic_parent_id = Utils.get_parent_id(parent_id=parent_id)
- collections = await Collection.get_children_by_id(parent_id=topic_parent_id, skip=skip, limit=limit)
- return collections
- except CollectionWasNotInitialized as e:
- logging.debug(e)
- return []
async def get_all_collections_by_parent(
parent_id: Optional[str]) -> list[Collection]:
@@ -34,65 +17,12 @@ async def get_all_collections_by_parent(
logging.debug(e)
return []
-async def get_child_count(parent_id: Optional[str]) -> int:
- topic_parent_id = Utils.get_parent_id(parent_id=parent_id)
- count = await Collection.count_children(parent_id=topic_parent_id)
- return count
-
-
-async def create_collection(create_collection_request: CreateCollectionRequest) -> Collection:
- try:
- existing_collection = await Collection.find_one(Collection.slug == create_collection_request.slug)
- if existing_collection:
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Collection with this slug {create_collection_request.slug} already exists")
- except AttributeError as e:
- logging.debug(e)
- new_collection = Collection(pecha_collection_id=create_collection_request.pecha_collection_id, slug=create_collection_request.slug, titles=create_collection_request.titles,
- descriptions=create_collection_request.descriptions, parent_id=create_collection_request.parent_id)
- saved_collection = await new_collection.insert()
- if create_collection_request.parent_id is not None:
- await update_collection_child_status(collection_id=create_collection_request.parent_id)
- return saved_collection
-
-
async def get_collection_by_id(collection_id: Optional[str]) -> Optional[Collection]:
if not collection_id:
return None
return await Collection.get(collection_id)
-async def update_collection_child_status(collection_id: str) -> Collection:
- existing_collection = await Collection.get(collection_id)
- if not existing_collection:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=COLLECTION_NOT_FOUND)
- child_count = await Collection.count_children(parent_id=existing_collection.id)
- has_child = child_count > 0
- if existing_collection.has_sub_child != has_child:
- existing_collection.has_sub_child = has_child
- await existing_collection.save()
- return existing_collection
-
-
-async def update_collection_titles(collection_id: str, update_collection_request: UpdateCollectionRequest) -> Collection:
- existing_collection = await Collection.get(collection_id)
- if not existing_collection:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=COLLECTION_NOT_FOUND)
-
- existing_collection.titles = update_collection_request.titles
- await existing_collection.save()
- return existing_collection
-
-
-async def delete_collection(collection_id: str):
- existing_collection = await Collection.get(collection_id)
- if not existing_collection:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=COLLECTION_NOT_FOUND)
- parent_id = existing_collection.parent_id
- await existing_collection.delete()
- if parent_id is not None:
- await update_collection_child_status(collection_id=str(parent_id))
- return existing_collection
-
async def get_collection_id_by_slug(slug: str) -> Optional[str]:
collection = await Collection.get_by_slug(slug=slug)
if collection:
diff --git a/pecha_api/collections/collections_response_models.py b/pecha_api/collections/collections_response_models.py
index 36400f8f2..936014737 100644
--- a/pecha_api/collections/collections_response_models.py
+++ b/pecha_api/collections/collections_response_models.py
@@ -1,19 +1,8 @@
-from typing import Dict, List, Optional
+from typing import List, Optional
from pydantic import BaseModel
-class CreateCollectionRequest(BaseModel):
- pecha_collection_id: Optional[str] = None
- slug: str
- titles: Dict[str, str]
- descriptions: Dict[str, str]
- parent_id: Optional[str]
-
-class UpdateCollectionRequest(BaseModel):
- titles: Dict[str, str]
- descriptions: Dict[str, str]
-
class CollectionModel(BaseModel):
id: str
pecha_collection_id: Optional[str] = None
diff --git a/pecha_api/collections/collections_service.py b/pecha_api/collections/collections_service.py
index 61edcfe7b..c32856bd7 100644
--- a/pecha_api/collections/collections_service.py
+++ b/pecha_api/collections/collections_service.py
@@ -1,99 +1,17 @@
from typing import Optional
-from starlette import status
-from beanie import PydanticObjectId
-
-from pecha_api.error_contants import ErrorConstants
from pecha_api.utils import Utils
from ..config import get
-from ..collections.collections_response_models import CollectionModel, CollectionsResponse, Pagination, CreateCollectionRequest, UpdateCollectionRequest
-from .collections_repository import get_child_count, get_collections_by_parent, create_collection, update_collection_titles, delete_collection, \
- get_collection_by_id, get_collection_id_by_pecha_collection_id
+from ..collections.collections_response_models import CollectionModel
+from .collections_repository import get_collection_by_id, get_collection_id_by_pecha_collection_id
from .collections_cache_service import (
- get_collections_cache,
- set_collections_cache,
get_collection_detail_cache,
- set_collection_detail_cache,
- delete_collection_cache
+ set_collection_detail_cache
)
from pecha_api.cache.cache_enums import CacheType
-from ..users.users_service import verify_admin_access
-from fastapi import HTTPException
-
-
-async def get_all_collections(language: str, parent_id: Optional[PydanticObjectId], skip: int, limit: int) -> CollectionsResponse:
- if language is None:
- language = get("DEFAULT_LANGUAGE")
-
- # Try to get from cache first
- cached_data = await get_collections_cache(
- parent_id=parent_id,
- language=language,
- skip=skip,
- limit=limit,
- cache_type=CacheType.COLLECTIONS
- )
-
- if cached_data:
- return cached_data
-
- # If not in cache, fetch from database
- total = await get_child_count(parent_id=parent_id)
- parent_collection = await get_collection(collection_id=parent_id,language=language)
- collections = await get_collections_by_parent(
- parent_id=parent_id,
- skip=skip,
- limit=limit
- )
- collection_list = [
- CollectionModel(
- id=str(collection.id),
- pecha_collection_id=str(collection.pecha_collection_id),
- title=Utils.get_value_from_dict(values=collection.titles, language=language),
- description=Utils.get_value_from_dict(values=collection.descriptions, language=language),
- has_child=collection.has_sub_child,
- language=language,
- slug=collection.slug
- )
- for collection in collections
- ]
- pagination = Pagination(total=total, skip=skip, limit=limit)
-
- collection_response = CollectionsResponse(parent=parent_collection, pagination=pagination, collections=collection_list)
-
- # Cache the result
- await set_collections_cache(
- parent_id=parent_id,
- language=language,
- skip=skip,
- limit=limit,
- data=collection_response,
- cache_type=CacheType.COLLECTIONS
- )
-
- return collection_response
-async def create_new_collection(create_collection_request: CreateCollectionRequest, token: str, language: Optional[str]) -> CollectionModel:
- is_admin = verify_admin_access(token=token)
- if is_admin:
- new_collection = await create_collection(create_collection_request=create_collection_request)
- if language is None:
- language = get("DEFAULT_LANGUAGE")
- return CollectionModel(
- id=str(new_collection.id),
- pecha_collection_id=str(new_collection.pecha_collection_id),
- title=Utils.get_value_from_dict(values=new_collection.titles, language=language),
- description=Utils.get_value_from_dict(values=new_collection.descriptions, language=language),
- has_child=new_collection.has_sub_child,
- language=language,
- slug=new_collection.slug
-
- )
-
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE)
-
-async def get_collection(collection_id: str,language: str) -> Optional[CollectionModel]:
+async def get_collection(collection_id: str, language: str) -> Optional[CollectionModel]:
if collection_id is None:
return None
@@ -131,34 +49,6 @@ async def get_collection(collection_id: str,language: str) -> Optional[Collectio
return collection_model
return None
-async def update_existing_collection(collection_id: str, update_collection_request: UpdateCollectionRequest, token: str,
- language: Optional[str]) -> CollectionModel:
- is_admin = verify_admin_access(token=token)
- if is_admin:
- updated_collection = await update_collection_titles(collection_id=collection_id, update_collection_request=update_collection_request)
- if language is None:
- language = get("DEFAULT_LANGUAGE")
- return CollectionModel(
- id=collection_id,
- pecha_collection_id=str(updated_collection.pecha_collection_id),
- title=Utils.get_value_from_dict(values=updated_collection.titles, language=language),
- description=Utils.get_value_from_dict(values=updated_collection.descriptions, language=language),
- has_child=updated_collection.has_sub_child,
- language=language,
- slug=updated_collection.slug
- )
-
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE)
-
-
-async def delete_existing_collection(collection_id: str, token: str):
- is_admin = verify_admin_access(token=token)
- if is_admin:
- await delete_collection(collection_id=collection_id)
- return collection_id
-
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ErrorConstants.ADMIN_ERROR_MESSAGE)
-
async def get_collection_by_pecha_collection_id_service(pecha_collection_id: str) -> Optional[str]:
collection_id = await get_collection_id_by_pecha_collection_id(pecha_collection_id=pecha_collection_id)
if collection_id:
diff --git a/pecha_api/collections/collections_views.py b/pecha_api/collections/collections_views.py
deleted file mode 100644
index 3bf729d24..000000000
--- a/pecha_api/collections/collections_views.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from __future__ import annotations
-
-from fastapi import APIRouter, Depends, Query, Response
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
-from starlette import status
-from typing import Annotated, Optional
-
-from ..collections.collections_response_models import CreateCollectionRequest, UpdateCollectionRequest
-from ..collections.collections_service import (
- get_all_collections,
- create_new_collection,
- update_existing_collection,
- delete_existing_collection,
- get_collection_by_pecha_collection_id_service
-)
-
-oauth2_scheme = HTTPBearer()
-collections_router = APIRouter(
- prefix="/collections",
- tags=["collections"]
-)
-
-DEPRECATION_DATE = "Sat, 01 Jun 2026 00:00:00 GMT"
-V2_ENDPOINT = "/api/v2/collections"
-
-
-@collections_router.get(
- "",
- status_code=status.HTTP_200_OK,
- deprecated=True,
- summary="Get collections (DEPRECATED)",
- description="**DEPRECATED**: This endpoint will be removed on June 1, 2026. Please migrate to /api/v2/collections."
-)
-async def read_collection(
- response: Response,
- parent_id: Optional[str] = Query(None, description="Filter topics by title prefix"),
- language: Optional[str] = None,
- skip: int = Query(default=0, ge=0, description="Number of records to skip"),
- limit: int = Query(default=10, ge=1, le=100, description="Number of records to return")
-):
- response.headers["Deprecation"] = "true"
- response.headers["Sunset"] = DEPRECATION_DATE
- response.headers["Link"] = f"<{V2_ENDPOINT}>; rel=\"successor-version\""
- return await get_all_collections(
- parent_id=parent_id,
- language=language,
- skip=skip,
- limit=limit
- )
-
-
-@collections_router.post(
- "",
- status_code=status.HTTP_201_CREATED,
- deprecated=True,
- summary="Create collection (DEPRECATED)",
- description="**DEPRECATED**: This endpoint will be removed on June 1, 2026. Please migrate to /api/v2/collections."
-)
-async def create_collection(language: str | None, create_collection_request: CreateCollectionRequest,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)]):
- return await create_new_collection(
- create_collection_request=create_collection_request,
- token=authentication_credential.credentials,
- language=language
- )
-
-
-@collections_router.put(
- "/{collection_id}",
- status_code=status.HTTP_202_ACCEPTED,
- deprecated=True,
- summary="Update collection (DEPRECATED)",
- description="**DEPRECATED**: This endpoint will be removed on June 1, 2026. Please migrate to /api/v2/collections."
-)
-async def update_collection_by_id(collection_id: str, language: str | None, update_collection_request: UpdateCollectionRequest,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)]):
- return await update_existing_collection(
- collection_id=collection_id,
- update_collection_request=update_collection_request,
- token=authentication_credential.credentials,
- language=language
- )
-
-
-@collections_router.delete(
- "/{collection_id}",
- status_code=status.HTTP_204_NO_CONTENT,
- deprecated=True,
- summary="Delete collection (DEPRECATED)",
- description="**DEPRECATED**: This endpoint will be removed on June 1, 2026. Please migrate to /api/v2/collections."
-)
-async def delete_collection_by_id(collection_id: str,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)]):
- return await delete_existing_collection(
- collection_id=collection_id,
- token=authentication_credential.credentials
- )
-
diff --git a/tests/collection/test_collection_service.py b/tests/collection/test_collection_service.py
deleted file mode 100644
index 8e2f799b2..000000000
--- a/tests/collection/test_collection_service.py
+++ /dev/null
@@ -1,352 +0,0 @@
-import pytest
-from unittest.mock import patch, AsyncMock
-
-from starlette import status
-
-from pecha_api.collections.collections_service import (
- get_all_collections,
- create_new_collection,
- update_existing_collection,
- delete_existing_collection,
- get_collection,
- get_collection_by_pecha_collection_id_service
-)
-from pecha_api.collections.collections_response_models import CollectionModel, CollectionsResponse, CreateCollectionRequest, UpdateCollectionRequest
-from fastapi import HTTPException
-
-
-@pytest.mark.asyncio
-async def test_get_all_collections():
- with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \
- patch("pecha_api.collections.collections_service.get_collections_cache", new_callable=AsyncMock, return_value=None), \
- patch("pecha_api.collections.collections_service.get_collections_by_parent",
- new_callable=AsyncMock) as mock_get_collections_by_parent, \
- patch("pecha_api.collections.collections_service.get_child_count", new_callable=AsyncMock, return_value=2), \
- patch("pecha_api.collections.collections_service.set_collections_cache", new_callable=AsyncMock) as mock_set_collections_cache:
- mock_get_collections_by_parent.return_value = [
- AsyncMock(id="id_1", pecha_collection_id="pecha_id_1", titles={"en": "Collection 1"}, slug="collection-1",parent_id=None, has_sub_child=False),
- AsyncMock(id="id_2", pecha_collection_id="pecha_id_2", titles={"en": "Collection 2"}, descriptions={"en": "Description 2"}, slug="collection-2",parent_id=None,has_sub_child=False)
- ]
- response = await get_all_collections(language="en",parent_id=None,skip=0,limit=10)
- assert isinstance(response, CollectionsResponse)
- assert len(response.collections) == 2
- assert response.collections[0].title == "Collection 1"
- assert response.collections[1].has_child == False
- mock_set_collections_cache.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_create_new_collection():
- with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \
- patch("pecha_api.collections.collections_service.create_collection", new_callable=AsyncMock) as mock_create_collection:
- mock_create_collection.return_value = AsyncMock(pecha_collection_id="pecha_new_id", titles={"en": "New Collection"}, descriptions={"en": "New Description"}, slug="new-collection",parent_id=None,has_sub_child=False)
- create_collection_request = CreateCollectionRequest(slug="new-collection", titles={"en": "New Collection"}, descriptions={"en": "New Description"},parent_id=None)
- response = await create_new_collection(
- create_collection_request=create_collection_request,
- token="valid_token",
- language="en"
- )
- assert isinstance(response, CollectionModel)
- assert response.title == "New Collection"
- assert response.slug == "new-collection"
- assert response.description == "New Description"
- assert response.has_child == False
-
-
-@pytest.mark.asyncio
-async def test_update_existing_collection():
- with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \
- patch("pecha_api.collections.collections_service.update_collection_titles",
- new_callable=AsyncMock) as mock_update_collection_titles:
- mock_update_collection_titles.return_value = AsyncMock(pecha_collection_id="pecha_id_1", titles={"en": "Updated Collection"}, descriptions={"en": "Description 1"}, slug="updated-collection",parent_id=None)
- update_collection_request = UpdateCollectionRequest(titles={"en": "Updated Collection"}, descriptions={"en": "New Description"})
- response = await update_existing_collection(collection_id="1", update_collection_request=update_collection_request, token="valid_token",
- language="en")
- assert isinstance(response, CollectionModel)
- assert response.title == "Updated Collection"
-
-
-@pytest.mark.asyncio
-async def test_delete_existing_collection():
- with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \
- patch("pecha_api.collections.collections_service.delete_collection", new_callable=AsyncMock) as mock_delete_collection:
- mock_delete_collection.return_value = "id_1"
- response = await delete_existing_collection(collection_id="id_1", token="valid_token")
- assert response == "id_1"
-
-
-@pytest.mark.asyncio
-async def test_create_new_collection_unauthorized():
- with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=False):
- create_collection_request = CreateCollectionRequest(slug="new-collection", titles={"en": "New Collection"},descriptions={"en": "New Description"},parent_id=None)
- try:
- await create_new_collection(create_collection_request=create_collection_request, token="invalid_token", language="en")
- except HTTPException as e:
- assert e.status_code == status.HTTP_403_FORBIDDEN
-
-
-@pytest.mark.asyncio
-@patch("pecha_api.collections.collections_service.verify_admin_access", return_value=False)
-async def test_update_existing_collection_unauthorized(mock_verify_admin_access):
- update_collection_request = UpdateCollectionRequest(titles={"en": "Updated Collection"}, descriptions={"en": "Updated Descriptions"})
- try:
- await update_existing_collection(collection_id="1", update_collection_request=update_collection_request, token="invalid_token",
- language=None)
- except HTTPException as e:
- assert e.status_code == status.HTTP_403_FORBIDDEN
-
-
-@pytest.mark.asyncio
-@patch("pecha_api.collections.collections_service.verify_admin_access", return_value=False)
-async def test_delete_existing_collection_unauthorized(mock_verify_admin_access):
- try:
- await delete_existing_collection(collection_id="1", token="invalid_token")
- except HTTPException as e:
- assert e.status_code == status.HTTP_403_FORBIDDEN
-
-
-# New test cases for uncovered scenarios
-
-@pytest.mark.asyncio
-async def test_get_collection_with_none_id():
- """Test get_collection when collection_id is None - should return None"""
- response = await get_collection(collection_id=None, language="en")
- assert response is None
-
-
-@pytest.mark.asyncio
-async def test_get_collection_cache_hit():
- """Test get_collection when data is found in cache"""
- cached_collection = CollectionModel(
- id="cached_id",
- pecha_collection_id="pecha_cached_id",
- title="Cached Collection",
- description="Cached Description",
- language="en",
- slug="cached-collection",
- has_child=False
- )
-
- with patch("pecha_api.collections.collections_service.get_collection_detail_cache", new_callable=AsyncMock) as mock_cache_get:
- mock_cache_get.return_value = cached_collection
-
- response = await get_collection(collection_id="cached_id", language="en")
-
- assert response == cached_collection
- mock_cache_get.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_collection_cache_miss_collection_found():
- """Test get_collection when cache miss but collection found in database"""
- mock_db_collection = AsyncMock(
- pecha_collection_id="pecha_db_id",
- titles={"en": "DB Collection", "es": "Colección DB"},
- descriptions={"en": "DB Description", "es": "Descripción DB"},
- slug="db-collection",
- has_sub_child=True
- )
-
- with patch("pecha_api.collections.collections_service.get_collection_detail_cache", new_callable=AsyncMock, return_value=None) as mock_cache_get, \
- patch("pecha_api.collections.collections_service.get_collection_by_id", new_callable=AsyncMock) as mock_db_get, \
- patch("pecha_api.collections.collections_service.set_collection_detail_cache", new_callable=AsyncMock) as mock_cache_set, \
- patch("pecha_api.collections.collections_service.Utils.get_value_from_dict", side_effect=["DB Collection", "DB Description"]):
-
- mock_db_get.return_value = mock_db_collection
-
- response = await get_collection(collection_id="db_id", language="en")
-
- assert isinstance(response, CollectionModel)
- assert response.id == "db_id"
- assert response.title == "DB Collection"
- assert response.description == "DB Description"
- assert response.slug == "db-collection"
- assert response.has_child == True
- assert response.language == "en"
-
- mock_cache_get.assert_called_once()
- mock_db_get.assert_called_once_with(collection_id="db_id")
- mock_cache_set.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_collection_cache_miss_collection_not_found():
- """Test get_collection when cache miss and collection not found in database"""
- with patch("pecha_api.collections.collections_service.get_collection_detail_cache", new_callable=AsyncMock, return_value=None), \
- patch("pecha_api.collections.collections_service.get_collection_by_id", new_callable=AsyncMock, return_value=None):
-
- response = await get_collection(collection_id="nonexistent_id", language="en")
-
- assert response is None
-
-
-@pytest.mark.asyncio
-async def test_get_all_collections_cache_hit():
- """Test get_all_collections when data is found in cache"""
- cached_response = CollectionsResponse(
- parent=None,
- pagination={"total": 1, "skip": 0, "limit": 10},
- collections=[
- CollectionModel(
- id="cached_id",
- pecha_collection_id="pecha_cached_id",
- title="Cached Collection",
- description="Cached Description",
- language="en",
- slug="cached-collection",
- has_child=False
- )
- ]
- )
-
- with patch("pecha_api.collections.collections_service.get_collections_cache", new_callable=AsyncMock) as mock_cache_get:
- mock_cache_get.return_value = cached_response
-
- response = await get_all_collections(language="en", parent_id=None, skip=0, limit=10)
-
- assert response == cached_response
- mock_cache_get.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_all_collections_with_default_language():
- """Test get_all_collections when language is None - should use default language"""
- with patch("pecha_api.collections.collections_service.get_collections_cache", new_callable=AsyncMock, return_value=None), \
- patch("pecha_api.collections.collections_service.get_child_count", new_callable=AsyncMock, return_value=1), \
- patch("pecha_api.collections.collections_service.get_collection", new_callable=AsyncMock, return_value=None), \
- patch("pecha_api.collections.collections_service.get_collections_by_parent", new_callable=AsyncMock) as mock_get_collections, \
- patch("pecha_api.collections.collections_service.set_collections_cache", new_callable=AsyncMock), \
- patch("pecha_api.collections.collections_service.get", return_value="en") as mock_get_config:
-
- mock_get_collections.return_value = [
- AsyncMock(id="test_id", pecha_collection_id="pecha_test_id", titles={"en": "Test"}, descriptions={"en": "Test Desc"}, slug="test", has_sub_child=False)
- ]
-
- response = await get_all_collections(language=None, parent_id=None, skip=0, limit=10)
-
- mock_get_config.assert_called_with("DEFAULT_LANGUAGE")
- assert isinstance(response, CollectionsResponse)
-
-
-@pytest.mark.asyncio
-async def test_create_new_collection_with_default_language():
- """Test create_new_collection when language is None - should use default language"""
- with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \
- patch("pecha_api.collections.collections_service.create_collection", new_callable=AsyncMock) as mock_create, \
- patch("pecha_api.collections.collections_service.get", return_value="en") as mock_get_config, \
- patch("pecha_api.collections.collections_service.Utils.get_value_from_dict", side_effect=["New Collection", "New Description"]):
-
- mock_create.return_value = AsyncMock(
- id="new_id",
- pecha_collection_id="pecha_new_id",
- titles={"en": "New Collection"},
- descriptions={"en": "New Description"},
- slug="new-collection",
- has_sub_child=False
- )
-
- create_request = CreateCollectionRequest(
- slug="new-collection",
- titles={"en": "New Collection"},
- descriptions={"en": "New Description"},
- parent_id=None
- )
-
- response = await create_new_collection(
- create_collection_request=create_request,
- token="valid_token",
- language=None
- )
-
- mock_get_config.assert_called_with("DEFAULT_LANGUAGE")
- assert isinstance(response, CollectionModel)
- assert response.language == "en"
-
-
-@pytest.mark.asyncio
-async def test_update_existing_collection_with_default_language():
- """Test update_existing_collection when language is None - should use default language"""
- with patch("pecha_api.collections.collections_service.verify_admin_access", return_value=True), \
- patch("pecha_api.collections.collections_service.update_collection_titles", new_callable=AsyncMock) as mock_update, \
- patch("pecha_api.collections.collections_service.get", return_value="en") as mock_get_config, \
- patch("pecha_api.collections.collections_service.Utils.get_value_from_dict", side_effect=["Updated Collection", "Updated Description"]):
-
- mock_update.return_value = AsyncMock(
- pecha_collection_id="pecha_test_id",
- titles={"en": "Updated Collection"},
- descriptions={"en": "Updated Description"},
- slug="updated-collection",
- has_sub_child=True
- )
-
- update_request = UpdateCollectionRequest(
- titles={"en": "Updated Collection"},
- descriptions={"en": "Updated Description"}
- )
-
- response = await update_existing_collection(
- collection_id="test_id",
- update_collection_request=update_request,
- token="valid_token",
- language=None
- )
-
- mock_get_config.assert_called_with("DEFAULT_LANGUAGE")
- assert isinstance(response, CollectionModel)
- assert response.language == "en"
-
-
-@pytest.mark.asyncio
-async def test_get_all_collections_cache_miss_with_parent():
- """Test get_all_collections cache miss scenario with parent_id provided"""
- parent_collection = CollectionModel(
- id="parent_id",
- pecha_collection_id="pecha_parent_id",
- title="Parent Collection",
- description="Parent Description",
- language="en",
- slug="parent-collection",
- has_child=True
- )
-
- with patch("pecha_api.collections.collections_service.get_collections_cache", new_callable=AsyncMock, return_value=None), \
- patch("pecha_api.collections.collections_service.get_child_count", new_callable=AsyncMock, return_value=2), \
- patch("pecha_api.collections.collections_service.get_collection", new_callable=AsyncMock, return_value=parent_collection), \
- patch("pecha_api.collections.collections_service.get_collections_by_parent", new_callable=AsyncMock) as mock_get_collections, \
- patch("pecha_api.collections.collections_service.set_collections_cache", new_callable=AsyncMock) as mock_cache_set, \
- patch("pecha_api.collections.collections_service.Utils.get_value_from_dict", side_effect=["Child 1", "Child 1 Desc", "Child 2", "Child 2 Desc"]):
-
- mock_get_collections.return_value = [
- AsyncMock(id="child1", pecha_collection_id="pecha_child1", titles={"en": "Child 1"}, descriptions={"en": "Child 1 Desc"}, slug="child-1", has_sub_child=False),
- AsyncMock(id="child2", pecha_collection_id="pecha_child2", titles={"en": "Child 2"}, descriptions={"en": "Child 2 Desc"}, slug="child-2", has_sub_child=False)
- ]
-
- response = await get_all_collections(language="en", parent_id="parent_id", skip=0, limit=10)
-
- assert isinstance(response, CollectionsResponse)
- assert response.parent == parent_collection
- assert len(response.collections) == 2
- assert response.pagination.total == 2
- mock_cache_set.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_collection_by_pecha_collection_id_service_found():
- """Test get_collection_by_pecha_collection_id_service when collection is found"""
- with patch("pecha_api.collections.collections_service.get_collection_id_by_pecha_collection_id",
- new_callable=AsyncMock, return_value="collection_id_123"):
-
- result = await get_collection_by_pecha_collection_id_service(pecha_collection_id="pecha_123")
-
- assert result == "collection_id_123"
-
-
-@pytest.mark.asyncio
-async def test_get_collection_by_pecha_collection_id_service_not_found():
- """Test get_collection_by_pecha_collection_id_service when collection is not found"""
- with patch("pecha_api.collections.collections_service.get_collection_id_by_pecha_collection_id",
- new_callable=AsyncMock, return_value=None):
-
- result = await get_collection_by_pecha_collection_id_service(pecha_collection_id="nonexistent_pecha_id")
-
- assert result is None
diff --git a/tests/collection/test_collection_views.py b/tests/collection/test_collection_views.py
deleted file mode 100644
index 72fb64362..000000000
--- a/tests/collection/test_collection_views.py
+++ /dev/null
@@ -1,277 +0,0 @@
-import asyncio
-import pytest
-from unittest.mock import patch, AsyncMock
-from fastapi.testclient import TestClient
-from fastapi import HTTPException
-from starlette import status
-from httpx import AsyncClient, ASGITransport
-
-from pecha_api.app import api
-from pecha_api.collections.collections_response_models import (
- CollectionModel,
- CollectionsResponse,
- CreateCollectionRequest,
- UpdateCollectionRequest,
- Pagination
-)
-
-client = TestClient(api)
-
-# Test constants
-VALID_TOKEN = "valid_admin_token"
-INVALID_TOKEN = "invalid_token"
-COLLECTION_ID = "60d21b4667d0d8992e610c85"
-
-# Mock data
-MOCK_COLLECTION = CollectionModel(
- id=COLLECTION_ID,
- pecha_collection_id="pecha_60d21b4667d0d8992e610c85",
- title="Test Collection",
- description="Test Description",
- language="en",
- slug="test-collection",
- has_child=False
-)
-
-MOCK_COLLECTIONS_RESPONSE = CollectionsResponse(
- parent=None,
- pagination=Pagination(total=2, skip=0, limit=10),
- collections=[
- MOCK_COLLECTION,
- CollectionModel(
- id="60d21b4667d0d8992e610c86",
- pecha_collection_id="pecha_60d21b4667d0d8992e610c86",
- title="Another Collection",
- description="Another Description",
- language="en",
- slug="another-collection",
- has_child=True
- )
- ]
-)
-
-MOCK_CREATE_REQUEST = CreateCollectionRequest(
- slug="new-collection",
- titles={"en": "New Collection"},
- descriptions={"en": "New Description"},
- parent_id=None
-)
-
-MOCK_UPDATE_REQUEST = UpdateCollectionRequest(
- titles={"en": "Updated Collection"},
- descriptions={"en": "Updated Description"}
-)
-
-
-# Tests for GET /collections endpoint
-
-@pytest.mark.asyncio
-async def test_read_collections_success():
- # Test successful retrieval of collections
- with patch("pecha_api.collections.collections_views.get_all_collections",
- new_callable=AsyncMock, return_value=MOCK_COLLECTIONS_RESPONSE):
-
- response = client.get("/collections")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["pagination"]["total"] == 2
- assert len(data["collections"]) == 2
- assert data["collections"][0]["title"] == "Test Collection"
- assert data["collections"][1]["has_child"] == True
-
-
-@pytest.mark.asyncio
-async def test_read_collections_with_filters():
- # Test reading collections with query parameters
- with patch("pecha_api.collections.collections_views.get_all_collections",
- new_callable=AsyncMock, return_value=MOCK_COLLECTIONS_RESPONSE):
-
- response = client.get("/collections", params={
- "parent_id": COLLECTION_ID,
- "language": "bo",
- "skip": 10,
- "limit": 5
- })
-
- assert response.status_code == status.HTTP_200_OK
-
-
-@pytest.mark.asyncio
-async def test_read_collections_service_error():
- # Test handling of service errors
- with patch("pecha_api.collections.collections_views.get_all_collections",
- new_callable=AsyncMock, side_effect=HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal server error"
- )):
-
- response = client.get("/collections")
-
- assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
-
-
-# Tests for POST /collections endpoint
-
-@pytest.mark.asyncio
-async def test_create_collection_success():
- # Test successful collection creation
- with patch("pecha_api.collections.collections_views.create_new_collection",
- new_callable=AsyncMock, return_value=MOCK_COLLECTION):
-
- response = client.post("/collections",
- json=MOCK_CREATE_REQUEST.model_dump(),
- headers={"Authorization": f"Bearer {VALID_TOKEN}"},
- params={"language": "en"})
-
- assert response.status_code == status.HTTP_201_CREATED
- data = response.json()
- assert data["title"] == "Test Collection"
- assert data["slug"] == "test-collection"
- assert data["has_child"] == False
-
-
-@pytest.mark.asyncio
-async def test_create_collection_without_auth():
- # Test collection creation without authentication
- response = client.post("/collections",
- json=MOCK_CREATE_REQUEST.model_dump())
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
-
-@pytest.mark.asyncio
-async def test_create_collection_invalid_data():
- # Test collection creation with invalid data
- invalid_data = {
- "slug": "", # Empty slug should fail validation
- "titles": {}, # Empty titles
- "descriptions": {},
- "parent_id": None
- }
-
- response = client.post("/collections",
- json=invalid_data,
- headers={"Authorization": f"Bearer {VALID_TOKEN}"})
-
- assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
-
-
-# Tests for PUT /collections/{collection_id} endpoint
-
-@pytest.mark.asyncio
-async def test_update_collection_success():
- # Test successful collection update
- updated_collection = CollectionModel(
- id=COLLECTION_ID,
- pecha_collection_id="pecha_60d21b4667d0d8992e610c85",
- title="Updated Collection",
- description="Updated Description",
- language="en",
- slug="test-collection",
- has_child=False
- )
-
- with patch("pecha_api.collections.collections_views.update_existing_collection",
- new_callable=AsyncMock, return_value=updated_collection):
-
- response = client.put(f"/collections/{COLLECTION_ID}",
- json=MOCK_UPDATE_REQUEST.model_dump(),
- headers={"Authorization": f"Bearer {VALID_TOKEN}"},
- params={"language": "en"})
-
- assert response.status_code == status.HTTP_202_ACCEPTED
- data = response.json()
- assert data["title"] == "Updated Collection"
- assert data["description"] == "Updated Description"
-
-
-@pytest.mark.asyncio
-async def test_update_collection_without_auth():
- # Test collection update without authentication
- response = client.put(f"/collections/{COLLECTION_ID}",
- json=MOCK_UPDATE_REQUEST.model_dump())
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
-@pytest.mark.asyncio
-async def test_update_collection_invalid_data():
- # Test collection update with invalid data
- invalid_data = {
- "titles": {}, # Empty titles should fail validation
- "descriptions": {}
- }
-
- response = client.put(f"/collections/{COLLECTION_ID}",
- json=invalid_data,
- headers={"Authorization": f"Bearer {VALID_TOKEN}"})
-
- assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
-
-
-# Tests for DELETE /collections/{collection_id} endpoint
-
-@pytest.mark.asyncio
-async def test_delete_collection_success():
- # Test successful collection deletion
- with patch("pecha_api.collections.collections_views.delete_existing_collection",
- new_callable=AsyncMock, return_value=COLLECTION_ID):
-
- response = client.delete(f"/collections/{COLLECTION_ID}",
- headers={"Authorization": f"Bearer {VALID_TOKEN}"})
-
- assert response.status_code == status.HTTP_204_NO_CONTENT
-
-
-@pytest.mark.asyncio
-async def test_delete_collection_without_auth():
- # Test collection deletion without authentication
- response = client.delete(f"/collections/{COLLECTION_ID}")
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
-
-@pytest.mark.asyncio
-async def test_delete_collection_invalid_token():
- # Test collection deletion with invalid token - patch where views imports it
- with patch("pecha_api.collections.collections_views.delete_existing_collection",
- new_callable=AsyncMock, side_effect=HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="Admin access required"
- )):
-
- response = client.delete(f"/collections/{COLLECTION_ID}",
- headers={"Authorization": f"Bearer {INVALID_TOKEN}"})
-
- assert response.status_code == status.HTTP_403_FORBIDDEN
-
-
-@pytest.mark.asyncio
-async def test_delete_collection_not_found():
- # Test deleting non-existent collection - patch where views imports it
- with patch("pecha_api.collections.collections_views.delete_existing_collection",
- new_callable=AsyncMock, side_effect=HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Collection not found"
- )):
-
- response = client.delete(f"/collections/{COLLECTION_ID}",
- headers={"Authorization": f"Bearer {VALID_TOKEN}"})
-
- assert response.status_code == status.HTTP_404_NOT_FOUND
-
-
-@pytest.mark.asyncio
-async def test_delete_collection_with_children():
- # Test deleting collection that has children - patch where views imports it
- with patch("pecha_api.collections.collections_views.delete_existing_collection",
- new_callable=AsyncMock, side_effect=HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Cannot delete collection with children"
- )):
-
- response = client.delete(f"/collections/{COLLECTION_ID}",
- headers={"Authorization": f"Bearer {VALID_TOKEN}"})
-
- assert response.status_code == status.HTTP_400_BAD_REQUEST
-
diff --git a/tests/collection/test_collections_cache_service.py b/tests/collection/test_collections_cache_service.py
deleted file mode 100644
index af3d4768b..000000000
--- a/tests/collection/test_collections_cache_service.py
+++ /dev/null
@@ -1,367 +0,0 @@
-import pytest
-from unittest.mock import patch, AsyncMock, MagicMock
-
-from pecha_api.collections.collections_cache_service import (
- get_collections_cache,
- set_collections_cache,
- get_collection_detail_cache,
- set_collection_detail_cache,
- delete_collection_cache
-)
-from pecha_api.collections.collections_response_models import (
- CollectionsResponse,
- CollectionModel,
- Pagination
-)
-from pecha_api.cache.cache_enums import CacheType
-
-
-@pytest.mark.asyncio
-async def test_get_collections_cache_empty_cache():
- #Test get_collections_cache when cache is empty/None.
- with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None):
-
- response = await get_collections_cache(
- parent_id="parent_id",
- language="en",
- skip=0,
- limit=10,
- cache_type=CacheType.COLLECTIONS
- )
-
- assert response is None
-
-
-@pytest.mark.asyncio
-async def test_get_collections_cache_success():
- #Test get_collections_cache when cache returns valid CollectionsResponse object.
- mock_collection = CollectionModel(
- id="collection_id",
- title="Test Collection",
- description="Test Description",
- language="en",
- slug="test-collection",
- has_child=False
- )
-
- mock_pagination = Pagination(total=1, skip=0, limit=10)
-
- mock_collections_response = CollectionsResponse(
- parent=None,
- pagination=mock_pagination,
- collections=[mock_collection]
- )
-
- with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_collections_response):
-
- response = await get_collections_cache(
- parent_id="parent_id",
- language="en",
- skip=0,
- limit=10,
- cache_type=CacheType.COLLECTIONS
- )
-
- assert response is not None
- assert isinstance(response, CollectionsResponse)
- assert len(response.collections) == 1
- assert response.collections[0].id == "collection_id"
- assert response.pagination.total == 1
-
-
-@pytest.mark.asyncio
-async def test_get_collections_cache_with_dict_response():
- #Test get_collections_cache when cache returns dict and needs conversion.
- mock_cache_dict = {
- "parent": None,
- "pagination": {"total": 1, "skip": 0, "limit": 10},
- "collections": [{
- "id": "collection_id",
- "title": "Test Collection",
- "description": "Test Description",
- "language": "en",
- "slug": "test-collection",
- "has_child": False
- }]
- }
-
- with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict):
-
- response = await get_collections_cache(
- parent_id="parent_id",
- language="en",
- skip=0,
- limit=10,
- cache_type=CacheType.COLLECTIONS
- )
-
- assert response is not None
- assert isinstance(response, CollectionsResponse)
- assert len(response.collections) == 1
- assert response.collections[0].id == "collection_id"
-
-
-@pytest.mark.asyncio
-async def test_set_collections_cache_success():
- #Test set_collections_cache successfully sets cache.
- mock_collection = CollectionModel(
- id="collection_id",
- title="Test Collection",
- description="Test Description",
- language="en",
- slug="test-collection",
- has_child=False
- )
-
- mock_pagination = Pagination(total=1, skip=0, limit=10)
-
- mock_collections_response = CollectionsResponse(
- parent=None,
- pagination=mock_pagination,
- collections=[mock_collection]
- )
-
- with patch("pecha_api.collections.collections_cache_service.set_cache", new_callable=AsyncMock) as mock_set_cache, \
- patch("pecha_api.collections.collections_cache_service.config.get_int", return_value=1800) as mock_config:
-
- await set_collections_cache(
- parent_id="parent_id",
- language="en",
- skip=0,
- limit=10,
- data=mock_collections_response,
- cache_type=CacheType.COLLECTIONS
- )
-
- mock_set_cache.assert_called_once()
- mock_config.assert_called_once_with("CACHE_COLLECTION_TIMEOUT")
-
- # Verify the call arguments
- call_args = mock_set_cache.call_args
- assert "hash_key" in call_args.kwargs
- assert call_args.kwargs["value"] == mock_collections_response
- assert call_args.kwargs["cache_time_out"] == 1800
-
-
-@pytest.mark.asyncio
-async def test_get_collection_detail_cache_empty_cache():
- #Test get_collection_detail_cache when cache is empty/None.
- with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None):
-
- response = await get_collection_detail_cache(
- collection_id="collection_id",
- language="en",
- cache_type=CacheType.COLLECTION_DETAIL
- )
-
- assert response is None
-
-
-@pytest.mark.asyncio
-async def test_get_collection_detail_cache_success():
- #Test get_collection_detail_cache when cache returns valid CollectionModel object.
- mock_collection = CollectionModel(
- id="collection_id",
- title="Test Collection",
- description="Test Description",
- language="en",
- slug="test-collection",
- has_child=True
- )
-
- with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_collection):
-
- response = await get_collection_detail_cache(
- collection_id="collection_id",
- language="en",
- cache_type=CacheType.COLLECTION_DETAIL
- )
-
- assert response is not None
- assert isinstance(response, CollectionModel)
- assert response.id == "collection_id"
- assert response.title == "Test Collection"
- assert response.has_child is True
-
-
-@pytest.mark.asyncio
-async def test_get_collection_detail_cache_with_dict_response():
- #Test get_collection_detail_cache when cache returns dict and needs conversion.
- mock_cache_dict = {
- "id": "collection_id",
- "title": "Test Collection",
- "description": "Test Description",
- "language": "en",
- "slug": "test-collection",
- "has_child": True
- }
-
- with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict):
-
- response = await get_collection_detail_cache(
- collection_id="collection_id",
- language="en",
- cache_type=CacheType.COLLECTION_DETAIL
- )
-
- assert response is not None
- assert isinstance(response, CollectionModel)
- assert response.id == "collection_id"
- assert response.has_child is True
-
-
-@pytest.mark.asyncio
-async def test_set_collection_detail_cache_success():
- #Test set_collection_detail_cache successfully sets cache.
- mock_collection = CollectionModel(
- id="collection_id",
- title="Test Collection",
- description="Test Description",
- language="en",
- slug="test-collection",
- has_child=False
- )
-
- with patch("pecha_api.collections.collections_cache_service.set_cache", new_callable=AsyncMock) as mock_set_cache, \
- patch("pecha_api.collections.collections_cache_service.config.get_int", return_value=1800) as mock_config:
-
- await set_collection_detail_cache(
- collection_id="collection_id",
- language="en",
- data=mock_collection,
- cache_type=CacheType.COLLECTION_DETAIL
- )
-
- mock_set_cache.assert_called_once()
- mock_config.assert_called_once_with("CACHE_COLLECTION_TIMEOUT")
-
- # Verify the call arguments
- call_args = mock_set_cache.call_args
- assert "hash_key" in call_args.kwargs
- assert call_args.kwargs["value"] == mock_collection
- assert call_args.kwargs["cache_time_out"] == 1800
-
-
-@pytest.mark.asyncio
-async def test_delete_collection_cache_success():
- #Test delete_collection_cache successfully clears cache.
- with patch("pecha_api.collections.collections_cache_service.clear_cache", new_callable=AsyncMock) as mock_clear_cache:
-
- await delete_collection_cache(
- collection_id="collection_id",
- cache_type=CacheType.COLLECTION_DETAIL
- )
-
- mock_clear_cache.assert_called_once()
-
- # Verify the call arguments
- call_args = mock_clear_cache.call_args
- assert "hash_key" in call_args.kwargs
-
-
-@pytest.mark.asyncio
-async def test_get_collections_cache_hash_key_generation():
- #Test that correct hash key is generated for get_collections_cache.
- with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None) as mock_get_cache, \
- patch("pecha_api.collections.collections_cache_service.Utils.generate_hash_key", return_value="test_hash_key") as mock_generate_hash:
-
- await get_collections_cache(
- parent_id="parent_id",
- language="en",
- skip=0,
- limit=10,
- cache_type=CacheType.COLLECTIONS
- )
-
- mock_generate_hash.assert_called_once_with(payload=["parent_id", "en", 0, 10, CacheType.COLLECTIONS])
- mock_get_cache.assert_called_once_with(hash_key="test_hash_key")
-
-
-@pytest.mark.asyncio
-async def test_get_collection_detail_cache_hash_key_generation():
- #Test that correct hash key is generated for get_collection_detail_cache.
- with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None) as mock_get_cache, \
- patch("pecha_api.collections.collections_cache_service.Utils.generate_hash_key", return_value="test_hash_key") as mock_generate_hash:
-
- await get_collection_detail_cache(
- collection_id="collection_id",
- language="en",
- cache_type=CacheType.COLLECTION_DETAIL
- )
-
- mock_generate_hash.assert_called_once_with(payload=["collection_id", "en", CacheType.COLLECTION_DETAIL])
- mock_get_cache.assert_called_once_with(hash_key="test_hash_key")
-
-
-@pytest.mark.asyncio
-async def test_delete_collection_cache_hash_key_generation():
- #Test that correct hash key is generated for delete_collection_cache.
- with patch("pecha_api.collections.collections_cache_service.clear_cache", new_callable=AsyncMock) as mock_clear_cache, \
- patch("pecha_api.collections.collections_cache_service.Utils.generate_hash_key", return_value="test_hash_key") as mock_generate_hash:
-
- await delete_collection_cache(
- collection_id="collection_id",
- cache_type=CacheType.COLLECTION_DETAIL
- )
-
- mock_generate_hash.assert_called_once_with(payload=["collection_id", CacheType.COLLECTION_DETAIL])
- mock_clear_cache.assert_called_once_with(hash_key="test_hash_key")
-
-
-@pytest.mark.asyncio
-async def test_collections_cache_with_none_parameters():
- #Test cache functions work correctly with None parameters.
- with patch("pecha_api.collections.collections_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None) as mock_get_cache, \
- patch("pecha_api.collections.collections_cache_service.Utils.generate_hash_key", return_value="test_hash_key") as mock_generate_hash:
-
- # Test with None values
- await get_collections_cache(
- parent_id=None,
- language=None,
- skip=None,
- limit=None,
- cache_type=None
- )
-
- mock_generate_hash.assert_called_once_with(payload=[None, None, None, None, None])
- mock_get_cache.assert_called_once_with(hash_key="test_hash_key")
-
-
-@pytest.mark.asyncio
-async def test_set_collections_cache_with_none_data():
- #Test set_collections_cache handles None data correctly.
- with patch("pecha_api.collections.collections_cache_service.set_cache", new_callable=AsyncMock) as mock_set_cache, \
- patch("pecha_api.collections.collections_cache_service.config.get_int", return_value=1800) as mock_config:
-
- await set_collections_cache(
- parent_id="parent_id",
- language="en",
- skip=0,
- limit=10,
- data=None,
- cache_type=CacheType.COLLECTIONS
- )
-
- mock_set_cache.assert_called_once()
- call_args = mock_set_cache.call_args
- assert call_args.kwargs["value"] is None
- assert call_args.kwargs["cache_time_out"] == 1800
-
-
-@pytest.mark.asyncio
-async def test_set_collection_detail_cache_with_none_data():
- #Test set_collection_detail_cache handles None data correctly.
- with patch("pecha_api.collections.collections_cache_service.set_cache", new_callable=AsyncMock) as mock_set_cache, \
- patch("pecha_api.collections.collections_cache_service.config.get_int", return_value=1800) as mock_config:
-
- await set_collection_detail_cache(
- collection_id="collection_id",
- language="en",
- data=None,
- cache_type=CacheType.COLLECTION_DETAIL
- )
-
- mock_set_cache.assert_called_once()
- call_args = mock_set_cache.call_args
- assert call_args.kwargs["value"] is None
- assert call_args.kwargs["cache_time_out"] == 1800
\ No newline at end of file
From 40423ea67ec1526a70d990b723b6bf20b2470ffb Mon Sep 17 00:00:00 2001
From: TenzDelek <122612557+TenzDelek@users.noreply.github.com>
Date: Mon, 1 Jun 2026 14:22:50 +0530
Subject: [PATCH 03/29] Merge pull request #655 from OpenPecha/clean-text
Text Migration
---
pecha_api/app.py | 2 -
pecha_api/texts/texts_cache_service.py | 77 +-
pecha_api/texts/texts_repository.py | 40 -
pecha_api/texts/texts_service.py | 825 +----
pecha_api/texts/texts_views.py | 149 -
tests/texts/test_text_cache_service.py | 487 ---
tests/texts/test_texts_service.py | 4385 +++---------------------
tests/texts/test_texts_views.py | 1348 --------
8 files changed, 448 insertions(+), 6865 deletions(-)
delete mode 100644 pecha_api/texts/texts_views.py
delete mode 100644 tests/texts/test_texts_views.py
diff --git a/pecha_api/app.py b/pecha_api/app.py
index e93d8a965..50a601039 100644
--- a/pecha_api/app.py
+++ b/pecha_api/app.py
@@ -7,7 +7,6 @@
from pecha_api.db.mongo_database import lifespan
from pecha_api.auth import auth_views
from pecha_api.terms import terms_views
-from pecha_api.texts import texts_views
from pecha_api.topics import topics_views
from pecha_api.users import users_views
from pecha_api.texts.groups import groups_views
@@ -48,7 +47,6 @@
)
api.include_router(auth_views.auth_router)
api.include_router(terms_views.terms_router)
-api.include_router(texts_views.text_router)
api.include_router(groups_views.group_router)
api.include_router(topics_views.topics_router)
api.include_router(users_views.user_router)
diff --git a/pecha_api/texts/texts_cache_service.py b/pecha_api/texts/texts_cache_service.py
index ea4eaca67..d9beb5bb1 100644
--- a/pecha_api/texts/texts_cache_service.py
+++ b/pecha_api/texts/texts_cache_service.py
@@ -9,86 +9,13 @@
invalidate_text_related_cache,
invalidate_multiple_cache_keys,
)
-from .texts_response_models import (
- DetailTableOfContentResponse,
- TextsCategoryResponse,
- TableOfContentResponse,
- TextVersionResponse,
- TextDTO,
- TableOfContent
-)
+from .texts_response_models import TextDTO
+
from pecha_api.cache.cache_enums import CacheType
-from typing import Optional
import logging
from pecha_api import config
-async def set_text_details_cache(text_id: str = None, content_id: str = None, version_id: str = None, skip: int = None, limit: int = None, data: DetailTableOfContentResponse = None, cache_type: CacheType = None):
- #Set text details cache asynchronously.
- payload = [text_id, content_id, version_id, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def get_text_details_cache(text_id: str = None, content_id: str = None, version_id: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> DetailTableOfContentResponse:
- #Get text details cache asynchronously.
- payload = [text_id, content_id, version_id, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: DetailTableOfContentResponse = await get_cache_data(hash_key =hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = DetailTableOfContentResponse(**cache_data)
- return cache_data
-
-
-async def get_text_by_text_id_or_collection_cache(text_id: str = None, collection_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> TextsCategoryResponse | TextDTO:
- """Get text by text id or collection cache asynchronously."""
- payload = [text_id, collection_id, language, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: TextsCategoryResponse | TextDTO = await get_cache_data(hash_key = hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = TextsCategoryResponse(**cache_data)
- return cache_data
-
-
-async def set_text_by_text_id_or_collection_cache(text_id: str = None, collection_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None, data: TextsCategoryResponse = None):
- """Set text by text_id or collection cache asynchronously."""
- payload = [text_id, collection_id, language, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def get_table_of_contents_by_text_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> TableOfContentResponse:
- """Get table of contents by text id cache asynchronously."""
- payload = [text_id, language, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: TableOfContentResponse = await get_cache_data(hash_key = hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = TableOfContentResponse(**cache_data)
- return cache_data
-
-async def set_table_of_contents_by_text_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, data: TableOfContentResponse = None, cache_type: CacheType = None):
- """Set table of contents by text_id cache asynchronously."""
- payload = [text_id, language, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
-async def get_text_versions_by_group_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, cache_type: CacheType = None) -> TextVersionResponse:
- #Get text versions by group_id cache asynchronously.
- payload = [text_id, language, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_data: TextVersionResponse = await get_cache_data(hash_key = hashed_key)
- if cache_data and isinstance(cache_data, dict):
- cache_data = TextVersionResponse(**cache_data)
- return cache_data
-
-async def set_text_versions_by_group_id_cache(text_id: str = None, language: str = None, skip: int = None, limit: int = None, data: TextVersionResponse = None, cache_type: CacheType = None):
- #Set text versions by group_id cache asynchronously.
- payload = [text_id, language, skip, limit, cache_type]
- hashed_key: str = Utils.generate_hash_key(payload = payload)
- cache_time_out = config.get_int("CACHE_TEXT_TIMEOUT")
- await set_cache(hash_key=hashed_key, value=data, cache_time_out=cache_time_out)
-
async def set_text_details_by_id_cache(text_id: str = None, cache_type: CacheType = None, data: TextDTO = None):
"""Set text details by id cache asynchronously."""
payload = [text_id, cache_type]
diff --git a/pecha_api/texts/texts_repository.py b/pecha_api/texts/texts_repository.py
index d5417d098..75d204ea8 100644
--- a/pecha_api/texts/texts_repository.py
+++ b/pecha_api/texts/texts_repository.py
@@ -30,9 +30,6 @@ async def get_text_by_pecha_text_id(pecha_text_id: str) -> Text | None:
logging.debug(e)
return None
-async def get_sections_count_of_table_of_content(content_id: str) -> int:
- return await TableOfContent.get_sections_count(content_id=content_id)
-
async def get_texts_by_id(text_id: str) -> Text | None:
try:
text = await Text.get_text(text_id=text_id)
@@ -81,45 +78,12 @@ async def check_all_text_exists(text_ids: List[UUID]) -> bool:
logging.debug(e)
return False
-async def get_texts_by_collection(collection_id: str, skip: int, limit: int) -> List[Text]:
- return await Text.get_texts_by_collection_id(collection_id=collection_id, skip=skip, limit=limit)
-
async def get_all_texts_by_collection(collection_id: str) -> List[Text]:
return await Text.get_all_texts_by_collection_id(collection_id=collection_id)
async def get_all_recitation_texts_by_collection(collection_id: str, language: str) -> List[Text]:
return await Text.get_all_recitation_texts_by_collection_id(collection_id=collection_id, language=language)
-async def get_texts_by_group_id(group_id: str, skip: int, limit: int) -> List[TextDTO]:
- texts = await Text.get_texts_by_group_id(group_id=group_id, skip=skip, limit=limit)
- return [
- TextDTO(
- id=str(text.id),
- pecha_text_id=str(text.pecha_text_id),
- title=text.title,
- language=text.language,
- group_id=text.group_id,
- type=text.type,
- is_published=text.is_published,
- created_date=text.created_date,
- updated_date=text.updated_date,
- published_date=text.published_date,
- published_by=text.published_by,
- categories=text.categories,
- views=text.views,
- source_link=text.source_link,
- ranking=text.ranking,
- license=text.license
- )
- for text in texts
- ]
-
-async def get_texts_by_titles(titles: List[str]) -> List[Text]:
- if not titles:
- return []
- return await Text.find({"title": {"$in": titles}}).to_list()
-
-
async def get_all_texts_by_group_id(group_id: str) -> List[TextDTO]:
texts = await Text.get_all_texts_by_group_id(group_id=group_id)
return [
@@ -173,10 +137,6 @@ async def create_table_of_content_detail(table_of_content_request: TableOfConten
async def get_contents_by_id(text_id: str) -> List[TableOfContent]:
return await TableOfContent.get_table_of_contents_by_text_id(text_id=text_id)
-async def get_table_of_content_by_content_id(content_id: str, skip: int = None, limit: int = None) -> Optional[TableOfContent]:
- return await TableOfContent.get_table_of_content_by_content_id(content_id=content_id, skip=skip, limit=limit)
-
-
async def delete_table_of_content_by_text_id(text_id: str):
return await TableOfContent.delete_table_of_content_by_text_id(text_id=text_id)
diff --git a/pecha_api/texts/texts_service.py b/pecha_api/texts/texts_service.py
index 06159ed99..3f6e668e1 100644
--- a/pecha_api/texts/texts_service.py
+++ b/pecha_api/texts/texts_service.py
@@ -1,68 +1,33 @@
from fastapi import HTTPException
from starlette import status
+from pecha_api.config import get
from pecha_api.error_contants import ErrorConstants
-from pecha_api.http_message_utils import handle_http_status_error, handle_request_error
from .texts_repository import (
- get_all_texts_by_collection,
- get_texts_by_group_id,
- create_text,
create_table_of_content_detail,
get_contents_by_id,
- get_table_of_content_by_content_id,
delete_table_of_content_by_text_id,
update_text_details_by_id,
delete_text_by_id,
- get_all_texts_by_collection,
get_all_recitation_texts_by_collection,
get_texts_by_pecha_text_ids,
- get_texts_by_titles,
- get_all_texts_by_group_id
-
)
from .texts_response_models import (
TableOfContent,
TableOfContentType,
- DetailTableOfContentResponse,
- TableOfContentResponse,
TextDTO,
TextSegment,
- TextVersionResponse,
- TextVersion,
- TextsCategoryResponse,
- CreateTextRequest,
- TextDetailsRequest,
- UpdateTextRequest,
- TextDetailsRequest,
Section,
- DetailTableOfContentResponse,
+ UpdateTextRequest,
TextsByPechaTextIdsRequest,
- TitleSearchResult,
- LanguageResponse,
- AvailableLanguage,
- VersionDetail,
- VersionsResponse
)
-from pecha_api.recitations.recitations_response_models import(
- RecitationDTO,
- RecitationsResponse
+from pecha_api.recitations.recitations_response_models import (
+ RecitationDTO,
+ RecitationsResponse
)
-from pecha_api.constants import Constants
-from pecha_api.plans.response_message import (
- EXTERNAL_PECHA_API_URL_NOT_CONFIGURED,
- TITLE_OR_AUTHOR_QUERY_REQUIRED,
-)
-
-from .groups.groups_service import (
- validate_group_exists
-)
-from .segments.segments_models import Segment
from pecha_api.texts.texts_cache_service import (
- get_text_by_text_id_or_collection_cache,
- set_text_by_text_id_or_collection_cache,
- set_text_versions_by_group_id_cache,
update_text_details_cache,
invalidate_text_cache_on_update
)
@@ -71,135 +36,13 @@
from .texts_utils import TextUtils
from pecha_api.users.users_service import validate_user_exists
-from pecha_api.collections.collections_service import get_collection
-from pecha_api.users.users_service import (
- validate_user_exists
-)
from .segments.segments_utils import SegmentUtils
-from typing import List, Dict, Optional, Tuple, Set
-from pecha_api.config import get
+from typing import List, Dict, Optional
from pecha_api.utils import Utils
-from .texts_enums import PaginationDirection, TextType, TextTypes
+from .texts_enums import TextType
import logging
-import httpx
-
-EXTERNAL_TITLE_SEARCH_API_URL = get("EXTERNAL_TITLE_SEARCH_API_URL")
-ACCEPT_JSON_HEADER = {"Accept": "application/json"}
-
-
-def extract_title_for_language(title_payload: object, language: Optional[str]) -> Optional[str]:
- if isinstance(title_payload, dict):
- if language and isinstance(title_payload.get(language), str):
- return title_payload.get(language)
- for value in title_payload.values():
- if isinstance(value, str) and value.strip():
- return value
- return None
- if isinstance(title_payload, str):
- return title_payload.strip() or None
- return None
-
-
-async def get_text_by_text_id_or_collection(
- text_id: str,
- collection_id: Optional[str] = None,
- language: Optional[str] = None,
- skip: int = 0,
- limit: int = 10
-) -> TextsCategoryResponse | TextDTO:
- if language is None:
- language = get("DEFAULT_LANGUAGE")
-
- cached_data: TextsCategoryResponse | TextDTO = await get_text_by_text_id_or_collection_cache(
- text_id = text_id,
- collection_id = collection_id,
- language = language,
- skip = skip,
- limit = limit,
- cache_type = CacheType.TEXTS_BY_ID_OR_COLLECTION
- )
-
- if cached_data is not None:
- return cached_data
-
- if collection_id is not None:
- collection = await get_collection(collection_id=collection_id, language=language)
- texts, total_unique_group_ids = await _get_texts_by_collection_id(collection_id=collection_id, language=language, skip=skip, limit=limit)
-
- response = TextsCategoryResponse(
- collection=collection,
- texts=texts,
- total=total_unique_group_ids,
- skip=skip,
- limit=limit
- )
- else:
- response = await TextUtils.get_text_detail_by_id(text_id=text_id)
-
- await set_text_by_text_id_or_collection_cache(
- text_id = text_id,
- collection_id = collection_id,
- language = language,
- skip = skip,
- limit = limit,
- cache_type = CacheType.TEXTS_BY_ID_OR_COLLECTION,
- data = response
- )
- return response
-
-
-async def get_titles_and_ids_by_query(
- title: Optional[str],
- author: Optional[str],
- limit: int,
- offset: int
-) -> List[TitleSearchResult]:
- if not title and not author:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=TITLE_OR_AUTHOR_QUERY_REQUIRED
- )
- if not EXTERNAL_TITLE_SEARCH_API_URL:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=EXTERNAL_PECHA_API_URL_NOT_CONFIGURED
- )
-
- params: Dict[str, object] = {"limit": limit, "offset": offset}
- if title:
- params["title"] = title
- if author:
- params["author"] = author
-
- endpoint = f"{EXTERNAL_TITLE_SEARCH_API_URL}/v2/texts"
- try:
- async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
- response = await client.get(endpoint, headers=ACCEPT_JSON_HEADER, params=params)
- response.raise_for_status()
- data = response.json()
- except httpx.HTTPStatusError as e:
- handle_http_status_error(e)
- except httpx.RequestError as e:
- handle_request_error(e)
-
- titles = []
- for item in data or []:
- if not isinstance(item, dict):
- continue
- language = item.get("language")
- title_value = extract_title_for_language(item.get("title"), language)
- if title_value:
- titles.append(title_value)
-
- unique_titles = list(dict.fromkeys(titles))
- if not unique_titles:
- return []
-
- texts = await get_texts_by_titles(titles=unique_titles)
- return [TitleSearchResult(id=str(text.id), title=text.title) for text in texts]
-
async def get_table_of_contents_by_text_id(text_id: str, language: str = None, skip: int = 0, limit: int = 10) -> TableOfContentResponse:
@@ -256,138 +99,8 @@ def _get_paginated_sections(sections: List[Section], skip: int, limit: int) -> L
return filtered_sections[skip_index:limit_index]
-async def remove_table_of_content_by_text_id(text_id: str):
- is_valid_text = await TextUtils.validate_text_exists(text_id=text_id)
- if not is_valid_text:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE)
- return await delete_table_of_content_by_text_id(text_id=text_id)
-
-
-# NEW TEXT DETAILS SERVICE
-async def get_text_details_by_text_id(
- text_id: str,
- text_details_request: TextDetailsRequest
-) -> DetailTableOfContentResponse:
-
- await _validate_text_detail_request(
- text_id=text_id,
- text_details_request=text_details_request
- )
- selected_text = await TextUtils.get_text_detail_by_id(text_id=text_id)
-
- table_of_content: TableOfContent = await _receive_table_of_content(
- text_id=text_id,
- text_details_request=text_details_request
- )
- segments_with_position: List[Tuple[str, int]] = _get_segments_with_position_(
- table_of_content=table_of_content,
- )
- total_segments = len(segments_with_position)
- trimmed_segment_dict = _get_trimmed_segment_dict_(
- segments_with_position=segments_with_position,
- segment_id=text_details_request.segment_id,
- direction=text_details_request.direction,
- size=text_details_request.size
- )
- current_segment_position = trimmed_segment_dict.get(text_details_request.segment_id)
- paginated_table_of_content: TableOfContent = _generate_paginated_table_of_content_by_segments_(
- table_of_content = table_of_content,
- segment_dict = trimmed_segment_dict
- )
-
- detail_table_of_content: DetailTableOfContentResponse = await _mapping_table_of_content(
- text=selected_text,
- table_of_content=paginated_table_of_content,
- version_id=text_details_request.version_id,
- size=text_details_request.size,
- total_segments=total_segments,
- current_segment_position=current_segment_position,
- pagination_direction=text_details_request.direction
- )
-
- return detail_table_of_content
-
-
-async def get_text_versions_by_group_id(text_id: str, language: str, skip: int, limit: int) -> TextVersionResponse:
- '''
- This function will first retrive the group_id from the text_id details
- It will retrieve all the texts with same group_id
- Then root text will be determined by the language provied
- Left texts will be considered as versions
- '''
- if language is None:
- language = get("DEFAULT_LANGUAGE")
-
- # cached_data: TextVersionResponse = await get_text_versions_by_group_id_cache(
- # text_id = text_id,
- # language = language,
- # skip = skip,
- # limit = limit,
- # cache_type = CacheType.TEXT_VERSIONS
- # )
- # if cached_data is not None:
- # return cached_data
-
- root_text = await TextUtils.get_text_detail_by_id(text_id=text_id)
- group_id = root_text.group_id
- texts = await get_texts_by_group_id(group_id=group_id, skip=skip, limit=limit)
- filtered_text_on_root_and_version = TextUtils.filter_text_on_root_and_version(texts=texts, language=language)
- root_text = filtered_text_on_root_and_version[TextType.ROOT_TEXT.value]
- versions = filtered_text_on_root_and_version[TextTypes.VERSIONS.value]
- versions_table_of_content_id_dict: Dict[str, List[str]] = await _get_table_of_content_by_version_text_id(versions=versions)
- list_of_version = _get_list_of_text_version_response_model(versions=versions, versions_table_of_content_id_dict=versions_table_of_content_id_dict)
- response = TextVersionResponse(
- text=root_text,
- versions=list_of_version
- )
-
- await set_text_versions_by_group_id_cache(
- text_id = text_id,
- language = language,
- skip = skip,
- limit = limit,
- cache_type = CacheType.TEXT_VERSIONS,
- data = response
- )
-
- return response
-
-
-async def create_new_text(
- create_text_request: CreateTextRequest,
- token: str
-) -> TextDTO:
- is_valid_user = validate_user_exists(token=token)
- if is_valid_user:
- valid_group = await validate_group_exists(group_id=create_text_request.group_id)
- if not valid_group:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.GROUP_NOT_FOUND_MESSAGE)
- new_text = await create_text(create_text_request=create_text_request)
- return TextDTO(
- id=str(new_text.id),
- pecha_text_id=str(new_text.pecha_text_id),
- title=new_text.title,
- language=new_text.language,
- group_id=new_text.group_id,
- type=new_text.type,
- is_published=new_text.is_published,
- created_date=new_text.created_date,
- updated_date=new_text.updated_date,
- published_date=new_text.published_date,
- published_by=new_text.published_by,
- categories=new_text.categories,
- views=new_text.views,
- source_link=new_text.source_link,
- ranking=new_text.ranking,
- license=new_text.license
- )
- else:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE)
-
-
async def create_table_of_content(table_of_content_request: TableOfContent, token: str):
is_valid_user = validate_user_exists(token=token)
-
if is_valid_user:
await TextUtils.validate_text_exists(text_id=table_of_content_request.text_id)
new_table_of_content = await get_table_of_content_by_type(table_of_content=table_of_content_request)
@@ -399,100 +112,14 @@ async def create_table_of_content(table_of_content_request: TableOfContent, toke
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ErrorConstants.TOKEN_ERROR_MESSAGE)
-# PRIVATE FUNCTIONS
-
-async def get_table_of_content_by_type(table_of_content: TableOfContent):
- if table_of_content.type == TableOfContentType.TEXT:
- new_table_of_content = await replace_pecha_segment_id_with_segment_id(table_of_content=table_of_content)
- else:
- new_table_of_content = table_of_content
-
- return new_table_of_content
-
-
-async def replace_pecha_segment_id_with_segment_id(table_of_content: TableOfContent) -> TableOfContent:
- text_segments = await get_segments_by_text_id(text_id=table_of_content.text_id)
- segments_dict = {segment.pecha_segment_id: segment.id for segment in text_segments}
-
- new_toc = TableOfContent(
- text_id=table_of_content.text_id,
- type=table_of_content.type,
- sections=[]
- )
- new_sections = []
- for section in table_of_content.sections:
- new_segments = []
- for segment in section.segments:
- # db_segment = await Segment.get_segment_by_pecha_segment_id(pecha_segment_id=segment.pecha_segment_id)
- new_segments.append(
- TextSegment(
- segment_id=str(segments_dict[segment.segment_id]),
- segment_number=segment.segment_number
- )
- )
- new_section = Section(
- id=section.id,
- title=section.title,
- section_number=section.section_number,
- segments=new_segments
- )
- new_sections.append(new_section)
- new_toc.sections = new_sections
- return new_toc
-
-async def _mapping_table_of_content(
- text: TextDTO,
- table_of_content: TableOfContent,
- version_id: str,
- size: int,
- total_segments: int,
- current_segment_position: int,
- pagination_direction: PaginationDirection,
-) -> DetailTableOfContentResponse:
- detail_table_of_content = await SegmentUtils.get_mapped_segment_content_for_table_of_content(
- table_of_content=table_of_content,
- version_id=version_id
- )
- detail_table_of_content = DetailTableOfContentResponse(
- text_detail=text,
- content=detail_table_of_content,
- size=size,
- pagination_direction=pagination_direction,
- current_segment_position=current_segment_position,
- total_segments=total_segments
- )
- return detail_table_of_content
-
-
-async def _validate_text_detail_request(text_id: str, text_details_request: TextDetailsRequest) -> bool:
- # Check if text_id is provided
- if text_id is None:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=ErrorConstants.TEXT_OR_TERM_NOT_FOUND_MESSAGE
- )
-
- # Check if valid version_id is provided
- if text_details_request.version_id is not None:
- await TextUtils.validate_text_exists(
- text_id=text_details_request.version_id
- )
-
- # Check if valid segment_id is provided
- if text_details_request.segment_id is not None:
- await SegmentUtils.validate_segment_exists(
- segment_id=text_details_request.segment_id
- )
-
- await TextUtils.validate_text_exists(text_id=text_id)
-
async def get_root_text_by_collection_id(collection_id: str, language: str) -> Optional[tuple[str, str]]:
-
texts = await get_all_recitation_texts_by_collection(collection_id=collection_id, language=language)
grouped_texts = _group_texts_by_group_id(texts=texts, language=language)
recitation_text_list = []
for group_texts in grouped_texts.values():
- filter_text_base_on_group_id_type = await TextUtils.filter_text_base_on_group_id_type_and_language_preference(texts=group_texts, language=language)
+ filter_text_base_on_group_id_type = await TextUtils.filter_text_base_on_group_id_type_and_language_preference(
+ texts=group_texts, language=language
+ )
root_text = filter_text_base_on_group_id_type[TextType.ROOT_TEXT.value]
if root_text is None:
continue
@@ -500,112 +127,6 @@ async def get_root_text_by_collection_id(collection_id: str, language: str) -> O
return RecitationsResponse(recitations=recitation_text_list)
-def _group_texts_by_group_id(texts: List[TextDTO], language: str|None = None) -> Dict[str, List[TextDTO]]:
- texts_by_group_id = {}
- for text in texts:
- group_id = str(text.group_id)
- if group_id not in texts_by_group_id:
- texts_by_group_id[group_id] = []
- texts_by_group_id[group_id].append(text)
-
- # Sort texts within each group by language preference
- for group_id in texts_by_group_id:
- texts_by_group_id[group_id].sort(
- key=lambda text: TextUtils.get_language_priority(text.language, language)
- )
-
- return texts_by_group_id
-
-
-async def _get_texts_by_collection_id(collection_id: str, language: str, skip: int, limit: int) -> Tuple[List[TextDTO], int]:
- texts = await get_all_texts_by_collection(collection_id=collection_id)
- group_id_set = set()
- for text in texts:
- group_id_set.add(text.group_id)
- total_unique_group_ids = len(group_id_set)
- texts.sort(
- key=lambda text: TextUtils.get_language_priority(text.language, language)
- )
- track_skip = 0
- track_limit = 0
- text_list = []
- visited_group_ids = set()
- for text in texts:
- if text.group_id in visited_group_ids:
- continue
- elif track_skip < skip:
- track_skip += 1
- continue
- if text is not None:
- text_list.append(TextDTO(
- id=str(text.id),
- pecha_text_id=str(text.pecha_text_id),
- title=text.title,
- language=text.language,
- group_id=text.group_id,
- type="root_text",
- is_published=text.is_published,
- created_date=text.created_date,
- updated_date=text.updated_date,
- published_date=text.published_date,
- published_by=text.published_by,
- ))
- track_limit += 1
- if track_limit >= limit:
- break;
- visited_group_ids.add(text.group_id)
- return text_list, total_unique_group_ids
-
-
-async def _get_table_of_content_by_version_text_id(versions: List[TextDTO]) -> Dict[str, List[str]]:
- versions_table_of_content_id_dict = {}
- for version in versions:
- list_of_table_of_contents = await get_contents_by_id(text_id=str(version.id))
- list_of_table_of_contents_ids = []
- for table_of_content in list_of_table_of_contents:
- list_of_table_of_contents_ids.append(str(table_of_content.id))
- versions_table_of_content_id_dict[str(version.id)] = list_of_table_of_contents_ids
- return versions_table_of_content_id_dict
-
-
-async def get_commentaries_by_text_id(text_id: str, skip: int, limit: int) -> List[TextDTO]:
- is_valid_text = await TextUtils.validate_text_exists(text_id=text_id)
- if not is_valid_text:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE)
-
- root_text = await TextUtils.get_text_detail_by_id(text_id=text_id)
- group_id = root_text.group_id
-
- commentaries = await TextUtils.get_commentaries_by_text_type(text_type="commentary", language=root_text.language, skip=skip, limit=limit)
- final_commentary = []
- for commentary in commentaries:
- if commentary.categories and group_id in commentary.categories:
- final_commentary.append(commentary)
- return final_commentary
-
-
-def _get_list_of_text_version_response_model(versions: List[TextDTO], versions_table_of_content_id_dict: Dict[str, List[str]]) -> List[TextVersion]:
- list_of_version = [
- TextVersion(
- id=str(version.id),
- title=version.title,
- language=version.language,
- type=version.type,
- group_id=version.group_id,
- table_of_contents=versions_table_of_content_id_dict.get(str(version.id), []),
- is_published=version.is_published,
- created_date=version.created_date,
- updated_date=version.updated_date,
- published_date=version.published_date,
- published_by=version.published_by,
- source_link=version.source_link,
- ranking=version.ranking,
- license=version.license
- )
- for version in versions
- ]
- return list_of_version
-
async def update_text_details(text_id: str, update_text_request: UpdateTextRequest):
is_valid_text = await TextUtils.validate_text_exists(text_id=text_id)
if not is_valid_text:
@@ -615,317 +136,63 @@ async def update_text_details(text_id: str, update_text_request: UpdateTextReque
text_details.title = update_text_request.title
text_details.is_published = update_text_request.is_published
- # Update the text details in the database
updated_text = await update_text_details_by_id(text_id=text_id, update_text_request=update_text_request)
- # Update the cache with the new text details
try:
await update_text_details_cache(text_id=text_id, updated_text_data=updated_text)
except Exception as e:
- # If cache update fails, log the error but don't fail the entire operation
- # Fallback to cache invalidation to ensure consistency
logging.exception(f"Failed to update cache for text_id {text_id}")
await invalidate_text_cache_on_update(text_id=text_id)
return updated_text
-async def delete_text_by_text_id(text_id: str):
- is_valid_text = await TextUtils.validate_text_exists(text_id=text_id)
- if not is_valid_text:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE)
- await delete_text_by_id(text_id=text_id)
-
+# PRIVATE FUNCTIONS
+async def get_table_of_content_by_type(table_of_content: TableOfContent):
+ if table_of_content.type == TableOfContentType.TEXT:
+ return await replace_pecha_segment_id_with_segment_id(table_of_content=table_of_content)
+ return table_of_content
-def _filter_single_section_(section: Section, wanted_segment_ids: Set[str]) -> Section | None:
- kept_segments = []
- for segment in section.segments:
- if segment.segment_id in wanted_segment_ids:
- kept_segments.append(segment)
-
- kept_subsections = []
- if section.sections:
- for subsection in section.sections:
- filtered_subsection = _filter_single_section_(subsection, wanted_segment_ids)
- if filtered_subsection is not None:
- kept_subsections.append(filtered_subsection)
-
- has_wanted_segments = len(kept_segments) > 0
- has_valid_subsections = len(kept_subsections) > 0
-
- if has_wanted_segments or has_valid_subsections:
- new_section = Section(
- id=section.id,
- title=section.title,
- section_number=section.section_number,
- parent_id=section.parent_id,
- segments=kept_segments,
- sections=kept_subsections if kept_subsections else None,
- created_date=section.created_date,
- updated_date=section.updated_date,
- published_date=section.published_date
- )
- return new_section
- else:
- return None
-def _generate_paginated_table_of_content_by_segments_(
- table_of_content: TableOfContent,
- segment_dict: Dict[str, int]
-) -> TableOfContent:
+async def replace_pecha_segment_id_with_segment_id(table_of_content: TableOfContent) -> TableOfContent:
+ text_segments = await get_segments_by_text_id(text_id=table_of_content.text_id)
+ segments_dict = {segment.pecha_segment_id: segment.id for segment in text_segments}
- wanted_segment_ids = set(segment_dict.keys())
-
- filtered_sections = []
- for section in table_of_content.sections:
- filtered_section = _filter_single_section_(section=section, wanted_segment_ids=wanted_segment_ids)
- if filtered_section is not None:
- filtered_sections.append(filtered_section)
-
- paginated_table_of_content = TableOfContent(
- id=str(table_of_content.id),
+ new_toc = TableOfContent(
text_id=table_of_content.text_id,
type=table_of_content.type,
- sections=filtered_sections
+ sections=[]
)
-
- return paginated_table_of_content
-
-
-def _get_trimmed_segment_dict_(segments_with_position:List[Tuple[str,int]], segment_id: str, direction: PaginationDirection, size: int) -> Dict[str, int]:
-
-
- dict_segment_id_with_position: Dict[str, int] = dict(segments_with_position)
-
- segment_position = dict_segment_id_with_position.get(segment_id) - 1
-
- total_segments = len(segments_with_position)
-
- if direction == PaginationDirection.NEXT:
- trimmed_segments_with_position = segments_with_position[segment_position : min(segment_position + size, total_segments)]
-
- else:
- trimmed_segments_with_position = segments_with_position[max(0, segment_position - size + 1) : segment_position + 1]
-
- trimmed_segments_with_position = dict(trimmed_segments_with_position)
-
- return trimmed_segments_with_position
-
-def _get_segments_with_position_(table_of_content: TableOfContent) -> List[Tuple[str, int]]:
- segments_with_position: List[Tuple[str, int]] = []
- position = 1
-
- def get_segment_from_section(section: Section):
- nonlocal position
-
- for segment in section.segments:
- segments_with_position.append((segment.segment_id, position))
- position += 1
-
- if section.sections:
- for sub_section in section.sections:
- get_segment_from_section(sub_section)
-
+ new_sections = []
for section in table_of_content.sections:
- get_segment_from_section(section)
- return segments_with_position
-
-
-async def _receive_table_of_content(text_id: str, text_details_request: TextDetailsRequest) -> TableOfContent:
- table_of_content = None
- if text_details_request.content_id is not None and text_details_request.segment_id is not None:
- table_of_content:TableOfContent = await get_table_of_content_by_content_id(
- content_id=text_details_request.content_id
- )
- elif text_details_request.segment_id is not None:
- table_of_contents: List[TableOfContent] = await get_contents_by_id(text_id=text_id)
- table_of_content: TableOfContent = _search_table_of_content_where_segment_id_exists(table_of_contents=table_of_contents, segment_id=text_details_request.segment_id)
- else:
- table_of_content = await get_contents_by_id(text_id=text_id)
- segment_id, table_of_content = _get_first_segment_and_table_of_content_(table_of_contents=table_of_content)
- text_details_request.segment_id = segment_id
-
- if table_of_content is None:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE
+ new_segments = [
+ TextSegment(
+ segment_id=str(segments_dict[segment.segment_id]),
+ segment_number=segment.segment_number
+ )
+ for segment in section.segments
+ ]
+ new_section = Section(
+ id=section.id,
+ title=section.title,
+ section_number=section.section_number,
+ segments=new_segments
)
- return table_of_content
-
-def _get_first_segment_and_table_of_content_(table_of_contents: List[TableOfContent]) -> tuple[str | None, TableOfContent | None]:
- def find_first_segment(sections: list) -> str | None:
- for section in sections:
- if section.segments:
- return section.segments[0].segment_id
- if section.sections:
- result = find_first_segment(section.sections)
- if result:
- return result
- return None
-
- for table_of_content in table_of_contents:
- segment_id = find_first_segment(sections=table_of_content.sections)
- if segment_id:
- return segment_id, table_of_content
- return None, None
-
-def _search_section_(sections: List[Section], segment_id: str) -> bool:
- for section in sections:
- for segment in section.segments:
- if segment.segment_id == segment_id:
- return True
-
- if section.sections:
- result = _search_section_(sections=section.sections, segment_id=segment_id)
- if result:
- return result
-
- return False
-
-def _search_table_of_content_where_segment_id_exists(table_of_contents: List[TableOfContent], segment_id: str) -> TableOfContent:
- for table_of_content in table_of_contents:
- result = _search_section_(sections=table_of_content.sections, segment_id=segment_id)
- if result:
- return table_of_content
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE
- )
-
-async def get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request: TextsByPechaTextIdsRequest) -> Optional[List[TextDTO]]:
- pecha_text_ids = texts_by_pecha_text_ids_request.pecha_text_ids
- texts = await get_texts_by_pecha_text_ids(pecha_text_ids=pecha_text_ids)
- return [TextDTO(
- id=str(text.id),
- pecha_text_id=str(text.pecha_text_id),
- title=text.title,
- language=text.language,
- group_id=text.group_id,
- type=text.type,
- is_published=text.is_published,
- created_date=text.created_date,
- updated_date=text.updated_date,
- published_date=text.published_date,
- published_by=text.published_by,
- categories=text.categories,
- views=text.views,
- source_link=text.source_link,
- ranking=text.ranking,
- license=text.license
- ) for text in texts]
-
+ new_sections.append(new_section)
+ new_toc.sections = new_sections
+ return new_toc
-async def get_text_languages(text_id: str) -> LanguageResponse:
- is_valid_text: bool = await TextUtils.validate_text_exists(text_id=text_id)
- if not is_valid_text:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE
- )
-
- text_detail: TextDTO = await TextUtils.get_text_detail_by_id(text_id=text_id)
- group_id: str = text_detail.group_id
-
- texts = await get_all_texts_by_group_id(group_id=group_id)
-
- language_counts: Dict[str, int] = {}
+def _group_texts_by_group_id(texts: List[TextDTO], language: str | None = None) -> Dict[str, List[TextDTO]]:
+ texts_by_group_id: Dict[str, List[TextDTO]] = {}
for text in texts:
- if text.language:
- language_counts[text.language] = language_counts.get(text.language, 0) + 1
-
- available_languages = [
- AvailableLanguage(
- language=lang,
- language_code=lang,
- version_count=count
- )
- for lang, count in language_counts.items()
- ]
-
- return LanguageResponse(
- text_id=text_id,
- title=text_detail.title,
- available_languages=available_languages
- )
-
+ group_id = str(text.group_id)
+ if group_id not in texts_by_group_id:
+ texts_by_group_id[group_id] = []
+ texts_by_group_id[group_id].append(text)
-async def get_language_versions(text_id: str, language: str) -> VersionsResponse:
- is_valid_text: bool = await TextUtils.validate_text_exists(text_id=text_id)
- if not is_valid_text:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE
- )
-
- text_detail: TextDTO = await TextUtils.get_text_detail_by_id(text_id=text_id)
- group_id: str = text_detail.group_id
-
- texts: List[TextDTO] = await get_all_texts_by_group_id(group_id=group_id)
-
- filtered_texts = [text for text in texts if text.language == language]
-
- versions_table_of_content_id_dict: Dict[str, List[str]] = await _get_table_of_content_by_version_text_id(versions=filtered_texts)
-
- available_versions = [
- VersionDetail(
- id=str(text.id),
- title=text.title,
- parent_id=None,
- priority=None,
- language=text.language,
- type=text.type,
- group_id=text.group_id,
- table_of_contents=versions_table_of_content_id_dict.get(str(text.id), []),
- is_published=text.is_published,
- created_date=text.created_date,
- updated_date=text.updated_date,
- published_date=text.published_date,
- published_by=text.published_by,
- source_link=text.source_link,
- ranking=text.ranking,
- license=text.license,
- is_selected=(str(text.id) == text_id)
+ for group_id in texts_by_group_id:
+ texts_by_group_id[group_id].sort(
+ key=lambda text: TextUtils.get_language_priority(text.language, language)
)
- for text in filtered_texts
- ]
-
- return VersionsResponse(
- text_id=text_id,
- language=language,
- available_versions=available_versions
- )
-
-
-async def get_version_info(version_id: str) -> VersionDetail:
- is_valid_text: bool = await TextUtils.validate_text_exists(text_id=version_id)
- if not is_valid_text:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=ErrorConstants.TEXT_NOT_FOUND_MESSAGE
- )
-
- text_detail: TextDTO = await TextUtils.get_text_detail_by_id(text_id=version_id)
-
- table_of_contents: List[TableOfContent] = await get_contents_by_id(text_id=version_id)
- table_of_content_ids = [str(toc.id) for toc in table_of_contents]
-
- return VersionDetail(
- id=str(text_detail.id),
- title=text_detail.title,
- parent_id=None,
- priority=None,
- language=text_detail.language,
- type=text_detail.type,
- group_id=text_detail.group_id,
- table_of_contents=table_of_content_ids,
- is_published=text_detail.is_published,
- created_date=text_detail.created_date,
- updated_date=text_detail.updated_date,
- published_date=text_detail.published_date,
- published_by=text_detail.published_by,
- source_link=text_detail.source_link,
- ranking=text_detail.ranking,
- license=text_detail.license,
- is_selected=True
- )
+ return texts_by_group_id
diff --git a/pecha_api/texts/texts_views.py b/pecha_api/texts/texts_views.py
deleted file mode 100644
index 5ecbbbaa6..000000000
--- a/pecha_api/texts/texts_views.py
+++ /dev/null
@@ -1,149 +0,0 @@
-from fastapi import APIRouter, Query, Depends
-from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
-from starlette import status
-
-from typing import Optional, Annotated, List
-
-from .texts_service import (
- get_table_of_contents_by_text_id,
- get_text_versions_by_group_id,
- create_new_text,
- get_text_by_text_id_or_collection,
- get_text_details_by_text_id,
- create_table_of_content,
- get_text_details_by_text_id,
- get_commentaries_by_text_id,
- get_titles_and_ids_by_query,
- get_text_languages,
- get_language_versions,
- get_version_info
-)
-from .texts_response_models import (
- CreateTextRequest,
- TableOfContentResponse,
- TextDetailsRequest,
- TableOfContent,
- TextDTO,
- TextVersionResponse,
- DetailTableOfContentResponse,
- TextDetailsRequest,
- TitleSearchResult,
- LanguageResponse,
- VersionsResponse,
- VersionDetail
-)
-
-oauth2_scheme = HTTPBearer()
-text_router = APIRouter(
- prefix="/texts",
- tags=["Texts"]
-)
-
-
-@text_router.get("", status_code=status.HTTP_200_OK)
-async def get_text(
- text_id: Optional[str] = Query(default=None),
- collection_id: Optional[str] = Query(default=None),
- language: str = Query(default=None),
- skip: int = Query(default=0),
- limit: int = Query(default=10)
-):
- return await get_text_by_text_id_or_collection(
- text_id=text_id,
- collection_id=collection_id,
- language=language,
- skip=skip,
- limit=limit
- )
-
-
-@text_router.post(
- "",
- status_code=status.HTTP_201_CREATED,
- summary="Create text",
- description="Create a new text resource.",
-)
-async def create_text(
- create_text_request: CreateTextRequest,
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)],
-) -> TextDTO:
- return await create_new_text(
- create_text_request=create_text_request,
- token=authentication_credential.credentials
- )
-
-
-@text_router.get("/{text_id}/versions", status_code=status.HTTP_200_OK)
-async def get_versions(
- text_id: str,
- language: str = Query(default=None),
- skip: int = Query(default=0),
- limit: int = Query(default=10)
-) -> TextVersionResponse:
- return await get_text_versions_by_group_id(
- text_id=text_id,
- language=language, skip=skip, limit=limit
- )
-
-
-@text_router.get("/{text_id}/languages", status_code=status.HTTP_200_OK)
-async def get_languages(text_id: str) -> LanguageResponse:
-
- return await get_text_languages(text_id=text_id)
-
-
-@text_router.get("/versions/{version_id}/info", status_code=status.HTTP_200_OK)
-async def get_version_info_endpoint(version_id: str) -> VersionDetail:
- return await get_version_info(version_id=version_id)
-
-
-@text_router.get("/{text_id}/languages/{language}/versions", status_code=status.HTTP_200_OK)
-async def get_versions_by_language(text_id: str, language: str) -> VersionsResponse:
- return await get_language_versions(text_id=text_id, language=language)
-
-
-@text_router.get("/{text_id}/contents", status_code=status.HTTP_200_OK)
-async def get_contents(
- text_id: str,
- language: str = Query(default=None),
- skip: int = Query(default=0),
- limit: int = Query(default=10)
-) -> TableOfContentResponse:
- return await get_table_of_contents_by_text_id(text_id=text_id, language=language, skip=skip, limit=limit)
-
-
-@text_router.post("/{text_id}/details", status_code=status.HTTP_200_OK)
-async def get_contents_with_details(
- text_id: str,
- text_details_request: TextDetailsRequest
-) -> DetailTableOfContentResponse:
- return await get_text_details_by_text_id(text_id=text_id, text_details_request=text_details_request)
-
-@text_router.post("/table-of-content", status_code=status.HTTP_200_OK)
-async def create_table_of_content_request(
- authentication_credential: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)],
- table_of_content_request: TableOfContent
-):
- return await create_table_of_content(table_of_content_request=table_of_content_request, token=authentication_credential.credentials)
-
-@text_router.get("/{text_id}/commentaries", status_code=status.HTTP_200_OK)
-async def get_commentaries(
- text_id: str,
- skip: int = Query(default=0, ge=0),
- limit: int = Query(default=10, le=100)
-) -> List[TextDTO]:
- return await get_commentaries_by_text_id(text_id=text_id, skip=skip, limit=limit)
-
-@text_router.get("/title-search", status_code=status.HTTP_200_OK)
-async def search_titles(
- title: Optional[str] = Query(default=None),
- author: Optional[str] = Query(default=None),
- limit: int = Query(default=20),
- offset: int = Query(default=0)
-) -> List[TitleSearchResult]:
- return await get_titles_and_ids_by_query(
- title=title,
- author=author,
- limit=limit,
- offset=offset
- )
\ No newline at end of file
diff --git a/tests/texts/test_text_cache_service.py b/tests/texts/test_text_cache_service.py
index d5d73ea8b..011b466f1 100644
--- a/tests/texts/test_text_cache_service.py
+++ b/tests/texts/test_text_cache_service.py
@@ -2,14 +2,6 @@
from unittest.mock import patch, Mock, AsyncMock
from pecha_api.texts.texts_cache_service import (
- get_text_details_cache,
- set_text_details_cache,
- get_text_by_text_id_or_collection_cache,
- set_text_by_text_id_or_collection_cache,
- get_table_of_contents_by_text_id_cache,
- set_table_of_contents_by_text_id_cache,
- get_text_versions_by_group_id_cache,
- set_text_versions_by_group_id_cache,
update_text_details_cache,
invalidate_text_cache_on_update,
set_text_details_by_id_cache,
@@ -17,373 +9,13 @@
delete_text_details_by_id_cache
)
from pecha_api.texts.texts_response_models import (
- DetailTableOfContent,
- DetailSection,
- DetailTextSegment,
TextDTO,
TableOfContent,
TableOfContentType,
- Section,
- TextVersionResponse,
- TextsCategoryResponse,
- DetailTableOfContentResponse,
- TableOfContentResponse
)
-from pecha_api.collections.collections_response_models import CollectionModel
from pecha_api.cache.cache_enums import CacheType
-@pytest.mark.asyncio
-async def test_get_text_details_cache_empty_cache():
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None):
-
- response = await get_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, cache_type=CacheType.TEXT_DETAIL)
-
- assert response is None
-
-@pytest.mark.asyncio
-async def test_set_text_details_cache_success():
- mock_text_detail = DetailTableOfContent(
- id="id_1",
- text_id="text_id_1",
- sections=[
- DetailSection(
- id=f"id_{i}",
- title=f"section_{i}",
- section_number=i,
- parent_id="parent_id_1",
- segments=[
- DetailTextSegment(
- segment_id=f"segment_id_{i}",
- segment_number=1,
- content=f"content_{i}",
- translation=None
- )
- for i in range(1,6)
- ],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- for i in range(1,6)
- ]
- )
-
- with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock):
-
- await set_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, data=mock_text_detail, cache_type=CacheType.TEXT_DETAIL)
-
-
-@pytest.mark.asyncio
-async def test_get_text_details_cache_for_text_details_response():
- mock_cache_data = DetailTableOfContent(
- id="id_1",
- text_id="text_id_1",
- sections=[
- DetailSection(
- id=f"id_{i}",
- title=f"section_{i}",
- section_number=i,
- parent_id="parent_id_1",
- segments=[
- DetailTextSegment(
- segment_id=f"segment_id_{i}",
- segment_number=1,
- content=f"content_{i}",
- translation=None
- )
- for i in range(1,6)
- ],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- for i in range(1,6)
- ]
- )
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data):
- response = await get_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, cache_type=CacheType.TEXT_DETAIL)
-
- assert response is not None
- assert isinstance(response, DetailTableOfContent)
- assert response.id == "id_1"
- assert response.text_id == "text_id_1"
- assert len(response.sections) == 5
- assert response.sections[0].id == "id_1"
-
-@pytest.mark.asyncio
-async def test_set_text_details_cache_for_text_details_response():
- mock_cache_data = DetailTableOfContent(
- id="id_1",
- text_id="text_id_1",
- sections=[
- DetailSection(
- id=f"id_{i}",
- title=f"section_{i}",
- section_number=i,
- parent_id="parent_id_1",
- segments=[
- DetailTextSegment(
- segment_id=f"segment_id_{i}",
- segment_number=1,
- content=f"content_{i}",
- translation=None
- )
- for i in range(1,6)
- ],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- for i in range(1,6)
- ]
- )
- with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock):
- await set_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXT_DETAIL)
-
-@pytest.mark.asyncio
-async def test_get_text_by_text_id_or_collection_cache_empty_cache():
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None):
- response = await get_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION)
-
-
- assert response is None
-
-@pytest.mark.asyncio
-async def test_get_text_by_text_id_or_collection_cache_for_text_by_text_id_or_collection_response():
- mock_cache_data = TextDTO(
- id="id_1",
- title="title_1",
- language="en",
- group_id="group_id_1",
- type="type_1",
- is_published=True,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="published_by_1",
- categories=[],
- views=0
- )
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data):
-
- response = await get_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION)
-
-
- assert response is not None
- assert isinstance(response, TextDTO)
- assert response.id == "id_1"
-
-@pytest.mark.asyncio
-async def test_set_text_by_text_id_or_collection_cache_for_text_by_text_id_or_collection_response():
- mock_cache_data = TextDTO(
- id="id_1",
- title="title_1",
- language="en",
- group_id="group_id_1",
- type="type_1",
- is_published=True,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="published_by_1",
- categories=[],
- views=0
- )
-
- with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock):
- await set_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION)
-
-@pytest.mark.asyncio
-async def test_get_table_of_contents_by_text_id_cache_empty_cache():
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None):
- response = await get_table_of_contents_by_text_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS)
-
- assert response is None
-
-@pytest.mark.asyncio
-async def test_get_table_of_contents_by_text_id_cache_for_table_of_contents_by_text_id_response():
- mock_cache_data = TableOfContent(
- id="table_of_content_id",
- text_id="text_id_1",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="id_1",
- title="section_1",
- section_number=1,
- parent_id="id_1",
- segments=[],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data):
-
- response = await get_table_of_contents_by_text_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS)
-
- assert response is not None
- assert isinstance(response, TableOfContent)
- assert response.id == "table_of_content_id"
- assert response.text_id == "text_id_1"
- assert len(response.sections) == 1
- assert response.sections[0].id == "id_1"
-
-@pytest.mark.asyncio
-async def test_set_table_of_contents_by_text_id_cache_for_table_of_contents_by_text_id_response():
- mock_cache_data = TableOfContent(
- id="table_of_content_id",
- text_id="text_id_1",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="id_1",
- title="section_1",
- section_number=1,
- parent_id="id_1",
- segments=[],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
- with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock):
- await set_table_of_contents_by_text_id_cache(text_id="text_id", data=mock_cache_data, language="en", skip=0, limit=10, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS)
-
-@pytest.mark.asyncio
-async def test_get_text_versions_by_group_id_cache_empty_cache():
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=None):
- response = await get_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_VERSIONS)
-
- assert response is None
-
-@pytest.mark.asyncio
-async def test_get_text_versions_by_group_id_cache_for_text_versions_by_group_id_response():
- mock_cache_data = TextVersionResponse(
- text=TextDTO(
- id="id_1",
- title="title_1",
- language="en",
- group_id="group_id_1",
- type="type_1",
- is_published=True,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="published_by_1",
- categories=[],
- views=0
- ),
- versions=[]
- )
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_data):
- response = await get_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_VERSIONS)
-
- assert response is not None
- assert isinstance(response, TextVersionResponse)
-
-@pytest.mark.asyncio
-async def test_set_text_versions_by_group_id_cache_for_text_versions_by_group_id_response():
- mock_cache_data = TextVersionResponse(
- text=TextDTO(
- id="id_1",
- title="title_1",
- language="en",
- group_id="group_id_1",
- type="type_1",
- is_published=True,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="published_by_1",
- categories=[],
- views=0
- ),
- versions=[]
- )
- with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock):
- await set_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXT_VERSIONS)
-
-
-@pytest.mark.asyncio
-async def test_set_text_by_text_id_or_collection_cache_success():
- mock_data = TextsCategoryResponse(
- collection=CollectionModel(
- id="id_1",
- title="title_1",
- description="description_1",
- language="en",
- slug="slug_1",
- has_child=False
- ),
- texts=[],
- total=0,
- skip=0,
- limit=10
- )
-
- with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock):
-
- await set_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, data=mock_data, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION)
-
-@pytest.mark.asyncio
-async def test_set_table_of_contents_by_text_id_cache_success():
- mock_cache_data = TableOfContent(
- id="table_of_content_id",
- text_id="text_id_1",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="id_1",
- title="section_1",
- section_number=1,
- parent_id="id_1",
- segments=[],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock):
-
- await set_table_of_contents_by_text_id_cache(text_id="text_id", language="en", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS)
-
-@pytest.mark.asyncio
-async def test_set_text_versions_by_group_id_cache_success():
- mock_cache_data = TextVersionResponse(
- text=TextDTO(
- id="id_1",
- title="title_1",
- language="en",
- group_id="group_id_1",
- type="type_1",
- is_published=True,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="published_by_1",
- categories=[],
- views=0
- ),
- versions=[]
- )
-
- with patch("pecha_api.texts.texts_cache_service.set_cache", new_callable=AsyncMock):
-
- await set_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, data=mock_cache_data, cache_type=CacheType.TEXT_VERSIONS)
-
@pytest.mark.asyncio
async def test_update_text_details_cache_success():
#Test successful update of text details cache
@@ -560,125 +192,6 @@ def mock_generate_hash_key(payload):
]
assert kwargs["hash_keys"] == expected_hash_keys
-@pytest.mark.asyncio
-async def test_get_text_details_cache_with_dict_response():
- #Test get_text_details_cache when cache returns dict and needs conversion#
- mock_cache_dict = {
- "text_detail": {
- "id": "text_id_1",
- "title": "Test Title",
- "language": "en",
- "group_id": "group_id_1",
- "type": "root_text",
- "is_published": True,
- "created_date": "2025-03-16 04:40:54.757652",
- "updated_date": "2025-03-16 04:40:54.757652",
- "published_date": "2025-03-16 04:40:54.757652",
- "published_by": "user_1",
- "categories": [],
- "views": 0
- },
- "content": {
- "id": "id_1",
- "text_id": "text_id_1",
- "sections": []
- },
- "size": 20,
- "pagination_direction": "next",
- "current_segment_position": 1,
- "total_segments": 100
- }
-
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict):
- response = await get_text_details_cache(text_id="text_id", content_id="content_id", version_id="version_id", skip=0, limit=10, cache_type=CacheType.TEXT_DETAIL)
-
- assert response is not None
- assert isinstance(response, DetailTableOfContentResponse)
- assert response.content.id == "id_1"
-
-@pytest.mark.asyncio
-async def test_get_text_by_text_id_or_collection_cache_with_dict_response():
- #Test get_text_by_text_id_or_collection_cache when cache returns dict and needs conversion#
- mock_cache_dict = {
- "collection": {
- "id": "id_1",
- "title": "title_1",
- "description": "description_1",
- "language": "en",
- "slug": "slug_1",
- "has_child": False
- },
- "texts": [],
- "total": 0,
- "skip": 0,
- "limit": 10
- }
-
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict):
- response = await get_text_by_text_id_or_collection_cache(text_id="text_id", collection_id="collection_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXTS_BY_ID_OR_COLLECTION)
-
- assert response is not None
- assert isinstance(response, TextsCategoryResponse)
-
-@pytest.mark.asyncio
-async def test_get_table_of_contents_by_text_id_cache_with_dict_response():
- #Test get_table_of_contents_by_text_id_cache when cache returns dict and needs conversion#
- mock_cache_dict = {
- "text_detail": {
- "id": "text_id_1",
- "title": "Test Title",
- "language": "en",
- "group_id": "group_id_1",
- "type": "root_text",
- "is_published": True,
- "created_date": "2025-03-16 04:40:54.757652",
- "updated_date": "2025-03-16 04:40:54.757652",
- "published_date": "2025-03-16 04:40:54.757652",
- "published_by": "user_1",
- "categories": [],
- "views": 0
- },
- "contents": [{
- "id": "table_of_content_id",
- "text_id": "text_id_1",
- "type": "text",
- "sections": []
- }]
- }
-
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict):
- response = await get_table_of_contents_by_text_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_TABLE_OF_CONTENTS)
-
- assert response is not None
- assert isinstance(response, TableOfContentResponse)
- assert response.contents[0].id == "table_of_content_id"
-
-@pytest.mark.asyncio
-async def test_get_text_versions_by_group_id_cache_with_dict_response():
- #Test get_text_versions_by_group_id_cache when cache returns dict and needs conversion#
- mock_cache_dict = {
- "text": {
- "id": "id_1",
- "title": "title_1",
- "language": "en",
- "group_id": "group_id_1",
- "type": "type_1",
- "is_published": True,
- "created_date": "2025-03-16 04:40:54.757652",
- "updated_date": "2025-03-16 04:40:54.757652",
- "published_date": "2025-03-16 04:40:54.757652",
- "published_by": "published_by_1",
- "categories": [],
- "views": 0
- },
- "versions": []
- }
-
- with patch("pecha_api.texts.texts_cache_service.get_cache_data", new_callable=AsyncMock, return_value=mock_cache_dict):
- response = await get_text_versions_by_group_id_cache(text_id="text_id", language="en", skip=0, limit=10, cache_type=CacheType.TEXT_VERSIONS)
-
- assert response is not None
- assert isinstance(response, TextVersionResponse)
@pytest.mark.asyncio
async def test_set_text_details_by_id_cache_success():
diff --git a/tests/texts/test_texts_service.py b/tests/texts/test_texts_service.py
index d8097484d..c491c665f 100644
--- a/tests/texts/test_texts_service.py
+++ b/tests/texts/test_texts_service.py
@@ -1,375 +1,27 @@
-from unittest.mock import AsyncMock, patch, MagicMock, Mock
-import httpx
+from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from uuid import uuid4
-from pecha_api.collections.collections_response_models import CollectionModel
import pytest
from pecha_api.texts.texts_service import (
- create_new_text,
- get_text_versions_by_group_id,
- get_text_by_text_id_or_collection,
create_table_of_content,
- get_table_of_contents_by_text_id,
- get_text_details_by_text_id,
update_text_details,
- remove_table_of_content_by_text_id,
- delete_text_by_text_id,
get_table_of_content_by_type,
- _validate_text_detail_request,
get_root_text_by_collection_id,
- get_commentaries_by_text_id,
replace_pecha_segment_id_with_segment_id,
- get_text_by_pecha_text_ids_service,
- get_titles_and_ids_by_query,
- get_text_languages,
- get_language_versions,
- get_version_info
)
-from pecha_api.terms.terms_response_models import TermsModel
from pecha_api.texts.texts_response_models import (
- CreateTextRequest,
TextDTO,
- TextVersion,
TableOfContent,
TableOfContentType,
Section,
TextSegment,
- TableOfContentResponse,
- TextDetailsRequest,
- DetailTableOfContentResponse,
- DetailTableOfContent,
- DetailSection,
- DetailTextSegment,
- Translation,
UpdateTextRequest,
- TextDTOResponse,
- TextVersionResponse,
- TextsCategoryResponse,
TextsByPechaTextIdsRequest,
- LanguageResponse,
- AvailableLanguage,
- VersionDetail,
- VersionsResponse
)
from pecha_api.recitations.recitations_response_models import RecitationDTO, RecitationsResponse
-from pecha_api.texts.texts_enums import TextType, PaginationDirection, LANGUAGE_ORDERS
-
from pecha_api.error_contants import ErrorConstants
-from typing import List
-
-@pytest.mark.asyncio
-async def test_get_text_by_text_id_or_collection_without_collection_id_success():
- text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- collection_id = None
- with patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock) as mock_get_text_detail_by_id, \
- patch("pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache", new_callable=AsyncMock, return_value=None), \
- patch("pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache", new_callable=AsyncMock, return_value=None):
- mock_get_text_detail_by_id.return_value = TextDTO(
- id=text_id,
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- language="bo",
- group_id="group_id_1",
- type="commentary",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- )
-
- response = await get_text_by_text_id_or_collection(text_id=text_id, collection_id=collection_id)
-
- assert response is not None
- assert isinstance(response, TextDTO)
- assert response.id == text_id
-
-@pytest.mark.asyncio
-async def test_get_text_by_collection_id():
- mock_collection = CollectionModel(
- id="id_1",
- title="སྤྱོད་འཇུག",
- description="དུས་རབས་ ༨ པའི་ནང་སློབ་དཔོན་ཞི་བ་ལྷས་མཛད་པའི་རྩ་བ་དང་དེའི་འགྲེལ་བ་སོགས།",
- language="bo",
- slug="bodhicaryavatara",
- has_child=False
- )
-
- # Use valid UUID for group_id
- valid_group_id = "123e4567-e89b-12d3-a456-426614174001"
-
- mock_texts_by_category = [
- TextDTO(
- id="a48c0814-ce56-4ada-af31-f74b179b52a9",
- title="སྤྱོད་འཇུག་དཀའ་འགྲེལ།",
- language="bo",
- group_id=valid_group_id,
- type="commentary",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0,
- pecha_text_id="pecha_1"
- ),
- TextDTO(
- id="032b9a5f-0712-40d8-b7ec-73c8c94f1c15",
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- language="bo",
- group_id=valid_group_id,
- type="version",
- is_published=True,
- created_date="2025-03-20 09:26:16.571522",
- updated_date="2025-03-20 09:26:16.571532",
- published_date="2025-03-20 09:26:16.571536",
- published_by="pecha",
- categories=[],
- views=0,
- pecha_text_id="pecha_2"
- )
- ]
-
- with patch('pecha_api.texts.texts_service.get_collection', new_callable=AsyncMock, return_value=mock_collection), \
- patch('pecha_api.texts.texts_service.get_all_texts_by_collection', new_callable=AsyncMock) as mock_get_all_texts, \
- patch('pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache', new_callable=AsyncMock, return_value=None), \
- patch('pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache', new_callable=AsyncMock, return_value=None):
- mock_get_all_texts.return_value = [mock_texts_by_category[1]]
- response = await get_text_by_text_id_or_collection(text_id=None, collection_id="id_1", language="bo", skip=0, limit=10)
- assert response is not None
- assert response.collection is not None
- collection: CollectionModel = response.collection
- assert collection.id == "id_1"
- assert collection.slug == "bodhicaryavatara"
- assert response.texts is not None
- texts: List[TextDTO] = response.texts
- assert len(texts) == 1
- assert texts[0] is not None
- assert isinstance(texts[0], TextDTO)
- assert texts[0].id == mock_texts_by_category[1].id
- assert texts[0].title == mock_texts_by_category[1].title
- assert texts[0].language == mock_texts_by_category[1].language
- assert texts[0].type == "root_text"
- assert response.total == 1
- assert response.skip == 0
- assert response.limit == 10
-
-
-@pytest.mark.asyncio
-async def test_get_versions_by_group_id():
- text_detail = TextDTO(
- id="id_1",
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- group_id="group_id_1",
- language="bo",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:26:16.571522",
- updated_date="2025-03-20 09:26:16.571532",
- published_date="2025-03-20 09:26:16.571536",
- published_by="pecha",
- categories=[],
- views=0
- )
- texts_by_group_id = [
- TextDTO(
- id="text_id_1",
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- group_id="group_id_1",
- language="bo",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:26:16.571522",
- updated_date="2025-03-20 09:26:16.571532",
- published_date="2025-03-20 09:26:16.571536",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_2",
- title="The Way of the Bodhisattva",
- language="en",
- group_id="group_id_1",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:28:28.076920",
- updated_date="2025-03-20 09:28:28.076934",
- published_date="2025-03-20 09:28:28.076938",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_3",
- title="शबोधिचर्यावतार",
- language="sa",
- group_id="group_id_1",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:29:51.154697",
- updated_date="2025-03-20 09:29:51.154708",
- published_date="2025-03-20 09:29:51.154712",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
- mock_table_of_content = TableOfContent(
- id="table_of_content_id",
- text_id="text_id_1",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="id_1",
- title="section_1",
- section_number=1,
- parent_id="id_1",
- segments=[],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
- language = "en"
- with patch('pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id', new_callable=AsyncMock) as mock_text_detail, \
- patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\
- patch('pecha_api.texts.texts_service.get_texts_by_group_id', new_callable=AsyncMock) as mock_get_texts_by_group_id,\
- patch('pecha_api.texts.texts_service.get_contents_by_id', new_callable=AsyncMock) as mock_get_contents_by_id:
- mock_text_detail.return_value = text_detail
- mock_get_texts_by_group_id.return_value = texts_by_group_id
- mock_get_contents_by_id.return_value = [mock_table_of_content]
- response = await get_text_versions_by_group_id(text_id="id_1",language=language, skip=0, limit=10)
- assert response is not None
- assert response.text is not None
- assert isinstance(response.text, TextDTO)
- assert response.text.type == "version"
- assert response.text.language == language
- assert response.text.id == "text_id_2"
- assert response.versions is not None
- assert len(response.versions) == 2
- assert response.versions[0] is not None
- assert isinstance(response.versions[0], TextVersion)
- assert response.versions[0].id == "text_id_1"
- for version in response.versions:
- assert isinstance(version, TextVersion)
- assert version.type == "version"
-
-
-
-@pytest.mark.asyncio
-async def test_create_new_text():
- text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- title = "བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།"
- language = "bo"
- is_published = True
- group_id = "67dd22a8d9f06ab28feedc90"
- created_date = "2025-03-16 04:40:54.757652"
- updated_date = "2025-03-16 04:40:54.757652"
- published_date = "2025-03-16 04:40:54.757652"
- published_by = "pecha"
- type_ = TextType.VERSION
- categories = []
- with patch('pecha_api.texts.texts_service.validate_user_exists', return_value=True), \
- patch('pecha_api.texts.texts_service.create_text', new_callable=AsyncMock) as mock_create_text,\
- patch('pecha_api.texts.texts_service.validate_group_exists', new_callable=AsyncMock) as mock_validate_group_exists:
- mock_create_text.return_value = AsyncMock(
- id=text_id,
- pecha_text_id="test_pecha_id",
- title=title,
- language=language,
- is_published=is_published,
- group_id=group_id,
- created_date=created_date,
- updated_date=updated_date,
- published_date=published_date,
- published_by=published_by,
- type=type_,
- categories=categories,
- views=0,
- source_link="https://test-source.com",
- ranking=1,
- license="CC0"
- )
- mock_validate_group_exists.return_value = True
- response = await create_new_text(
- create_text_request=CreateTextRequest(
- pecha_text_id="test_pecha_id",
- title=title,
- language=language,
- group_id=group_id,
- published_by=published_by,
- type=type_,
- categories=categories,
- source_link="https://test-source.com",
- ranking=1,
- license="CC0"
- ),
- token="admin"
- )
- assert response is not None
- assert isinstance(response, TextDTO)
- assert response.id == text_id
- assert response.title == title
- assert response.language == language
- assert response.type == type_.value
- assert response.is_published == is_published
- assert response.created_date == created_date
- assert response.updated_date == updated_date
- assert response.published_date == published_date
- assert response.published_by == published_by
- assert response.categories == categories
-
-@pytest.mark.asyncio
-async def test_create_new_text_invalid_group_id():
- with patch("pecha_api.texts.texts_service.validate_user_exists", return_value=True), \
- patch("pecha_api.texts.texts_service.validate_group_exists", return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await create_new_text(
- create_text_request=CreateTextRequest(
- pecha_text_id="test_pecha_id",
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- language="bo",
- group_id="invalid_group_id",
- published_by="pecha",
- type=TextType.VERSION,
- categories=[],
- source_link="https://test-source.com",
- ranking=1,
- license="CC0"
- ),
- token="user"
- )
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.GROUP_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_create_new_text_invalid_user():
- with patch("pecha_api.texts.texts_service.validate_user_exists", return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await create_new_text(
- create_text_request=CreateTextRequest(
- pecha_text_id="test_pecha_id",
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- language="bo",
- group_id="67dd22a8d9f06ab28feedc90",
- published_by="pecha",
- type=TextType.VERSION,
- categories=[],
- source_link="https://test-source.com",
- ranking=1,
- license="CC0"
- ),
- token="user"
- )
- assert exc_info.value.status_code == 401
- assert exc_info.value.detail == ErrorConstants.TOKEN_ERROR_MESSAGE
@pytest.mark.asyncio
async def test_create_table_of_content_success():
@@ -488,305 +140,6 @@ async def test_create_table_of_content_invalid_segment():
assert ErrorConstants.SEGMENT_NOT_FOUND_MESSAGE in exc_info.value.detail
assert str(segment_ids) in exc_info.value.detail
-@pytest.mark.asyncio
-async def test_get_table_of_contents_by_text_id_success():
- text_id = "text_id_1"
- language = "bo"
- skip = 0
- limit = 10
-
- mock_text_detail = TextDTO(
- id=text_id,
- title="text_1",
- language=language,
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="pecha",
- categories=[],
- views=0
- )
- mock_group_texts = [
- TextDTO(
- id="text_id_1",
- title="text_1",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_2",
- title="text_2",
- language="en",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
-
- table_of_contents = [
- TableOfContent(
- id="id_1",
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="id_1",
- title="section_1",
- section_number=1,
- parent_id="id_1",
- segments=[
- TextSegment(
- segment_id="seg_1",
- segment_number=1
- )
- ],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
- ]
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents):
-
- response = await get_table_of_contents_by_text_id(
- text_id=text_id,
- language=language,
- skip=skip,
- limit=limit
- )
-
- assert response is not None
- assert isinstance(response, TableOfContentResponse)
- assert response.text_detail is not None
- assert isinstance(response.text_detail, TextDTO)
- assert response.text_detail.id == mock_text_detail.id
- assert response.text_detail.language == language
- assert response.contents is not None
- assert len(response.contents) == 1
- assert response.contents[0] is not None
- assert isinstance(response.contents[0], TableOfContent)
- assert response.contents[0].id == table_of_contents[0].id
- assert response.contents[0].text_id == table_of_contents[0].text_id
- assert response.contents[0].sections is not None
-
-@pytest.mark.asyncio
-async def test_get_table_of_contents_by_text_id_success_language_is_none():
- text_id = "text_id_1"
- language = "en"
- skip = 0
- limit = 10
-
- mock_text_detail = TextDTO(
- id=text_id,
- title="text_1",
- language=language,
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="pecha",
- categories=[],
- views=0
- )
- mock_group_texts = [
- TextDTO(
- id="text_id_1",
- title="text_1",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_2",
- title="text_2",
- language="en",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
-
- table_of_contents = [
- TableOfContent(
- id="id_1",
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="id_1",
- title="section_1",
- section_number=1,
- parent_id="id_1",
- segments=[
- TextSegment(
- segment_id="seg_1",
- segment_number=1
- )
- ],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
- ]
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents):
-
- response = await get_table_of_contents_by_text_id(
- text_id=text_id,
- language=None,
- skip=skip,
- limit=limit
- )
-
- assert response is not None
- assert isinstance(response, TableOfContentResponse)
- assert response.text_detail is not None
- assert isinstance(response.text_detail, TextDTO)
- assert response.text_detail.language == language
- assert response.contents is not None
- assert len(response.contents) == 1
- assert response.contents[0] is not None
- assert isinstance(response.contents[0], TableOfContent)
-
-@pytest.mark.asyncio
-async def test_get_table_of_contents_by_text_id_root_text_is_none():
- """Test that HTTPException is raised when no root text can be determined after filtering"""
- from pecha_api.constants import Constants
-
- text_id = "text_id_1"
- language = "zh" # Requesting Chinese but no Chinese text exists
- skip = 0
- limit = 10
-
- mock_text_detail = TextDTO(
- id=text_id,
- title="text_1",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="pecha",
- categories=[],
- views=0
- )
-
- # Mock texts that are all in excluded_text_ids so root_text becomes None
- excluded_id = "excluded_text_id_test"
- mock_group_texts = [
- TextDTO(
- id=excluded_id,
- title="text_1",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
-
- table_of_contents = [
- TableOfContent(
- id="id_1",
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="id_1",
- title="section_1",
- section_number=1,
- parent_id="id_1",
- segments=[],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
- ]
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=mock_group_texts), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=table_of_contents), \
- patch("pecha_api.constants.Constants.excluded_text_ids", [excluded_id]):
-
- with pytest.raises(HTTPException) as exc_info:
- await get_table_of_contents_by_text_id(
- text_id=text_id,
- language=language,
- skip=skip,
- limit=limit
- )
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_get_table_of_contents_by_text_id_invalid_text():
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_table_of_contents_by_text_id(
- text_id="id_1",
- language="en",
- skip=0,
- limit=10
- )
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-
@pytest.mark.asyncio
async def test_update_text_details_success():
mock_text_details = TextDTO(
@@ -825,621 +178,20 @@ async def test_update_text_details_invalid_text_id():
assert exec_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
@pytest.mark.asyncio
-async def test_delete_table_of_content_success():
- with patch("pecha_api.texts.texts_service.delete_table_of_content_by_text_id", new_callable=AsyncMock), \
- patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True):
- response = await remove_table_of_content_by_text_id(text_id="text_id_1")
- assert response is not None
-
-@pytest.mark.asyncio
-async def test_delete_table_of_content_invalid_text_id():
- with patch("pecha_api.texts.texts_service.delete_table_of_content_by_text_id", new_callable=AsyncMock), \
- patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exec_info:
- await remove_table_of_content_by_text_id(text_id="invalid_id")
- assert exec_info.value.status_code == 404
- assert exec_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_delete_text_by_text_id_success():
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.delete_text_by_id", new_callable=AsyncMock):
- response = await delete_text_by_text_id(text_id="text_id_1")
- assert response is None
-
-@pytest.mark.asyncio
-async def test_delete_text_by_text_id_invalid_text_id():
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await delete_text_by_text_id(text_id="invalid_text_id")
-
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_get_text_details_by_text_id_with_text_id_content_id_segment_id_success():
- text_id = "text_id_1"
- content_id = "content_id_1"
- segment_id = "segment_id_1"
- mock_text_detail = TextDTO(
- id=text_id,
- title="text_title",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=[],
- views=0
- )
- mock_table_of_content = TableOfContent(
- id=content_id,
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- TextSegment(
- segment_id="segment_id_1",
- segment_number=1
- ),
- TextSegment(
- segment_id="segment_id_2",
- segment_number=2
- ),
- TextSegment(
- segment_id="segment_id_3",
- segment_number=3
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
- mock_mapped_table_of_content = DetailTableOfContent(
- id=content_id,
- text_id=text_id,
- sections=[
- DetailSection(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- DetailTextSegment(
- segment_id="segment_id_1",
- segment_number=1,
- content="segment_content_1",
- translation=None
- ),
- DetailTextSegment(
- segment_id="segment_id_2",
- segment_number=2,
- content="segment_content_2",
- translation=None
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_service._validate_text_detail_request", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_table_of_content_by_content_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.texts.texts_service.SegmentUtils.get_mapped_segment_content_for_table_of_content", new_callable=AsyncMock, return_value=mock_mapped_table_of_content):
-
- response = await get_text_details_by_text_id(
- text_id=text_id,
- text_details_request=TextDetailsRequest(
- content_id=content_id,
- segment_id=segment_id,
- size=2,
- direction=PaginationDirection.NEXT
- )
- )
-
- assert response is not None
- assert response.text_detail is not None
- assert isinstance(response.text_detail, TextDTO)
- assert response.text_detail.id == mock_text_detail.id
- assert response.content is not None
- assert isinstance(response.content, DetailTableOfContent)
- assert response.content.sections is not None
- assert len(response.content.sections) == 1
- assert response.content.sections[0] is not None
- assert isinstance(response.content.sections[0], DetailSection)
- section = response.content.sections[0]
- assert section.segments is not None
- assert len(section.segments) == 2
- assert section.segments[0].segment_id == "segment_id_1"
- assert section.segments[1].segment_id == "segment_id_2"
- assert response.pagination_direction == PaginationDirection.NEXT
-
-@pytest.mark.asyncio
-async def test_get_text_details_by_text_id_with_text_id_content_id_segment_id_previous_success():
- text_id = "text_id_1"
- content_id = "content_id_1"
- segment_id = "segment_id_1"
- mock_text_detail = TextDTO(
- id=text_id,
- title="text_title",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=[],
- views=0
- )
- mock_table_of_content = TableOfContent(
- id=content_id,
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- TextSegment(
- segment_id="segment_id_1",
- segment_number=1
- ),
- TextSegment(
- segment_id="segment_id_2",
- segment_number=2
- ),
- TextSegment(
- segment_id="segment_id_3",
- segment_number=3
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
- mock_mapped_table_of_content = DetailTableOfContent(
- id=content_id,
- text_id=text_id,
- sections=[
- DetailSection(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- DetailTextSegment(
- segment_id="segment_id_1",
- segment_number=1,
- content="segment_content_1",
- translation=None
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_service._validate_text_detail_request", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_table_of_content_by_content_id", new_callable=AsyncMock, return_value=mock_table_of_content), \
- patch("pecha_api.texts.texts_service.SegmentUtils.get_mapped_segment_content_for_table_of_content", new_callable=AsyncMock, return_value=mock_mapped_table_of_content):
-
- response = await get_text_details_by_text_id(
- text_id=text_id,
- text_details_request=TextDetailsRequest(
- content_id=content_id,
- segment_id=segment_id,
- size=2,
- direction=PaginationDirection.PREVIOUS
- )
- )
-
- assert response is not None
- assert response.text_detail is not None
- assert isinstance(response.text_detail, TextDTO)
- assert response.text_detail.id == mock_text_detail.id
- assert response.content is not None
- assert isinstance(response.content, DetailTableOfContent)
- assert response.content.sections is not None
- assert len(response.content.sections) == 1
- assert response.content.sections[0] is not None
- assert isinstance(response.content.sections[0], DetailSection)
- section = response.content.sections[0]
- assert section.segments is not None
- assert len(section.segments) == 1
- assert section.segments[0].segment_id == "segment_id_1"
- assert response.pagination_direction == PaginationDirection.PREVIOUS
-
-
-@pytest.mark.asyncio
-async def test_get_text_details_by_text_id_with_segment_id_only_success():
- text_id = "text_id_1"
- segment_id = "segment_id_1"
- mock_text_detail = TextDTO(
- id=text_id,
- title="text_title",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=[],
- views=0
- )
- mock_table_of_contents = [
- TableOfContent(
- id=f"content_id_{i}",
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- TextSegment(
- segment_id=f"segment_id_{i}",
- segment_number=1
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
- for i in range(1,11)
- ]
- mock_mapped_table_of_contents = DetailTableOfContent(
- id="content_id_1",
- text_id=text_id,
- sections=[
- DetailSection(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- DetailTextSegment(
- segment_id=f"segment_id_1",
- segment_number=1,
- content="segment_content_1",
- translation=None
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_service._validate_text_detail_request", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents), \
- patch("pecha_api.texts.texts_service.SegmentUtils.get_mapped_segment_content_for_table_of_content", new_callable=AsyncMock, return_value=mock_mapped_table_of_contents):
-
- response = await get_text_details_by_text_id(
- text_id=text_id,
- text_details_request=TextDetailsRequest(
- segment_id=segment_id,
- size=2,
- direction=PaginationDirection.NEXT
- )
- )
-
- assert response is not None
- assert response.text_detail is not None
- assert isinstance(response.text_detail, TextDTO)
- assert response.text_detail.id == mock_text_detail.id
- assert response.content is not None
- assert isinstance(response.content, DetailTableOfContent)
- assert response.content.sections is not None
- assert len(response.content.sections) == 1
- assert response.content.sections[0] is not None
- assert isinstance(response.content.sections[0], DetailSection)
- section = response.content.sections[0]
- assert section.segments is not None
- assert len(section.segments) == 1
- assert section.segments[0].segment_id == segment_id
- assert response.pagination_direction == PaginationDirection.NEXT
-
-
-
-@pytest.mark.asyncio
-async def test_get_text_details_by_text_id_with_content_id_only_success():
- text_id = "text_id_1"
- content_id = "content_id_1"
- mock_text_detail = TextDTO(
- id=text_id,
- title="text_title",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=[],
- views=0
- )
- mock_table_of_contents = [
- TableOfContent(
- id=f"content_id_{i}",
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- TextSegment(
- segment_id=f"segment_id_{i}",
- segment_number=1
- )
- ],
- sections=[
- Section(
- id="section_id_2",
- title="section_title_2",
- section_number=2,
- parent_id="parent_id_2",
- segments=[
- TextSegment(
- segment_id=f"1_segment_id_{i}",
- segment_number=1
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
- for i in range(1,11)
- ]
- mock_mapped_table_of_contents = DetailTableOfContent(
- id="content_id_1",
- text_id=text_id,
- sections=[
- DetailSection(
- id="section_id_1",
- title="section_title",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- DetailTextSegment(
- segment_id=f"segment_id_1",
- segment_number=1,
- content="segment_content_1",
- translation=None
- )
- ],
- sections=[
- DetailSection(
- id="section_id_2",
- title="section_title_2",
- section_number=2,
- parent_id="parent_id_2",
- segments=[
- DetailTextSegment(
- segment_id=f"1_segment_id_1",
- segment_number=1,
- content="segment_content_1",
- translation=None
- )
- ],
- sections=[],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ],
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date"
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_service._validate_text_detail_request", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents), \
- patch("pecha_api.texts.texts_service.SegmentUtils.get_mapped_segment_content_for_table_of_content", new_callable=AsyncMock, return_value=mock_mapped_table_of_contents):
-
- response = await get_text_details_by_text_id(
- text_id=text_id,
- text_details_request=TextDetailsRequest(
- content_id=content_id,
- size=2,
- direction=PaginationDirection.NEXT
- )
- )
-
- assert response is not None
- assert response.text_detail is not None
- assert isinstance(response.text_detail, TextDTO)
- assert response.text_detail.id == mock_text_detail.id
- assert response.content is not None
- assert isinstance(response.content, DetailTableOfContent)
- assert response.content.id == content_id
- assert response.content.sections is not None
- assert len(response.content.sections) == 1
- assert response.content.sections[0] is not None
- assert isinstance(response.content.sections[0], DetailSection)
- section = response.content.sections[0]
- assert section.segments is not None
- assert section.sections is not None
- assert len(section.segments) + len(section.sections[0].segments) == 2
- assert section.segments[0].segment_id == "segment_id_1"
- assert response.pagination_direction == PaginationDirection.NEXT
-
-
-@pytest.mark.asyncio
-async def test_validate_text_detail_request_success():
- text_id = "text_id_1"
- content_id = "content_id_1"
- version_id = "version_id_1"
- segment_id = "segment_id_1"
- section_id = "section_id_1"
- size = 20
- direction = PaginationDirection.NEXT
-
- text_details_request = TextDetailsRequest(
- content_id=content_id,
- version_id=version_id,
- segment_id=segment_id,
- section_id=section_id,
- size=size,
- direction=direction
- )
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=True):
-
- response = await _validate_text_detail_request(text_id=text_id, text_details_request=text_details_request)
-
-@pytest.mark.asyncio
-async def test_validate_text_detail_request_text_id_is_none():
- text_id = None
- content_id = "content_id_1"
- version_id = "version_id_1"
- segment_id = "segment_id_1"
- section_id = "section_id_1"
- size = 20
- direction = PaginationDirection.NEXT
-
- text_details_request = TextDetailsRequest(
- content_id=content_id,
- version_id=version_id,
- segment_id=segment_id,
- section_id=section_id,
- size=size,
- direction=direction
- )
-
- with pytest.raises(HTTPException) as e:
- await _validate_text_detail_request(text_id=text_id, text_details_request=text_details_request)
-
- assert e.value.status_code == 400
- assert e.value.detail == ErrorConstants.TEXT_OR_TERM_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_validate_text_detail_request_invalid_version_id():
- text_id = None
- content_id = "content_id_1"
- version_id = "invalid_version_id_1"
- segment_id = "segment_id_1"
- section_id = "section_id_1"
- size = 20
- direction = PaginationDirection.NEXT
-
- text_details_request = TextDetailsRequest(
- content_id=content_id,
- version_id=version_id,
- segment_id=segment_id,
- section_id=section_id,
- size=size,
- direction=direction
- )
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as e:
- await _validate_text_detail_request(text_id=text_id, text_details_request=text_details_request)
- assert e.value.status_code == 400
- assert e.value.detail == ErrorConstants.TEXT_OR_TERM_NOT_FOUND_MESSAGE
-
-@pytest.mark.asyncio
-async def test_validate_text_detail_request_invalid_segment_id():
- text_id = None
- content_id = "content_id_1"
- version_id = "version_id_1"
- segment_id = "invalid_segment_id_1"
- section_id = "section_id_1"
- size = 20
- direction = PaginationDirection.NEXT
-
- text_details_request = TextDetailsRequest(
- content_id=content_id,
- version_id=version_id,
- segment_id=segment_id,
- section_id=section_id,
- size=size,
- direction=direction
- )
+async def test_get_root_text_by_collection_id_success_with_root_text():
+ """Test get_root_text_by_collection_id when root text is found"""
+ collection_id = str(uuid4())
+ language = "bo"
+ text_id_1 = str(uuid4())
+ text_id_2 = str(uuid4())
+ group_id_1 = str(uuid4())
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.SegmentUtils.validate_segment_exists", new_callable=AsyncMock, return_value=False):
-
- with pytest.raises(HTTPException) as e:
- await _validate_text_detail_request(text_id=text_id, text_details_request=text_details_request)
-
- assert e.value.status_code == 400
- assert e.value.detail == ErrorConstants.TEXT_OR_TERM_NOT_FOUND_MESSAGE
-
-
-@pytest.mark.asyncio
-async def test_get_versions_by_group_id_language_is_none():
- text_detail = TextDTO(
- id="id_1",
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- group_id="group_id_1",
- language="bo",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:26:16.571522",
- updated_date="2025-03-20 09:26:16.571532",
- published_date="2025-03-20 09:26:16.571536",
- published_by="pecha",
- categories=[],
- views=0
- )
- texts_by_group_id = [
+ mock_texts = [
TextDTO(
- id="text_id_1",
+ id=text_id_1,
title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- group_id="group_id_1",
language="bo",
+ group_id=group_id_1,
type="version",
is_published=True,
created_date="2025-03-20 09:26:16.571522",
@@ -1450,10 +202,10 @@ async def test_get_versions_by_group_id_language_is_none():
views=0
),
TextDTO(
- id="text_id_2",
+ id=text_id_2,
title="The Way of the Bodhisattva",
language="en",
- group_id="group_id_1",
+ group_id=group_id_1,
type="version",
is_published=True,
created_date="2025-03-20 09:28:28.076920",
@@ -1462,230 +214,31 @@ async def test_get_versions_by_group_id_language_is_none():
published_by="pecha",
categories=[],
views=0
- ),
- TextDTO(
- id="text_id_3",
- title="शबोधिचर्यावतार",
- language="sa",
- group_id="group_id_1",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:29:51.154697",
- updated_date="2025-03-20 09:29:51.154708",
- published_date="2025-03-20 09:29:51.154712",
- published_by="pecha",
- categories=[],
- views=0
)
]
- mock_table_of_content = TableOfContent(
- id="table_of_content_id",
- text_id="text_id_1",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="id_1",
- title="section_1",
- section_number=1,
- parent_id="id_1",
- segments=[],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
- language = "en"
- with patch('pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id', new_callable=AsyncMock) as mock_text_detail, \
- patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock, return_value=None),\
- patch('pecha_api.texts.texts_service.get_texts_by_group_id', new_callable=AsyncMock) as mock_get_texts_by_group_id,\
- patch('pecha_api.texts.texts_service.get_contents_by_id', new_callable=AsyncMock) as mock_get_contents_by_id:
- mock_text_detail.return_value = text_detail
- mock_get_texts_by_group_id.return_value = texts_by_group_id
- mock_get_contents_by_id.return_value = [mock_table_of_content]
- response = await get_text_versions_by_group_id(text_id="id_1",language=None, skip=0, limit=10)
- assert response is not None
- assert response.text is not None
- assert isinstance(response.text, TextDTO)
- assert response.text.type == "version"
- assert response.text.language == language
- assert response.text.id == "text_id_2"
- assert response.versions is not None
- assert len(response.versions) == 2
- assert response.versions[0] is not None
- assert isinstance(response.versions[0], TextVersion)
- assert response.versions[0].id == "text_id_1"
- for version in response.versions:
- assert isinstance(version, TextVersion)
- assert version.type == "version"
+
+ mock_filtered_result = {
+ "root_text": mock_texts[0],
+ "versions": [mock_texts[1]]
+ }
+
+ with patch("pecha_api.texts.texts_service.get_all_recitation_texts_by_collection", new_callable=AsyncMock) as mock_get_all_texts, \
+ patch("pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference", new_callable=AsyncMock) as mock_filter:
-@pytest.mark.asyncio
-async def test_get_versions_by_group_id_cache_data_is_not_none():
- """Test get_text_versions_by_group_id returns cached data when available"""
- text_detail = TextDTO(
- id="id_1",
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- group_id="group_id_1",
- language="bo",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:26:16.571522",
- updated_date="2025-03-20 09:26:16.571532",
- published_date="2025-03-20 09:26:16.571536",
- published_by="pecha",
- categories=[],
- views=0
- )
- texts_by_group_id = [
- TextDTO(
- id="text_id_1",
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- group_id="group_id_1",
- language="bo",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:26:16.571522",
- updated_date="2025-03-20 09:26:16.571532",
- published_date="2025-03-20 09:26:16.571536",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_2",
- title="The Way of the Bodhisattva",
- language="en",
- group_id="group_id_1",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:28:28.076920",
- updated_date="2025-03-20 09:28:28.076934",
- published_date="2025-03-20 09:28:28.076938",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_3",
- title="शबोधिचर्यावतार",
- language="sa",
- group_id="group_id_1",
- type="version",
- is_published=True,
- created_date="2025-03-20 09:29:51.154697",
- updated_date="2025-03-20 09:29:51.154708",
- published_date="2025-03-20 09:29:51.154712",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
- cache_text_version = TextVersionResponse(
- text=text_detail,
- versions=[
- TextVersion(
- id="text_id_1",
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- parent_id=None,
- priority=None,
- language="bo",
- type="version",
- group_id="group_id_1",
- table_of_contents=[],
- is_published=True,
- created_date="2025-03-20 09:26:16.571522",
- updated_date="2025-03-20 09:26:16.571532",
- published_date="2025-03-20 09:26:16.571536",
- published_by="pecha"
- )
- ]
- )
- language = "en"
-
- # Mock TextUtils.get_text_detail_by_id which is called before cache check
- # Note: The actual implementation first gets the root text, then checks cache
- with patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=text_detail), \
- patch("pecha_api.texts.texts_service.get_texts_by_group_id", new_callable=AsyncMock, return_value=texts_by_group_id), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[]), \
- patch("pecha_api.texts.texts_service.set_text_versions_by_group_id_cache", new_callable=AsyncMock):
-
- response = await get_text_versions_by_group_id(text_id="id_1", language=language, skip=0, limit=10)
-
- assert response is not None
- assert isinstance(response, TextVersionResponse)
- assert response.text is not None
- assert isinstance(response.text, TextDTO)
- assert response.versions is not None
- # When language="en", filter_text_on_root_and_version selects text_id_2 (English) as root
- assert response.text.id == "text_id_2"
- assert response.text.language == "en"
- # The other texts become versions
- assert len(response.versions) == 2
-
-
-@pytest.mark.asyncio
-async def test_get_root_text_by_collection_id_success_with_root_text():
- """Test get_root_text_by_collection_id when root text is found"""
- collection_id = str(uuid4())
- language = "bo"
- text_id_1 = str(uuid4())
- text_id_2 = str(uuid4())
- group_id_1 = str(uuid4())
-
- mock_texts = [
- TextDTO(
- id=text_id_1,
- title="བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།",
- language="bo",
- group_id=group_id_1,
- type="version",
- is_published=True,
- created_date="2025-03-20 09:26:16.571522",
- updated_date="2025-03-20 09:26:16.571532",
- published_date="2025-03-20 09:26:16.571536",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id=text_id_2,
- title="The Way of the Bodhisattva",
- language="en",
- group_id=group_id_1,
- type="version",
- is_published=True,
- created_date="2025-03-20 09:28:28.076920",
- updated_date="2025-03-20 09:28:28.076934",
- published_date="2025-03-20 09:28:28.076938",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
-
- mock_filtered_result = {
- "root_text": mock_texts[0],
- "versions": [mock_texts[1]]
- }
-
- with patch("pecha_api.texts.texts_service.get_all_recitation_texts_by_collection", new_callable=AsyncMock) as mock_get_all_texts, \
- patch("pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference", new_callable=AsyncMock) as mock_filter:
-
- mock_get_all_texts.return_value = mock_texts
- mock_filter.return_value = mock_filtered_result
-
- result = await get_root_text_by_collection_id(collection_id=collection_id, language=language)
-
- mock_get_all_texts.assert_called_once_with(collection_id=collection_id, language=language)
-
- assert result is not None
- assert isinstance(result, RecitationsResponse)
- assert len(result.recitations) == 1
- assert isinstance(result.recitations[0], RecitationDTO)
- assert str(result.recitations[0].text_id) == text_id_1
- assert result.recitations[0].title == "བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།"
-
+ mock_get_all_texts.return_value = mock_texts
+ mock_filter.return_value = mock_filtered_result
+
+ result = await get_root_text_by_collection_id(collection_id=collection_id, language=language)
+
+ mock_get_all_texts.assert_called_once_with(collection_id=collection_id, language=language)
+
+ assert result is not None
+ assert isinstance(result, RecitationsResponse)
+ assert len(result.recitations) == 1
+ assert isinstance(result.recitations[0], RecitationDTO)
+ assert str(result.recitations[0].text_id) == text_id_1
+ assert result.recitations[0].title == "བྱང་ཆུབ་སེམས་དཔའི་སྤྱོད་པ་ལ་འཇུག་པ།"
+
@pytest.mark.asyncio
async def test_get_root_text_by_collection_id_no_root_text():
"""Test get_root_text_by_collection_id when no root text is found"""
@@ -1799,2614 +352,476 @@ async def test_get_root_text_by_collection_id_multiple_groups():
created_date="2025-03-20 09:31:00.000000",
updated_date="2025-03-20 09:31:00.000000",
published_date="2025-03-20 09:31:00.000000",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
-
- # Mock filters for each group
- def mock_filter_side_effect(texts, language):
- if texts[0].group_id == group_id_1:
- return {"root_text": texts[0], "versions": [texts[1]]}
- elif texts[0].group_id == group_id_2:
- return {"root_text": texts[0], "versions": [texts[1]]}
- return {"root_text": None, "versions": texts}
-
- with patch("pecha_api.texts.texts_service.get_all_recitation_texts_by_collection", new_callable=AsyncMock) as mock_get_all_texts, \
- patch("pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference", new_callable=AsyncMock) as mock_filter:
-
- mock_get_all_texts.return_value = mock_texts
- mock_filter.side_effect = mock_filter_side_effect
-
- result = await get_root_text_by_collection_id(collection_id=collection_id, language=language)
-
- mock_get_all_texts.assert_called_once_with(collection_id=collection_id, language=language)
-
- assert result is not None
- assert isinstance(result, RecitationsResponse)
- assert len(result.recitations) == 2
- assert isinstance(result.recitations[0], RecitationDTO)
- assert isinstance(result.recitations[1], RecitationDTO)
- # Check that we got both root texts
- text_ids = {str(rec.text_id) for rec in result.recitations}
- assert text_id_1 in text_ids
- assert text_id_3 in text_ids
-
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_type_text_type():
- """Test get_table_of_content_by_type with TEXT type"""
- text_id = "text_id_1"
-
- incoming_toc = TableOfContent(
- id="toc_id_1",
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_id_1",
- title="section_1",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- TextSegment(segment_id="pseg_1", segment_number=1)
- ],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
-
- expected_toc = TableOfContent(
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_id_1",
- title="section_1",
- section_number=1,
- segments=[TextSegment(segment_id="seg_id_1", segment_number=1)]
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock) as mock_get_segments:
- mock_get_segments.return_value = [type("Seg", (), {"id": "seg_id_1", "pecha_segment_id": "pseg_1"})()]
-
- result = await get_table_of_content_by_type(table_of_content=incoming_toc)
-
- assert result is not None
- assert isinstance(result, TableOfContent)
- assert result.text_id == text_id
- assert result.type == TableOfContentType.TEXT
- assert len(result.sections) == 1
- assert result.sections[0].segments[0].segment_id == "seg_id_1"
-
-@pytest.mark.asyncio
-async def test_get_table_of_content_by_type_sheet_type():
- """Test get_table_of_content_by_type with SHEET type"""
- text_id = "text_id_1"
-
- incoming_toc = TableOfContent(
- id="toc_id_1",
- text_id=text_id,
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_id_1",
- title="section_1",
- section_number=1,
- parent_id="parent_id_1",
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[],
- created_date="2025-03-16 04:40:54.757652",
- updated_date="2025-03-16 04:40:54.757652",
- published_date="2025-03-16 04:40:54.757652"
- )
- ]
- )
-
- result = await get_table_of_content_by_type(table_of_content=incoming_toc)
-
- assert result is not None
- assert isinstance(result, TableOfContent)
- assert result.text_id == text_id
- assert result.type == TableOfContentType.SHEET
- assert result.sections == incoming_toc.sections
-
-def test_get_trimmed_segment_dict_next_direction():
- """Test _get_trimmed_segment_dict_ with NEXT direction"""
- from pecha_api.texts.texts_service import _get_trimmed_segment_dict_
-
- segments_with_position = [
- ("seg_1", 1),
- ("seg_2", 2),
- ("seg_3", 3),
- ("seg_4", 4),
- ("seg_5", 5)
- ]
-
- result = _get_trimmed_segment_dict_(
- segments_with_position=segments_with_position,
- segment_id="seg_2",
- direction=PaginationDirection.NEXT,
- size=2
- )
-
- assert result is not None
- assert isinstance(result, dict)
- assert len(result) == 2
- assert "seg_2" in result
- assert "seg_3" in result
- assert result["seg_2"] == 2
- assert result["seg_3"] == 3
-
-def test_get_trimmed_segment_dict_previous_direction():
- """Test _get_trimmed_segment_dict_ with PREVIOUS direction"""
- from pecha_api.texts.texts_service import _get_trimmed_segment_dict_
-
- segments_with_position = [
- ("seg_1", 1),
- ("seg_2", 2),
- ("seg_3", 3),
- ("seg_4", 4),
- ("seg_5", 5)
- ]
-
- result = _get_trimmed_segment_dict_(
- segments_with_position=segments_with_position,
- segment_id="seg_4",
- direction=PaginationDirection.PREVIOUS,
- size=2
- )
-
- assert result is not None
- assert isinstance(result, dict)
- assert len(result) == 2
- assert "seg_3" in result
- assert "seg_4" in result
- assert result["seg_3"] == 3
- assert result["seg_4"] == 4
-
-def test_get_trimmed_segment_dict_next_at_end():
- """Test _get_trimmed_segment_dict_ with NEXT direction at end of list"""
- from pecha_api.texts.texts_service import _get_trimmed_segment_dict_
-
- segments_with_position = [
- ("seg_1", 1),
- ("seg_2", 2),
- ("seg_3", 3)
- ]
-
- result = _get_trimmed_segment_dict_(
- segments_with_position=segments_with_position,
- segment_id="seg_2",
- direction=PaginationDirection.NEXT,
- size=5
- )
-
- assert result is not None
- assert isinstance(result, dict)
- assert len(result) == 2
- assert "seg_2" in result
- assert "seg_3" in result
-
-def test_get_trimmed_segment_dict_previous_at_start():
- """Test _get_trimmed_segment_dict_ with PREVIOUS direction at start of list"""
- from pecha_api.texts.texts_service import _get_trimmed_segment_dict_
-
- segments_with_position = [
- ("seg_1", 1),
- ("seg_2", 2),
- ("seg_3", 3)
- ]
-
- result = _get_trimmed_segment_dict_(
- segments_with_position=segments_with_position,
- segment_id="seg_1",
- direction=PaginationDirection.PREVIOUS,
- size=5
- )
-
- assert result is not None
- assert isinstance(result, dict)
- assert len(result) == 1
- assert "seg_1" in result
-
-def test_get_segments_with_position_simple():
- """Test _get_segments_with_position_ with simple sections"""
- from pecha_api.texts.texts_service import _get_segments_with_position_
-
- table_of_content = TableOfContent(
- id="toc_id",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1),
- TextSegment(segment_id="seg_2", segment_number=2)
- ],
- sections=[]
- )
- ]
- )
-
- result = _get_segments_with_position_(table_of_content=table_of_content)
-
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 2
- assert result[0] == ("seg_1", 1)
- assert result[1] == ("seg_2", 2)
-
-def test_get_segments_with_position_nested():
- """Test _get_segments_with_position_ with nested sections"""
- from pecha_api.texts.texts_service import _get_segments_with_position_
-
- table_of_content = TableOfContent(
- id="toc_id",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[
- Section(
- id="section_2",
- title="Section 2",
- section_number=2,
- segments=[
- TextSegment(segment_id="seg_2", segment_number=1)
- ],
- sections=[]
- )
- ]
- )
- ]
- )
-
- result = _get_segments_with_position_(table_of_content=table_of_content)
-
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 2
- assert result[0] == ("seg_1", 1)
- assert result[1] == ("seg_2", 2)
-
-def test_filter_single_section_with_wanted_segments():
- """Test _filter_single_section_ with wanted segments"""
- from pecha_api.texts.texts_service import _filter_single_section_
-
- section = Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1),
- TextSegment(segment_id="seg_2", segment_number=2),
- TextSegment(segment_id="seg_3", segment_number=3)
- ],
- sections=[]
- )
-
- wanted_segment_ids = {"seg_1", "seg_3"}
-
- result = _filter_single_section_(section=section, wanted_segment_ids=wanted_segment_ids)
-
- assert result is not None
- assert isinstance(result, Section)
- assert len(result.segments) == 2
- assert result.segments[0].segment_id == "seg_1"
- assert result.segments[1].segment_id == "seg_3"
-
-def test_filter_single_section_no_wanted_segments():
- """Test _filter_single_section_ with no wanted segments"""
- from pecha_api.texts.texts_service import _filter_single_section_
-
- section = Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1),
- TextSegment(segment_id="seg_2", segment_number=2)
- ],
- sections=[]
- )
-
- wanted_segment_ids = {"seg_3", "seg_4"}
-
- result = _filter_single_section_(section=section, wanted_segment_ids=wanted_segment_ids)
-
- assert result is None
-
-def test_filter_single_section_nested_with_wanted_segments():
- """Test _filter_single_section_ with nested sections containing wanted segments"""
- from pecha_api.texts.texts_service import _filter_single_section_
-
- section = Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[
- Section(
- id="section_2",
- title="Section 2",
- section_number=2,
- segments=[
- TextSegment(segment_id="seg_2", segment_number=1)
- ],
- sections=[]
- )
- ]
- )
-
- wanted_segment_ids = {"seg_2"}
-
- result = _filter_single_section_(section=section, wanted_segment_ids=wanted_segment_ids)
-
- assert result is not None
- assert isinstance(result, Section)
- assert len(result.segments) == 0 # Parent has no wanted segments
- assert len(result.sections) == 1 # But subsection has wanted segments
- assert result.sections[0].segments[0].segment_id == "seg_2"
-
-def test_generate_paginated_table_of_content_by_segments():
- """Test _generate_paginated_table_of_content_by_segments_"""
- from pecha_api.texts.texts_service import _generate_paginated_table_of_content_by_segments_
-
- table_of_content = TableOfContent(
- id="toc_id",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1),
- TextSegment(segment_id="seg_2", segment_number=2),
- TextSegment(segment_id="seg_3", segment_number=3)
- ],
- sections=[]
- )
- ]
- )
-
- segment_dict = {
- "seg_1": 1,
- "seg_3": 3
- }
-
- result = _generate_paginated_table_of_content_by_segments_(
- table_of_content=table_of_content,
- segment_dict=segment_dict
- )
-
- assert result is not None
- assert isinstance(result, TableOfContent)
- assert result.type == TableOfContentType.TEXT
- assert len(result.sections) == 1
- assert len(result.sections[0].segments) == 2
- assert result.sections[0].segments[0].segment_id == "seg_1"
- assert result.sections[0].segments[1].segment_id == "seg_3"
-
-def test_generate_paginated_table_of_content_by_segments_with_sheet_type():
- """Test _generate_paginated_table_of_content_by_segments_ preserves SHEET type"""
- from pecha_api.texts.texts_service import _generate_paginated_table_of_content_by_segments_
-
- table_of_content = TableOfContent(
- id="toc_id",
- text_id="text_id",
- type=TableOfContentType.SHEET,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1),
- TextSegment(segment_id="seg_2", segment_number=2)
- ],
- sections=[]
- )
- ]
- )
-
- segment_dict = {
- "seg_1": 1
- }
-
- result = _generate_paginated_table_of_content_by_segments_(
- table_of_content=table_of_content,
- segment_dict=segment_dict
- )
-
- assert result is not None
- assert isinstance(result, TableOfContent)
- assert result.type == TableOfContentType.SHEET
- assert len(result.sections) == 1
- assert len(result.sections[0].segments) == 1
- assert result.sections[0].segments[0].segment_id == "seg_1"
-
-def test_search_section_found():
- """Test _search_section_ when segment is found"""
- from pecha_api.texts.texts_service import _search_section_
-
- sections = [
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1),
- TextSegment(segment_id="seg_2", segment_number=2)
- ],
- sections=[]
- )
- ]
-
- result = _search_section_(sections=sections, segment_id="seg_2")
-
- assert result is True
-
-def test_search_section_not_found():
- """Test _search_section_ when segment is not found"""
- from pecha_api.texts.texts_service import _search_section_
-
- sections = [
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[]
- )
- ]
-
- result = _search_section_(sections=sections, segment_id="seg_3")
-
- assert result is False
-
-def test_search_section_nested():
- """Test _search_section_ with nested sections"""
- from pecha_api.texts.texts_service import _search_section_
-
- sections = [
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[
- Section(
- id="section_2",
- title="Section 2",
- section_number=2,
- segments=[
- TextSegment(segment_id="seg_2", segment_number=1)
- ],
- sections=[]
- )
- ]
- )
- ]
-
- result = _search_section_(sections=sections, segment_id="seg_2")
-
- assert result is True
-
-def test_search_table_of_content_where_segment_id_exists_found():
- """Test _search_table_of_content_where_segment_id_exists when segment is found"""
- from pecha_api.texts.texts_service import _search_table_of_content_where_segment_id_exists
-
- table_of_contents = [
- TableOfContent(
- id="toc_1",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[]
- )
- ]
- ),
- TableOfContent(
- id="toc_2",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_2",
- title="Section 2",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_2", segment_number=1)
- ],
- sections=[]
- )
- ]
- )
- ]
-
- result = _search_table_of_content_where_segment_id_exists(
- table_of_contents=table_of_contents,
- segment_id="seg_2"
- )
-
- assert result is not None
- assert isinstance(result, TableOfContent)
- assert result.id == "toc_2"
-
-def test_search_table_of_content_where_segment_id_exists_not_found():
- """Test _search_table_of_content_where_segment_id_exists when segment is not found"""
- from pecha_api.texts.texts_service import _search_table_of_content_where_segment_id_exists
-
- table_of_contents = [
- TableOfContent(
- id="toc_1",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[]
- )
- ]
- )
- ]
-
- with pytest.raises(HTTPException) as exc_info:
- _search_table_of_content_where_segment_id_exists(
- table_of_contents=table_of_contents,
- segment_id="seg_3"
- )
-
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE
-
-def test_get_first_segment_and_table_of_content_success():
- """Test _get_first_segment_and_table_of_content_ when segment is found"""
- from pecha_api.texts.texts_service import _get_first_segment_and_table_of_content_
-
- table_of_contents = [
- TableOfContent(
- id="toc_1",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[]
- )
- ]
- )
- ]
-
- segment_id, table_of_content = _get_first_segment_and_table_of_content_(
- table_of_contents=table_of_contents
- )
-
- assert segment_id == "seg_1"
- assert table_of_content is not None
- assert table_of_content.id == "toc_1"
-
-def test_get_first_segment_and_table_of_content_no_segments():
- """Test _get_first_segment_and_table_of_content_ when no segments exist"""
- from pecha_api.texts.texts_service import _get_first_segment_and_table_of_content_
-
- table_of_contents = [
- TableOfContent(
- id="toc_1",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[],
- sections=[]
- )
- ]
- )
- ]
-
- segment_id, table_of_content = _get_first_segment_and_table_of_content_(
- table_of_contents=table_of_contents
- )
-
- assert segment_id is None
- assert table_of_content is None
-
-@pytest.mark.asyncio
-async def test_receive_table_of_content_with_content_and_segment_id():
- """Test _receive_table_of_content with content_id and segment_id"""
- from pecha_api.texts.texts_service import _receive_table_of_content
-
- text_details_request = TextDetailsRequest(content_id="content_1", segment_id="seg_1")
- mock_table_of_content = TableOfContent(
- id="toc_1",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[]
- )
- ]
- )
-
- with patch(
- "pecha_api.texts.texts_service.get_table_of_content_by_content_id",
- new_callable=AsyncMock,
- return_value=mock_table_of_content
- ) as mock_get_table_of_content_by_content_id, patch(
- "pecha_api.texts.texts_service.get_contents_by_id",
- new_callable=AsyncMock
- ) as mock_get_contents_by_id:
- result = await _receive_table_of_content(text_id="text_id", text_details_request=text_details_request)
-
- assert result == mock_table_of_content
- mock_get_table_of_content_by_content_id.assert_awaited_once_with(content_id="content_1")
- mock_get_contents_by_id.assert_not_awaited()
-
-@pytest.mark.asyncio
-async def test_receive_table_of_content_with_segment_id_only():
- """Test _receive_table_of_content with only segment_id"""
- from pecha_api.texts.texts_service import _receive_table_of_content
-
- text_details_request = TextDetailsRequest(segment_id="seg_2")
- mock_table_of_contents = [
- TableOfContent(
- id="toc_1",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[]
- )
- ]
- expected_table_of_content = TableOfContent(
- id="toc_2",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[]
- )
-
- with patch(
- "pecha_api.texts.texts_service.get_contents_by_id",
- new_callable=AsyncMock,
- return_value=mock_table_of_contents
- ) as mock_get_contents_by_id, patch(
- "pecha_api.texts.texts_service._search_table_of_content_where_segment_id_exists",
- return_value=expected_table_of_content
- ) as mock_search_table_of_content:
- result = await _receive_table_of_content(text_id="text_id", text_details_request=text_details_request)
-
- assert result == expected_table_of_content
- mock_get_contents_by_id.assert_awaited_once_with(text_id="text_id")
- mock_search_table_of_content.assert_called_once_with(
- table_of_contents=mock_table_of_contents,
- segment_id="seg_2"
- )
-
-@pytest.mark.asyncio
-async def test_receive_table_of_content_without_segment_and_content_id():
- """Test _receive_table_of_content when no content_id or segment_id is provided"""
- from pecha_api.texts.texts_service import _receive_table_of_content
-
- text_details_request = TextDetailsRequest()
- mock_table_of_contents = [
- TableOfContent(
- id="toc_1",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[]
- )
- ]
- expected_table_of_content = TableOfContent(
- id="toc_1",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[]
- )
-
- with patch(
- "pecha_api.texts.texts_service.get_contents_by_id",
- new_callable=AsyncMock,
- return_value=mock_table_of_contents
- ) as mock_get_contents_by_id, patch(
- "pecha_api.texts.texts_service._get_first_segment_and_table_of_content_",
- return_value=("seg_1", expected_table_of_content)
- ) as mock_get_first_segment_and_table_of_content:
- result = await _receive_table_of_content(text_id="text_id", text_details_request=text_details_request)
-
- assert result == expected_table_of_content
- assert text_details_request.segment_id == "seg_1"
- mock_get_contents_by_id.assert_awaited_once_with(text_id="text_id")
- mock_get_first_segment_and_table_of_content.assert_called_once_with(
- table_of_contents=mock_table_of_contents
- )
-
-@pytest.mark.asyncio
-async def test_receive_table_of_content_not_found():
- """Test _receive_table_of_content when table of content is not found"""
- from pecha_api.texts.texts_service import _receive_table_of_content
-
- text_details_request = TextDetailsRequest(content_id="content_1", segment_id="seg_1")
-
- with patch(
- "pecha_api.texts.texts_service.get_table_of_content_by_content_id",
- new_callable=AsyncMock,
- return_value=None
- ):
- with pytest.raises(HTTPException) as exc_info:
- await _receive_table_of_content(text_id="text_id", text_details_request=text_details_request)
-
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TABLE_OF_CONTENT_NOT_FOUND_MESSAGE
-
-def test_get_paginated_sections():
- """Test _get_paginated_sections"""
- from pecha_api.texts.texts_service import _get_paginated_sections
-
- sections = [
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1),
- TextSegment(segment_id="seg_2", segment_number=2)
- ],
- sections=[]
- ),
- Section(
- id="section_2",
- title="Section 2",
- section_number=2,
- segments=[
- TextSegment(segment_id="seg_3", segment_number=1)
- ],
- sections=[]
- ),
- Section(
- id="section_3",
- title="Section 3",
- section_number=3,
- segments=[
- TextSegment(segment_id="seg_4", segment_number=1)
- ],
- sections=[]
- )
- ]
-
- result = _get_paginated_sections(sections=sections, skip=0, limit=2)
-
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 2
- assert result[0].id == "section_1"
- assert result[1].id == "section_2"
- # Each section should only have first segment
- assert len(result[0].segments) == 1
- assert result[0].segments[0].segment_id == "seg_1"
-
-def test_get_paginated_sections_with_skip():
- """Test _get_paginated_sections with skip"""
- from pecha_api.texts.texts_service import _get_paginated_sections
-
- sections = [
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="seg_1", segment_number=1)
- ],
- sections=[]
- ),
- Section(
- id="section_2",
- title="Section 2",
- section_number=2,
- segments=[
- TextSegment(segment_id="seg_2", segment_number=1)
- ],
- sections=[]
- )
- ]
-
- result = _get_paginated_sections(sections=sections, skip=1, limit=2)
-
- assert result is not None
- assert isinstance(result, list)
- assert len(result) == 1
- assert result[0].id == "section_2"
-
-@pytest.mark.asyncio
-async def test_update_text_details_cache_update_fails():
- """Test update_text_details when cache update fails"""
- mock_text_details = TextDTO(
- id="text_id_1",
- title="text_title",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=False,
- created_date="created_date",
- updated_date="updated_date",
- published_date="published_date",
- published_by="published_by",
- categories=[],
- views=0
- )
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_details), \
- patch("pecha_api.texts.texts_service.update_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_details), \
- patch("pecha_api.texts.texts_service.update_text_details_cache", new_callable=AsyncMock, side_effect=Exception("Cache error")), \
- patch("pecha_api.texts.texts_service.invalidate_text_cache_on_update", new_callable=AsyncMock, return_value=None) as mock_invalidate:
-
- response = await update_text_details(text_id="text_id_1", update_text_request=UpdateTextRequest(title="updated_title", is_published=True))
-
- assert response is not None
- # Verify that invalidate was called as fallback
- mock_invalidate.assert_called_once_with(text_id="text_id_1")
-
-@pytest.mark.asyncio
-async def test_get_text_by_text_id_or_collection_with_cache():
- """Test get_text_by_text_id_or_collection fetches text (cache is currently disabled in code)"""
- text_id = "efb26a06-f373-450b-ba57-e7a8d4dd5b64"
- mock_text = TextDTO(
- id=text_id,
- title="Test Text",
- language="bo",
- group_id="group_id_1",
- type="commentary",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- )
-
- # Cache is commented out in the actual code, so we need to mock the actual data fetch
- with patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text), \
- patch("pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache", new_callable=AsyncMock):
- response = await get_text_by_text_id_or_collection(text_id=text_id, collection_id=None)
-
- assert response is not None
- assert isinstance(response, TextDTO)
- assert response.id == text_id
- assert response.title == "Test Text"
-
-
-@pytest.mark.asyncio
-async def test_update_text_details_with_cache_invalidation():
- """Test update_text_details updates text successfully"""
- text_id = "123e4567-e89b-12d3-a456-426614174000"
- update_request = UpdateTextRequest(
- title="Updated Title",
- language="bo"
- )
-
- updated_text = TextDTO(
- id=text_id,
- title="Updated Title",
- language="bo",
- group_id="group_id_1",
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- )
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=updated_text), \
- patch("pecha_api.texts.texts_service.update_text_details_by_id", new_callable=AsyncMock, return_value=updated_text) as mock_update, \
- patch("pecha_api.texts.texts_service.invalidate_text_cache_on_update", new_callable=AsyncMock):
-
- result = await update_text_details(text_id=text_id, update_text_request=update_request)
-
- assert result is not None
- assert result.title == "Updated Title"
- mock_update.assert_called_once_with(text_id=text_id, update_text_request=update_request)
-
-
-@pytest.mark.asyncio
-async def test_remove_table_of_content_by_text_id_success():
- """Test remove_table_of_content_by_text_id successfully deletes content"""
- text_id = "123e4567-e89b-12d3-a456-426614174000"
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.delete_table_of_content_by_text_id", new_callable=AsyncMock) as mock_delete:
- await remove_table_of_content_by_text_id(text_id=text_id)
-
- mock_delete.assert_called_once_with(text_id=text_id)
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_collection_id_empty_result():
- """Test get_text_by_text_id_or_collection with collection_id returns empty when no texts"""
- collection_id = "collection_id_1"
- language = "bo"
-
- mock_collection = CollectionModel(
- id=collection_id,
- title="Empty Collection",
- description="No texts",
- language=language,
- slug="empty-collection",
- has_child=False
- )
-
- with patch("pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache", new_callable=AsyncMock, return_value=None), \
- patch("pecha_api.texts.texts_service.get_collection", new_callable=AsyncMock, return_value=mock_collection), \
- patch("pecha_api.texts.texts_service._get_texts_by_collection_id", new_callable=AsyncMock, return_value=([], 0)), \
- patch("pecha_api.texts.texts_service.get_all_texts_by_collection", new_callable=AsyncMock, return_value=[]), \
- patch("pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache", new_callable=AsyncMock):
-
- response = await get_text_by_text_id_or_collection(
- text_id=None,
- collection_id=collection_id,
- language=language,
- skip=0,
- limit=10
- )
-
- assert response is not None
- assert isinstance(response, TextsCategoryResponse)
- assert response.total == 0
-
-
-# Tests for new private functions
-
-def test_group_texts_by_group_id_with_language_sorting():
- """Test _group_texts_by_group_id groups texts and sorts by language preference"""
- from pecha_api.texts.texts_service import _group_texts_by_group_id
-
- # Create mock texts with different group_ids and languages
- mock_texts = [
- TextDTO(
- id="text_id_1",
- title="Text 1 Bo",
- language="bo",
- group_id="group_1",
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_2",
- title="Text 2 En",
- language="en",
- group_id="group_1",
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_3",
- title="Text 3 Zh",
- language="zh",
- group_id="group_1",
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_4",
- title="Text 4 Bo Group2",
- language="bo",
- group_id="group_2",
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
-
- # Test with 'en' as preferred language
- result = _group_texts_by_group_id(texts=mock_texts, language="en")
-
- # Verify grouping
- assert len(result) == 2
- assert "group_1" in result
- assert "group_2" in result
- assert len(result["group_1"]) == 3
- assert len(result["group_2"]) == 1
-
- # Verify sorting - 'en' should be first for group_1
- assert result["group_1"][0].language == "en"
- assert result["group_1"][1].language == "bo"
- assert result["group_1"][2].language == "zh"
-
-
-def test_group_texts_by_group_id_with_bo_preference():
- """Test _group_texts_by_group_id with Tibetan (bo) language preference"""
- from pecha_api.texts.texts_service import _group_texts_by_group_id
-
- mock_texts = [
- TextDTO(
- id="text_id_1",
- title="Text En",
- language="en",
- group_id="group_1",
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text_id_2",
- title="Text Bo",
- language="bo",
- group_id="group_1",
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
-
- result = _group_texts_by_group_id(texts=mock_texts, language="bo")
-
- # Verify 'bo' is first
- assert result["group_1"][0].language == "bo"
- assert result["group_1"][1].language == "en"
-
-
-def test_get_language_priority_via_textutils():
- """Test language priority through TextUtils (used by service layer)"""
- from pecha_api.texts.texts_utils import TextUtils
-
- # Test with 'bo' preference
- assert TextUtils.get_language_priority("bo", "bo") == 0
- assert TextUtils.get_language_priority("en", "bo") == 1
- assert TextUtils.get_language_priority("zh", "bo") == 2
- assert TextUtils.get_language_priority("unknown", "bo") == 999
-
- # Test with 'en' preference
- assert TextUtils.get_language_priority("en", "en") == 0
- assert TextUtils.get_language_priority("bo", "en") == 1
- assert TextUtils.get_language_priority("zh", "en") == 2
- assert TextUtils.get_language_priority("unknown", "en") == 999
-
- # Test with 'zh' preference
- assert TextUtils.get_language_priority("zh", "zh") == 0
- assert TextUtils.get_language_priority("en", "zh") == 1
- assert TextUtils.get_language_priority("bo", "zh") == 2
-
-
-def test_get_language_priority_with_none_via_textutils():
- """Test TextUtils.get_language_priority handles None text_language"""
- from pecha_api.texts.texts_utils import TextUtils
-
- # None language should get default priority
- assert TextUtils.get_language_priority(None, "bo") == 999
- assert TextUtils.get_language_priority(None, "en") == 999
-
-
-def test_group_texts_by_group_id_empty_texts():
- """Test _group_texts_by_group_id with empty text list"""
- from pecha_api.texts.texts_service import _group_texts_by_group_id
-
- result = _group_texts_by_group_id(texts=[], language="bo")
-
- assert result == {}
-
-
-def test_group_texts_by_group_id_single_group():
- """Test _group_texts_by_group_id with single group"""
- from pecha_api.texts.texts_service import _group_texts_by_group_id
-
- mock_texts = [
- TextDTO(
- id="text_id_1",
- title="Text 1",
- language="bo",
- group_id="single_group",
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
-
- result = _group_texts_by_group_id(texts=mock_texts, language="bo")
-
- assert len(result) == 1
- assert "single_group" in result
- assert len(result["single_group"]) == 1
- assert result["single_group"][0].id == "text_id_1"
-
-
-def test_language_orders_constant():
- """Test LANGUAGE_ORDERS constant is properly defined"""
- # Verify the constant exists and has expected structure
- assert LANGUAGE_ORDERS is not None
- assert isinstance(LANGUAGE_ORDERS, dict)
-
- # Check all three languages are defined
- assert 'bo' in LANGUAGE_ORDERS
- assert 'en' in LANGUAGE_ORDERS
- assert 'zh' in LANGUAGE_ORDERS
-
- # Verify 'bo' preference order
- assert LANGUAGE_ORDERS['bo']['bo'] == 0
- assert LANGUAGE_ORDERS['bo']['en'] == 1
- assert LANGUAGE_ORDERS['bo']['zh'] == 2
-
- # Verify 'en' preference order
- assert LANGUAGE_ORDERS['en']['en'] == 0
- assert LANGUAGE_ORDERS['en']['bo'] == 1
- assert LANGUAGE_ORDERS['en']['zh'] == 2
-
- # Verify 'zh' preference order
- assert LANGUAGE_ORDERS['zh']['zh'] == 0
- assert LANGUAGE_ORDERS['zh']['en'] == 1
- assert LANGUAGE_ORDERS['zh']['bo'] == 2
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_by_text_id_success():
- """Test get_commentaries_by_text_id returns matching commentaries"""
- from uuid import UUID
-
- text_id = "text_id_1"
- group_id = "group_id_1"
-
- mock_root_text = TextDTO(
- id=text_id,
- title="Root Text",
- language="bo",
- group_id=group_id,
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- )
-
- mock_commentaries = [
- TextDTO(
- id="commentary_id_1",
- title="Commentary 1",
- language="bo",
- group_id="commentary_group_1",
- type="commentary",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[group_id], # Commentary references the root text's group_id
- views=0
- ),
- TextDTO(
- id="commentary_id_2",
- title="Commentary 2",
- language="bo",
- group_id="commentary_group_2",
- type="commentary",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=["other_group"], # This one shouldn't match
- views=0
- )
- ]
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_root_text), \
- patch("pecha_api.texts.texts_service.TextUtils.get_commentaries_by_text_type", new_callable=AsyncMock, return_value=mock_commentaries):
-
- result = await get_commentaries_by_text_id(text_id=text_id, skip=0, limit=10)
-
- assert result is not None
- assert len(result) == 1
- assert result[0].id == "commentary_id_1"
- assert group_id in result[0].categories
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_by_text_id_no_matching_commentaries():
- """Test get_commentaries_by_text_id with no matching commentaries"""
- text_id = "text_id_1"
- group_id = "group_id_1"
-
- mock_root_text = TextDTO(
- id=text_id,
- title="Root Text",
- language="bo",
- group_id=group_id,
- type="version",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- )
-
- mock_commentaries = [
- TextDTO(
- id="commentary_id_1",
- title="Commentary 1",
- language="bo",
- group_id="commentary_group_1",
- type="commentary",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=["other_group"], # Different group
- views=0
- )
- ]
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_root_text), \
- patch("pecha_api.texts.texts_service.TextUtils.get_commentaries_by_text_type", new_callable=AsyncMock, return_value=mock_commentaries):
-
- result = await get_commentaries_by_text_id(text_id=text_id, skip=0, limit=10)
-
- assert result is not None
- assert len(result) == 0
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_by_text_id_invalid_text():
- """Test get_commentaries_by_text_id raises exception for invalid text"""
- text_id = "invalid_text_id"
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, side_effect=HTTPException(status_code=404, detail="Text not found")):
- with pytest.raises(HTTPException) as exc_info:
- await get_commentaries_by_text_id(text_id=text_id, skip=0, limit=10)
-
- assert exc_info.value.status_code == 404
-
-
-@pytest.mark.asyncio
-async def test_replace_pecha_segment_id_with_segment_id_success():
- """Test replace_pecha_segment_id_with_segment_id converts pecha segment IDs to database segment IDs"""
- text_id = "text_id_1"
-
- # Mock segments from database
- class MockSegment:
- def __init__(self, segment_id, pecha_segment_id):
- self.id = segment_id
- self.pecha_segment_id = pecha_segment_id
-
- mock_segments = [
- MockSegment("db_seg_1", "pecha_seg_1"),
- MockSegment("db_seg_2", "pecha_seg_2")
- ]
-
- incoming_toc = TableOfContent(
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="pecha_seg_1", segment_number=1),
- TextSegment(segment_id="pecha_seg_2", segment_number=2)
- ]
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments):
- result = await replace_pecha_segment_id_with_segment_id(table_of_content=incoming_toc)
-
- assert result is not None
- assert isinstance(result, TableOfContent)
- assert result.text_id == text_id
- assert result.type == TableOfContentType.TEXT
- assert len(result.sections) == 1
- assert len(result.sections[0].segments) == 2
- # Verify segment IDs were replaced
- assert result.sections[0].segments[0].segment_id == "db_seg_1"
- assert result.sections[0].segments[1].segment_id == "db_seg_2"
-
-
-@pytest.mark.asyncio
-async def test_replace_pecha_segment_id_with_segment_id_multiple_sections():
- """Test replace_pecha_segment_id_with_segment_id with multiple sections"""
- text_id = "text_id_1"
-
- class MockSegment:
- def __init__(self, segment_id, pecha_segment_id):
- self.id = segment_id
- self.pecha_segment_id = pecha_segment_id
-
- mock_segments = [
- MockSegment("db_seg_1", "pecha_seg_1"),
- MockSegment("db_seg_2", "pecha_seg_2"),
- MockSegment("db_seg_3", "pecha_seg_3")
- ]
-
- incoming_toc = TableOfContent(
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[
- TextSegment(segment_id="pecha_seg_1", segment_number=1)
- ]
- ),
- Section(
- id="section_2",
- title="Section 2",
- section_number=2,
- segments=[
- TextSegment(segment_id="pecha_seg_2", segment_number=1),
- TextSegment(segment_id="pecha_seg_3", segment_number=2)
- ]
- )
- ]
- )
-
- with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments):
- result = await replace_pecha_segment_id_with_segment_id(table_of_content=incoming_toc)
-
- assert result is not None
- assert len(result.sections) == 2
- assert result.sections[0].segments[0].segment_id == "db_seg_1"
- assert result.sections[1].segments[0].segment_id == "db_seg_2"
- assert result.sections[1].segments[1].segment_id == "db_seg_3"
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_service_success():
- """Test get_text_by_pecha_text_ids_service with valid pecha_text_ids"""
- class MockText:
- def __init__(self, text_id, pecha_text_id, title, language, group_id, type_val, is_published,
- created_date, updated_date, published_date, published_by, categories=None,
- views=0, source_link=None, ranking=None, license_val=None):
- self.id = text_id
- self.pecha_text_id = pecha_text_id
- self.title = title
- self.language = language
- self.group_id = group_id
- self.type = type_val
- self.is_published = is_published
- self.created_date = created_date
- self.updated_date = updated_date
- self.published_date = published_date
- self.published_by = published_by
- self.categories = categories or []
- self.views = views
- self.source_link = source_link
- self.ranking = ranking
- self.license = license_val
-
- mock_texts = [
- MockText(
- text_id="text_id_1",
- pecha_text_id="pecha_text_id_1",
- title="Test Text 1",
- language="bo",
- group_id="group_id_1",
- type_val="root_text",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="user_1",
- categories=["cat1"],
- views=10,
- source_link="https://source1.com",
- ranking=1,
- license_val="CC0"
- ),
- MockText(
- text_id="text_id_2",
- pecha_text_id="pecha_text_id_2",
- title="Test Text 2",
- language="en",
- group_id="group_id_2",
- type_val="version",
- is_published=True,
- created_date="2025-01-02T00:00:00",
- updated_date="2025-01-02T00:00:00",
- published_date="2025-01-02T00:00:00",
- published_by="user_2",
- categories=["cat2"],
- views=20,
- source_link="https://source2.com",
- ranking=2,
- license_val="CC BY"
- )
- ]
-
- request = TextsByPechaTextIdsRequest(pecha_text_ids=["pecha_text_id_1", "pecha_text_id_2"])
-
- with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=mock_texts):
- result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request)
-
- assert result is not None
- assert len(result) == 2
- assert isinstance(result[0], TextDTO)
- assert result[0].id == "text_id_1"
- assert result[0].pecha_text_id == "pecha_text_id_1"
- assert result[0].title == "Test Text 1"
- assert result[0].language == "bo"
- assert result[0].group_id == "group_id_1"
- assert result[0].type == "root_text"
- assert result[0].is_published == True
- assert result[0].categories == ["cat1"]
- assert result[0].views == 10
- assert result[0].source_link == "https://source1.com"
- assert result[0].ranking == 1
- assert result[0].license == "CC0"
-
- assert isinstance(result[1], TextDTO)
- assert result[1].id == "text_id_2"
- assert result[1].pecha_text_id == "pecha_text_id_2"
- assert result[1].title == "Test Text 2"
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_service_empty_result():
- """Test get_text_by_pecha_text_ids_service when no texts are found"""
- request = TextsByPechaTextIdsRequest(pecha_text_ids=["nonexistent_pecha_id"])
-
- with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=[]):
- result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request)
-
- assert result is not None
- assert len(result) == 0
- assert result == []
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_service_single_text():
- """Test get_text_by_pecha_text_ids_service with single pecha_text_id"""
- class MockText:
- def __init__(self):
- self.id = "text_id_single"
- self.pecha_text_id = "pecha_text_id_single"
- self.title = "Single Test Text"
- self.language = "bo"
- self.group_id = "group_id_single"
- self.type = "root_text"
- self.is_published = True
- self.created_date = "2025-01-01T00:00:00"
- self.updated_date = "2025-01-01T00:00:00"
- self.published_date = "2025-01-01T00:00:00"
- self.published_by = "user_single"
- self.categories = []
- self.views = 5
- self.source_link = "https://source.com"
- self.ranking = 1
- self.license = "CC0"
-
- mock_texts = [MockText()]
- request = TextsByPechaTextIdsRequest(pecha_text_ids=["pecha_text_id_single"])
-
- with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=mock_texts):
- result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request)
-
- assert result is not None
- assert len(result) == 1
- assert isinstance(result[0], TextDTO)
- assert result[0].id == "text_id_single"
- assert result[0].pecha_text_id == "pecha_text_id_single"
- assert result[0].title == "Single Test Text"
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_service_with_none_optional_fields():
- """Test get_text_by_pecha_text_ids_service with texts having None optional fields"""
- class MockText:
- def __init__(self):
- self.id = "text_id_minimal"
- self.pecha_text_id = None
- self.title = "Minimal Text"
- self.language = "en"
- self.group_id = "group_id_minimal"
- self.type = "version"
- self.is_published = False
- self.created_date = "2025-01-01T00:00:00"
- self.updated_date = "2025-01-01T00:00:00"
- self.published_date = "2025-01-01T00:00:00"
- self.published_by = "user_minimal"
- self.categories = None
- self.views = 0
- self.source_link = None
- self.ranking = None
- self.license = None
-
- mock_texts = [MockText()]
- request = TextsByPechaTextIdsRequest(pecha_text_ids=["pecha_text_id_minimal"])
-
- with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=mock_texts):
- result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request)
-
- assert result is not None
- assert len(result) == 1
- assert isinstance(result[0], TextDTO)
- assert result[0].id == "text_id_minimal"
- # Note: str(None) returns "None" as a string in the service
- assert result[0].pecha_text_id == "None"
- assert result[0].title == "Minimal Text"
- assert result[0].categories is None
- assert result[0].views == 0
- assert result[0].source_link is None
- assert result[0].ranking is None
- assert result[0].license is None
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_service_empty_request():
- """Test get_text_by_pecha_text_ids_service with empty pecha_text_ids list"""
- request = TextsByPechaTextIdsRequest(pecha_text_ids=[])
-
- with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=[]):
- result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request)
-
- assert result is not None
- assert len(result) == 0
- assert result == []
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_pecha_text_ids_service_multiple_texts_various_types():
- """Test get_text_by_pecha_text_ids_service with texts of various types"""
- class MockText:
- def __init__(self, text_id, pecha_text_id, title, text_type):
- self.id = text_id
- self.pecha_text_id = pecha_text_id
- self.title = title
- self.language = "bo"
- self.group_id = "group_id_1"
- self.type = text_type
- self.is_published = True
- self.created_date = "2025-01-01T00:00:00"
- self.updated_date = "2025-01-01T00:00:00"
- self.published_date = "2025-01-01T00:00:00"
- self.published_by = "user_1"
- self.categories = []
- self.views = 10
- self.source_link = "https://source.com"
- self.ranking = 1
- self.license = "CC0"
-
- mock_texts = [
- MockText("text_id_1", "pecha_1", "Root Text", "root_text"),
- MockText("text_id_2", "pecha_2", "Version Text", "version"),
- MockText("text_id_3", "pecha_3", "Commentary Text", "commentary")
- ]
-
- request = TextsByPechaTextIdsRequest(pecha_text_ids=["pecha_1", "pecha_2", "pecha_3"])
-
- with patch("pecha_api.texts.texts_service.get_texts_by_pecha_text_ids", new_callable=AsyncMock, return_value=mock_texts):
- result = await get_text_by_pecha_text_ids_service(texts_by_pecha_text_ids_request=request)
-
- assert result is not None
- assert len(result) == 3
- assert result[0].type == "root_text"
- assert result[1].type == "version"
- assert result[2].type == "commentary"
- assert result[0].title == "Root Text"
- assert result[1].title == "Version Text"
- assert result[2].title == "Commentary Text"
-
-
-@pytest.mark.asyncio
-async def test_get_titles_and_ids_by_query_success():
- """Test get_titles_and_ids_by_query returns title search results"""
- mock_response_data = [
- {"title": {"en": "A Title"}, "language": "en"},
- {"title": {"bo": "མཚན"}, "language": "bo"},
- {"title": {"en": "A Title"}, "language": "en"}
- ]
-
- mock_http_response = Mock()
- mock_http_response.json.return_value = mock_response_data
- mock_http_response.raise_for_status = Mock()
-
- mock_client = AsyncMock()
- mock_client.get = AsyncMock(return_value=mock_http_response)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=None)
-
- class MockText:
- def __init__(self, text_id: str, title: str):
- self.id = text_id
- self.title = title
-
- mock_texts = [
- MockText(text_id="id_1", title="A Title"),
- MockText(text_id="id_2", title="མཚན")
- ]
-
- with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", "https://external.example"), \
- patch("httpx.AsyncClient", return_value=mock_client), \
- patch("pecha_api.texts.texts_service.get_texts_by_titles", new_callable=AsyncMock, return_value=mock_texts) as mock_get_texts:
- result = await get_titles_and_ids_by_query(
- title="Test",
- author=None,
- limit=20,
- offset=0
- )
-
- assert len(result) == 2
- assert result[0].id == "id_1"
- assert result[0].title == "A Title"
- assert result[1].id == "id_2"
- assert result[1].title == "མཚན"
-
- call_args = mock_client.get.call_args
- assert call_args.kwargs["params"]["title"] == "Test"
- assert call_args.kwargs["params"]["limit"] == 20
- assert call_args.kwargs["params"]["offset"] == 0
-
- mock_get_texts.assert_awaited_once()
- called_titles = mock_get_texts.call_args.kwargs["titles"]
- assert "A Title" in called_titles
- assert "མཚན" in called_titles
-
-
-def test_extract_title_for_language_variants():
- """Test extract_title_for_language with dict, string, and other payloads"""
- from pecha_api.texts.texts_service import extract_title_for_language
-
- assert extract_title_for_language({"bo": "མཚན", "en": "Title"}, "bo") == "མཚན"
- assert extract_title_for_language({"en": " ", "zh": "Title"}, "fr") == "Title"
- assert extract_title_for_language(" A Title ", "bo") == "A Title"
- assert extract_title_for_language(12345, "bo") is None
-
-
-@pytest.mark.asyncio
-async def test_get_text_by_text_id_or_collection_cache_hit():
- """Test get_text_by_text_id_or_collection returns cached data"""
- text_id = "text_id_1"
- cached_text = TextDTO(
- id=text_id,
- title="Cached Title",
- language="bo",
- group_id="group_id_1",
- type="root_text",
- is_published=True,
- created_date="2025-03-21 09:40:34.025024",
- updated_date="2025-03-21 09:40:34.025035",
- published_date="2025-03-21 09:40:34.025038",
- published_by="pecha",
- categories=[],
- views=0
- )
-
- with patch("pecha_api.texts.texts_service.get_text_by_text_id_or_collection_cache", new_callable=AsyncMock, return_value=cached_text) as mock_get_cache, \
- patch("pecha_api.texts.texts_service.set_text_by_text_id_or_collection_cache", new_callable=AsyncMock) as mock_set_cache, \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock) as mock_get_detail:
- result = await get_text_by_text_id_or_collection(text_id=text_id, collection_id=None)
-
- assert result == cached_text
- mock_get_cache.assert_awaited_once()
- mock_set_cache.assert_not_awaited()
- mock_get_detail.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_get_titles_and_ids_by_query_requires_title_or_author():
- """Test get_titles_and_ids_by_query requires title or author"""
- with pytest.raises(HTTPException) as exc_info:
- await get_titles_and_ids_by_query(title=None, author=None, limit=10, offset=0)
-
- assert exc_info.value.status_code == 400
-
-
-@pytest.mark.asyncio
-async def test_get_titles_and_ids_by_query_missing_external_url():
- """Test get_titles_and_ids_by_query raises when API URL is missing"""
- with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", None):
- with pytest.raises(HTTPException) as exc_info:
- await get_titles_and_ids_by_query(title="Test", author=None, limit=10, offset=0)
-
- assert exc_info.value.status_code == 500
-
-
-@pytest.mark.asyncio
-async def test_get_titles_and_ids_by_query_author_param_and_empty_titles():
- """Test get_titles_and_ids_by_query with author-only and no valid titles"""
- mock_response_data = [
- {"title": {"en": " "}},
- "not-a-dict"
- ]
-
- mock_http_response = Mock()
- mock_http_response.json.return_value = mock_response_data
- mock_http_response.raise_for_status = Mock()
-
- mock_client = AsyncMock()
- mock_client.get = AsyncMock(return_value=mock_http_response)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=None)
-
- with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", "https://external.example"), \
- patch("httpx.AsyncClient", return_value=mock_client), \
- patch("pecha_api.texts.texts_service.get_texts_by_titles", new_callable=AsyncMock) as mock_get_texts:
- result = await get_titles_and_ids_by_query(
- title=None,
- author="Author",
- limit=20,
- offset=0
- )
-
- assert result == []
- call_args = mock_client.get.call_args
- assert call_args.kwargs["params"]["author"] == "Author"
- assert "title" not in call_args.kwargs["params"]
- mock_get_texts.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_get_titles_and_ids_by_query_http_status_error():
- """Test get_titles_and_ids_by_query handles HTTPStatusError"""
- mock_request = httpx.Request("GET", "https://external.example/v2/texts")
- mock_response = httpx.Response(500, request=mock_request)
-
- mock_http_response = Mock()
- mock_http_response.raise_for_status.side_effect = httpx.HTTPStatusError(
- "Server error",
- request=mock_request,
- response=mock_response
- )
-
- mock_client = AsyncMock()
- mock_client.get = AsyncMock(return_value=mock_http_response)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=None)
-
- with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", "https://external.example"), \
- patch("httpx.AsyncClient", return_value=mock_client), \
- patch("pecha_api.texts.texts_service.handle_http_status_error", side_effect=HTTPException(status_code=502, detail="Bad Gateway")) as mock_handle:
- with pytest.raises(HTTPException) as exc_info:
- await get_titles_and_ids_by_query(title="Test", author=None, limit=10, offset=0)
-
- assert exc_info.value.status_code == 502
- mock_handle.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_titles_and_ids_by_query_request_error():
- """Test get_titles_and_ids_by_query handles RequestError"""
- mock_request = httpx.Request("GET", "https://external.example/v2/texts")
- mock_client = AsyncMock()
- mock_client.get = AsyncMock(side_effect=httpx.RequestError("Network error", request=mock_request))
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=None)
-
- with patch("pecha_api.texts.texts_service.EXTERNAL_TITLE_SEARCH_API_URL", "https://external.example"), \
- patch("httpx.AsyncClient", return_value=mock_client), \
- patch("pecha_api.texts.texts_service.handle_request_error", side_effect=HTTPException(status_code=503, detail="Service unavailable")) as mock_handle:
- with pytest.raises(HTTPException) as exc_info:
- await get_titles_and_ids_by_query(title="Test", author=None, limit=10, offset=0)
-
- assert exc_info.value.status_code == 503
- mock_handle.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_texts_by_collection_id_skip_duplicate_and_limit():
- """Test _get_texts_by_collection_id skip, duplicate, and limit handling"""
- from pecha_api.texts.texts_service import _get_texts_by_collection_id
-
- class MockText:
- def __init__(self, text_id: str, group_id: str, language: str):
- self.id = text_id
- self.pecha_text_id = f"pecha_{text_id}"
- self.title = f"title_{text_id}"
- self.language = language
- self.group_id = group_id
- self.type = "root_text"
- self.is_published = True
- self.created_date = "2025-01-01T00:00:00"
- self.updated_date = "2025-01-01T00:00:00"
- self.published_date = "2025-01-01T00:00:00"
- self.published_by = "user_1"
-
- texts = [
- MockText("1", "g1", "bo"),
- MockText("2", "g1", "bo"),
- MockText("3", "g2", "bo")
- ]
-
- with patch("pecha_api.texts.texts_service.get_all_texts_by_collection", new_callable=AsyncMock, return_value=texts), \
- patch("pecha_api.texts.texts_service.TextUtils.get_language_priority", return_value=0):
- result, total_unique_group_ids = await _get_texts_by_collection_id(
- collection_id="collection_1",
- language="bo",
- skip=0,
- limit=10
- )
-
- assert total_unique_group_ids == 2
- assert len(result) == 2
- assert result[0].group_id == "g1"
- assert result[1].group_id == "g2"
-
-
-@pytest.mark.asyncio
-async def test_get_texts_by_collection_id_skip_first_group():
- """Test _get_texts_by_collection_id skip branch"""
- from pecha_api.texts.texts_service import _get_texts_by_collection_id
-
- class MockText:
- def __init__(self, text_id: str, group_id: str):
- self.id = text_id
- self.pecha_text_id = f"pecha_{text_id}"
- self.title = f"title_{text_id}"
- self.language = "bo"
- self.group_id = group_id
- self.type = "root_text"
- self.is_published = True
- self.created_date = "2025-01-01T00:00:00"
- self.updated_date = "2025-01-01T00:00:00"
- self.published_date = "2025-01-01T00:00:00"
- self.published_by = "user_1"
-
- texts = [
- MockText("1", "g1"),
- MockText("2", "g2")
- ]
-
- with patch("pecha_api.texts.texts_service.get_all_texts_by_collection", new_callable=AsyncMock, return_value=texts), \
- patch("pecha_api.texts.texts_service.TextUtils.get_language_priority", return_value=0):
- result, _ = await _get_texts_by_collection_id(
- collection_id="collection_1",
- language="bo",
- skip=1,
- limit=10
- )
-
- assert len(result) == 1
- assert result[0].group_id == "g2"
-
-
-@pytest.mark.asyncio
-async def test_get_texts_by_collection_id_breaks_on_limit():
- """Test _get_texts_by_collection_id stops at limit"""
- from pecha_api.texts.texts_service import _get_texts_by_collection_id
-
- class MockText:
- def __init__(self, text_id: str, group_id: str):
- self.id = text_id
- self.pecha_text_id = f"pecha_{text_id}"
- self.title = f"title_{text_id}"
- self.language = "bo"
- self.group_id = group_id
- self.type = "root_text"
- self.is_published = True
- self.created_date = "2025-01-01T00:00:00"
- self.updated_date = "2025-01-01T00:00:00"
- self.published_date = "2025-01-01T00:00:00"
- self.published_by = "user_1"
-
- texts = [
- MockText("1", "g1"),
- MockText("2", "g2")
- ]
-
- with patch("pecha_api.texts.texts_service.get_all_texts_by_collection", new_callable=AsyncMock, return_value=texts), \
- patch("pecha_api.texts.texts_service.TextUtils.get_language_priority", return_value=0):
- result, _ = await _get_texts_by_collection_id(
- collection_id="collection_1",
- language="bo",
- skip=0,
- limit=1
- )
-
- assert len(result) == 1
- assert result[0].group_id == "g1"
-
-
-def test_get_first_segment_and_table_of_content_nested_sections():
- """Test _get_first_segment_and_table_of_content_ finds nested segment"""
- from pecha_api.texts.texts_service import _get_first_segment_and_table_of_content_
-
- table_of_contents = [
- TableOfContent(
- id="toc_1",
- text_id="text_id",
- type=TableOfContentType.TEXT,
- sections=[
- Section(
- id="section_1",
- title="Section 1",
- section_number=1,
- segments=[],
- sections=[
- Section(
- id="section_2",
- title="Section 2",
- section_number=2,
- segments=[TextSegment(segment_id="seg_2", segment_number=1)],
- sections=[]
- )
- ]
- )
- ]
- )
- ]
-
- segment_id, table_of_content = _get_first_segment_and_table_of_content_(
- table_of_contents=table_of_contents
- )
-
- assert segment_id == "seg_2"
- assert table_of_content is not None
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_by_text_id_returns_not_found_when_false():
- """Test get_commentaries_by_text_id raises 404 when text is invalid"""
- text_id = "missing_text_id"
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_commentaries_by_text_id(text_id=text_id, skip=0, limit=10)
-
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-
-# ============================================================================
-# get_text_languages Service Tests
-# ============================================================================
-
-@pytest.mark.asyncio
-async def test_get_text_languages_success():
- """Test get_text_languages returns available languages with version counts"""
- text_id = "123e4567-e89b-12d3-a456-426614174000"
- group_id = "group_id_1"
-
- mock_text_detail = TextDTO(
- id=text_id,
- title="Test Text",
- language="bo",
- group_id=group_id,
- type="version",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
- categories=[],
- views=0
- )
-
- mock_texts = [
- MagicMock(language="bo"),
- MagicMock(language="bo"),
- MagicMock(language="en"),
- MagicMock(language="zh")
+ published_by="pecha",
+ categories=[],
+ views=0
+ )
]
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts):
+ # Mock filters for each group
+ def mock_filter_side_effect(texts, language):
+ if texts[0].group_id == group_id_1:
+ return {"root_text": texts[0], "versions": [texts[1]]}
+ elif texts[0].group_id == group_id_2:
+ return {"root_text": texts[0], "versions": [texts[1]]}
+ return {"root_text": None, "versions": texts}
+
+ with patch("pecha_api.texts.texts_service.get_all_recitation_texts_by_collection", new_callable=AsyncMock) as mock_get_all_texts, \
+ patch("pecha_api.texts.texts_service.TextUtils.filter_text_base_on_group_id_type_and_language_preference", new_callable=AsyncMock) as mock_filter:
+
+ mock_get_all_texts.return_value = mock_texts
+ mock_filter.side_effect = mock_filter_side_effect
- response = await get_text_languages(text_id=text_id)
+ result = await get_root_text_by_collection_id(collection_id=collection_id, language=language)
- assert response is not None
- assert isinstance(response, LanguageResponse)
- assert response.text_id == text_id
- assert response.title == "Test Text"
- assert len(response.available_languages) == 3
+ mock_get_all_texts.assert_called_once_with(collection_id=collection_id, language=language)
- lang_dict = {lang.language: lang.version_count for lang in response.available_languages}
- assert lang_dict["bo"] == 2
- assert lang_dict["en"] == 1
- assert lang_dict["zh"] == 1
-
+ assert result is not None
+ assert isinstance(result, RecitationsResponse)
+ assert len(result.recitations) == 2
+ assert isinstance(result.recitations[0], RecitationDTO)
+ assert isinstance(result.recitations[1], RecitationDTO)
+ # Check that we got both root texts
+ text_ids = {str(rec.text_id) for rec in result.recitations}
+ assert text_id_1 in text_ids
+ assert text_id_3 in text_ids
@pytest.mark.asyncio
-async def test_get_text_languages_text_not_found():
- """Test get_text_languages raises 404 when text doesn't exist"""
- text_id = "non-existent-id"
+async def test_get_table_of_content_by_type_text_type():
+ """Test get_table_of_content_by_type with TEXT type"""
+ text_id = "text_id_1"
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_text_languages(text_id=text_id)
+ incoming_toc = TableOfContent(
+ id="toc_id_1",
+ text_id=text_id,
+ type=TableOfContentType.TEXT,
+ sections=[
+ Section(
+ id="section_id_1",
+ title="section_1",
+ section_number=1,
+ parent_id="parent_id_1",
+ segments=[
+ TextSegment(segment_id="pseg_1", segment_number=1)
+ ],
+ sections=[],
+ created_date="2025-03-16 04:40:54.757652",
+ updated_date="2025-03-16 04:40:54.757652",
+ published_date="2025-03-16 04:40:54.757652"
+ )
+ ]
+ )
+
+ expected_toc = TableOfContent(
+ text_id=text_id,
+ type=TableOfContentType.TEXT,
+ sections=[
+ Section(
+ id="section_id_1",
+ title="section_1",
+ section_number=1,
+ segments=[TextSegment(segment_id="seg_id_1", segment_number=1)]
+ )
+ ]
+ )
+
+ with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock) as mock_get_segments:
+ mock_get_segments.return_value = [type("Seg", (), {"id": "seg_id_1", "pecha_segment_id": "pseg_1"})()]
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
+ result = await get_table_of_content_by_type(table_of_content=incoming_toc)
+
+ assert result is not None
+ assert isinstance(result, TableOfContent)
+ assert result.text_id == text_id
+ assert result.type == TableOfContentType.TEXT
+ assert len(result.sections) == 1
+ assert result.sections[0].segments[0].segment_id == "seg_id_1"
@pytest.mark.asyncio
-async def test_get_text_languages_empty_languages():
- """Test get_text_languages returns empty list when no texts have languages"""
- text_id = "123e4567-e89b-12d3-a456-426614174000"
- group_id = "group_id_1"
+async def test_get_table_of_content_by_type_sheet_type():
+ """Test get_table_of_content_by_type with SHEET type"""
+ text_id = "text_id_1"
- mock_text_detail = TextDTO(
- id=text_id,
- title="Test Text",
- language=None,
- group_id=group_id,
- type="version",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
- categories=[],
- views=0
+ incoming_toc = TableOfContent(
+ id="toc_id_1",
+ text_id=text_id,
+ type=TableOfContentType.SHEET,
+ sections=[
+ Section(
+ id="section_id_1",
+ title="section_1",
+ section_number=1,
+ parent_id="parent_id_1",
+ segments=[
+ TextSegment(segment_id="seg_1", segment_number=1)
+ ],
+ sections=[],
+ created_date="2025-03-16 04:40:54.757652",
+ updated_date="2025-03-16 04:40:54.757652",
+ published_date="2025-03-16 04:40:54.757652"
+ )
+ ]
)
- mock_texts = [
- MagicMock(language=None),
- MagicMock(language=None)
- ]
+ result = await get_table_of_content_by_type(table_of_content=incoming_toc)
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts):
-
- response = await get_text_languages(text_id=text_id)
-
- assert response is not None
- assert isinstance(response, LanguageResponse)
- assert response.available_languages == []
+ assert result is not None
+ assert isinstance(result, TableOfContent)
+ assert result.text_id == text_id
+ assert result.type == TableOfContentType.SHEET
+ assert result.sections == incoming_toc.sections
+
@pytest.mark.asyncio
-async def test_get_text_languages_single_language():
- """Test get_text_languages with single language multiple versions"""
- text_id = "123e4567-e89b-12d3-a456-426614174000"
- group_id = "group_id_1"
-
- mock_text_detail = TextDTO(
- id=text_id,
- title="Tibetan Only Text",
+async def test_update_text_details_cache_update_fails():
+ """Test update_text_details when cache update fails"""
+ mock_text_details = TextDTO(
+ id="text_id_1",
+ title="text_title",
language="bo",
- group_id=group_id,
+ group_id="group_id_1",
type="version",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
+ is_published=False,
+ created_date="created_date",
+ updated_date="updated_date",
+ published_date="published_date",
+ published_by="published_by",
categories=[],
views=0
)
- mock_texts = [
- MagicMock(language="bo"),
- MagicMock(language="bo"),
- MagicMock(language="bo"),
- MagicMock(language="bo"),
- MagicMock(language="bo")
- ]
-
with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts):
+ patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_details), \
+ patch("pecha_api.texts.texts_service.update_text_details_by_id", new_callable=AsyncMock, return_value=mock_text_details), \
+ patch("pecha_api.texts.texts_service.update_text_details_cache", new_callable=AsyncMock, side_effect=Exception("Cache error")), \
+ patch("pecha_api.texts.texts_service.invalidate_text_cache_on_update", new_callable=AsyncMock, return_value=None) as mock_invalidate:
- response = await get_text_languages(text_id=text_id)
+ response = await update_text_details(text_id="text_id_1", update_text_request=UpdateTextRequest(title="updated_title", is_published=True))
assert response is not None
- assert len(response.available_languages) == 1
- assert response.available_languages[0].language == "bo"
- assert response.available_languages[0].language_code == "bo"
- assert response.available_languages[0].version_count == 5
-
-
-# ============================================================================
-# get_language_versions Service Tests
-# ============================================================================
+ # Verify that invalidate was called as fallback
+ mock_invalidate.assert_called_once_with(text_id="text_id_1")
@pytest.mark.asyncio
-async def test_get_language_versions_success():
- """Test get_language_versions returns versions for a specific language"""
+async def test_update_text_details_with_cache_invalidation():
+ """Test update_text_details updates text successfully"""
text_id = "123e4567-e89b-12d3-a456-426614174000"
- language = "bo"
- group_id = "group_id_1"
+ update_request = UpdateTextRequest(
+ title="Updated Title",
+ language="bo"
+ )
- mock_text_detail = TextDTO(
+ updated_text = TextDTO(
id=text_id,
- title="Test Text",
+ title="Updated Title",
language="bo",
- group_id=group_id,
+ group_id="group_id_1",
type="version",
is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
+ created_date="2025-03-21 09:40:34.025024",
+ updated_date="2025-03-21 09:40:34.025035",
+ published_date="2025-03-21 09:40:34.025038",
published_by="pecha",
categories=[],
views=0
)
+ with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
+ patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=updated_text), \
+ patch("pecha_api.texts.texts_service.update_text_details_by_id", new_callable=AsyncMock, return_value=updated_text) as mock_update, \
+ patch("pecha_api.texts.texts_service.invalidate_text_cache_on_update", new_callable=AsyncMock):
+
+ result = await update_text_details(text_id=text_id, update_text_request=update_request)
+
+ assert result is not None
+ assert result.title == "Updated Title"
+ mock_update.assert_called_once_with(text_id=text_id, update_text_request=update_request)
+
+# Tests for new private functions
+def test_group_texts_by_group_id_with_language_sorting():
+ """Test _group_texts_by_group_id groups texts and sorts by language preference"""
+ from pecha_api.texts.texts_service import _group_texts_by_group_id
+
+ # Create mock texts with different group_ids and languages
mock_texts = [
TextDTO(
- id=text_id,
- title="Version 1",
+ id="text_id_1",
+ title="Text 1 Bo",
language="bo",
- group_id=group_id,
+ group_id="group_1",
+ type="version",
+ is_published=True,
+ created_date="2025-03-21 09:40:34.025024",
+ updated_date="2025-03-21 09:40:34.025035",
+ published_date="2025-03-21 09:40:34.025038",
+ published_by="pecha",
+ categories=[],
+ views=0
+ ),
+ TextDTO(
+ id="text_id_2",
+ title="Text 2 En",
+ language="en",
+ group_id="group_1",
+ type="version",
+ is_published=True,
+ created_date="2025-03-21 09:40:34.025024",
+ updated_date="2025-03-21 09:40:34.025035",
+ published_date="2025-03-21 09:40:34.025038",
+ published_by="pecha",
+ categories=[],
+ views=0
+ ),
+ TextDTO(
+ id="text_id_3",
+ title="Text 3 Zh",
+ language="zh",
+ group_id="group_1",
type="version",
is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
+ created_date="2025-03-21 09:40:34.025024",
+ updated_date="2025-03-21 09:40:34.025035",
+ published_date="2025-03-21 09:40:34.025038",
published_by="pecha",
categories=[],
- views=0,
- source_link="https://source-1.com",
- ranking=1,
- license="CC0"
+ views=0
),
TextDTO(
- id="text-2-uuid",
- title="Version 2",
+ id="text_id_4",
+ title="Text 4 Bo Group2",
language="bo",
- group_id=group_id,
+ group_id="group_2",
+ type="version",
+ is_published=True,
+ created_date="2025-03-21 09:40:34.025024",
+ updated_date="2025-03-21 09:40:34.025035",
+ published_date="2025-03-21 09:40:34.025038",
+ published_by="pecha",
+ categories=[],
+ views=0
+ )
+ ]
+
+ # Test with 'en' as preferred language
+ result = _group_texts_by_group_id(texts=mock_texts, language="en")
+
+ # Verify grouping
+ assert len(result) == 2
+ assert "group_1" in result
+ assert "group_2" in result
+ assert len(result["group_1"]) == 3
+ assert len(result["group_2"]) == 1
+
+ # Verify sorting - 'en' should be first for group_1
+ assert result["group_1"][0].language == "en"
+ assert result["group_1"][1].language == "bo"
+ assert result["group_1"][2].language == "zh"
+
+
+def test_group_texts_by_group_id_with_bo_preference():
+ """Test _group_texts_by_group_id with Tibetan (bo) language preference"""
+ from pecha_api.texts.texts_service import _group_texts_by_group_id
+
+ mock_texts = [
+ TextDTO(
+ id="text_id_1",
+ title="Text En",
+ language="en",
+ group_id="group_1",
type="version",
is_published=True,
- created_date="2025-01-02T00:00:00",
- updated_date="2025-01-02T00:00:00",
- published_date="2025-01-02T00:00:00",
+ created_date="2025-03-21 09:40:34.025024",
+ updated_date="2025-03-21 09:40:34.025035",
+ published_date="2025-03-21 09:40:34.025038",
published_by="pecha",
categories=[],
- views=0,
- source_link="https://source-2.com",
- ranking=2,
- license="CC BY"
+ views=0
),
TextDTO(
- id="text-3-uuid",
- title="English Version",
- language="en",
- group_id=group_id,
- type="translation",
+ id="text_id_2",
+ title="Text Bo",
+ language="bo",
+ group_id="group_1",
+ type="version",
is_published=True,
- created_date="2025-01-03T00:00:00",
- updated_date="2025-01-03T00:00:00",
- published_date="2025-01-03T00:00:00",
+ created_date="2025-03-21 09:40:34.025024",
+ updated_date="2025-03-21 09:40:34.025035",
+ published_date="2025-03-21 09:40:34.025038",
published_by="pecha",
categories=[],
views=0
)
]
- mock_table_of_content = TableOfContent(
- id="toc-1",
- text_id=text_id,
- type=TableOfContentType.TEXT,
- sections=[]
- )
+ result = _group_texts_by_group_id(texts=mock_texts, language="bo")
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[mock_table_of_content]):
-
- response = await get_language_versions(text_id=text_id, language=language)
-
- assert response is not None
- assert isinstance(response, VersionsResponse)
- assert response.text_id == text_id
- assert response.language == language
- assert len(response.available_versions) == 2
- assert all(v.language == "bo" for v in response.available_versions)
-
- selected_versions = [v for v in response.available_versions if v.is_selected]
- assert len(selected_versions) == 1
- assert selected_versions[0].id == text_id
+ # Verify 'bo' is first
+ assert result["group_1"][0].language == "bo"
+ assert result["group_1"][1].language == "en"
-@pytest.mark.asyncio
-async def test_get_language_versions_text_not_found():
- """Test get_language_versions raises 404 when text doesn't exist"""
- text_id = "non-existent-id"
- language = "bo"
+def test_get_language_priority_via_textutils():
+ """Test language priority through TextUtils (used by service layer)"""
+ from pecha_api.texts.texts_utils import TextUtils
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_language_versions(text_id=text_id, language=language)
-
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
+ # Test with 'bo' preference
+ assert TextUtils.get_language_priority("bo", "bo") == 0
+ assert TextUtils.get_language_priority("en", "bo") == 1
+ assert TextUtils.get_language_priority("zh", "bo") == 2
+ assert TextUtils.get_language_priority("unknown", "bo") == 999
+
+ # Test with 'en' preference
+ assert TextUtils.get_language_priority("en", "en") == 0
+ assert TextUtils.get_language_priority("bo", "en") == 1
+ assert TextUtils.get_language_priority("zh", "en") == 2
+ assert TextUtils.get_language_priority("unknown", "en") == 999
+
+ # Test with 'zh' preference
+ assert TextUtils.get_language_priority("zh", "zh") == 0
+ assert TextUtils.get_language_priority("en", "zh") == 1
+ assert TextUtils.get_language_priority("bo", "zh") == 2
-@pytest.mark.asyncio
-async def test_get_language_versions_empty_for_language():
- """Test get_language_versions returns empty list when no versions for requested language"""
- text_id = "123e4567-e89b-12d3-a456-426614174000"
- language = "zh"
- group_id = "group_id_1"
+def test_get_language_priority_with_none_via_textutils():
+ """Test TextUtils.get_language_priority handles None text_language"""
+ from pecha_api.texts.texts_utils import TextUtils
- mock_text_detail = TextDTO(
- id=text_id,
- title="Test Text",
- language="bo",
- group_id=group_id,
- type="version",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="pecha",
- categories=[],
- views=0
- )
+ # None language should get default priority
+ assert TextUtils.get_language_priority(None, "bo") == 999
+ assert TextUtils.get_language_priority(None, "en") == 999
+
+
+def test_group_texts_by_group_id_empty_texts():
+ """Test _group_texts_by_group_id with empty text list"""
+ from pecha_api.texts.texts_service import _group_texts_by_group_id
- mock_texts = [
- TextDTO(
- id=text_id,
- title="Tibetan Version",
- language="bo",
- group_id=group_id,
- type="version",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="pecha",
- categories=[],
- views=0
- ),
- TextDTO(
- id="text-2-uuid",
- title="English Version",
- language="en",
- group_id=group_id,
- type="translation",
- is_published=True,
- created_date="2025-01-02T00:00:00",
- updated_date="2025-01-02T00:00:00",
- published_date="2025-01-02T00:00:00",
- published_by="pecha",
- categories=[],
- views=0
- )
- ]
+ result = _group_texts_by_group_id(texts=[], language="bo")
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts):
-
- response = await get_language_versions(text_id=text_id, language=language)
-
- assert response is not None
- assert isinstance(response, VersionsResponse)
- assert response.text_id == text_id
- assert response.language == language
- assert len(response.available_versions) == 0
+ assert result == {}
-@pytest.mark.asyncio
-async def test_get_language_versions_with_table_of_contents():
- """Test get_language_versions includes table_of_contents for each version"""
- text_id = "123e4567-e89b-12d3-a456-426614174000"
- language = "bo"
- group_id = "group_id_1"
-
- mock_text_detail = TextDTO(
- id=text_id,
- title="Test Text",
- language="bo",
- group_id=group_id,
- type="version",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="pecha",
- categories=[],
- views=0
- )
+def test_group_texts_by_group_id_single_group():
+ """Test _group_texts_by_group_id with single group"""
+ from pecha_api.texts.texts_service import _group_texts_by_group_id
mock_texts = [
TextDTO(
- id=text_id,
- title="Version 1",
+ id="text_id_1",
+ title="Text 1",
language="bo",
- group_id=group_id,
+ group_id="single_group",
type="version",
is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
+ created_date="2025-03-21 09:40:34.025024",
+ updated_date="2025-03-21 09:40:34.025035",
+ published_date="2025-03-21 09:40:34.025038",
published_by="pecha",
categories=[],
views=0
)
]
- mock_table_of_contents = [
- TableOfContent(id="toc-1", text_id=text_id, type=TableOfContentType.TEXT, sections=[]),
- TableOfContent(id="toc-2", text_id=text_id, type=TableOfContentType.TEXT, sections=[])
- ]
+ result = _group_texts_by_group_id(texts=mock_texts, language="bo")
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_all_texts_by_group_id", new_callable=AsyncMock, return_value=mock_texts), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents):
-
- response = await get_language_versions(text_id=text_id, language=language)
-
- assert response is not None
- assert len(response.available_versions) == 1
- assert len(response.available_versions[0].table_of_contents) == 2
- assert "toc-1" in response.available_versions[0].table_of_contents
- assert "toc-2" in response.available_versions[0].table_of_contents
+ assert len(result) == 1
+ assert "single_group" in result
+ assert len(result["single_group"]) == 1
+ assert result["single_group"][0].id == "text_id_1"
-# ============================================================================
-# get_version_info Service Tests
-# ============================================================================
@pytest.mark.asyncio
-async def test_get_version_info_success():
- """Test get_version_info returns version details for a valid version_id"""
- version_id = "123e4567-e89b-12d3-a456-426614174000"
- group_id = "group_id_1"
+async def test_replace_pecha_segment_id_with_segment_id_success():
+ """Test replace_pecha_segment_id_with_segment_id converts pecha segment IDs to database segment IDs"""
+ text_id = "text_id_1"
- mock_text_detail = TextDTO(
- id=version_id,
- title="Test Version",
- language="bo",
- group_id=group_id,
- type="version",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="pecha",
- categories=[],
- views=0,
- source_link="https://source.com",
- ranking=1,
- license="CC0"
- )
+ # Mock segments from database
+ class MockSegment:
+ def __init__(self, segment_id, pecha_segment_id):
+ self.id = segment_id
+ self.pecha_segment_id = pecha_segment_id
- mock_table_of_contents = [
- TableOfContent(id="toc-1", text_id=version_id, type=TableOfContentType.TEXT, sections=[]),
- TableOfContent(id="toc-2", text_id=version_id, type=TableOfContentType.TEXT, sections=[])
+ mock_segments = [
+ MockSegment("db_seg_1", "pecha_seg_1"),
+ MockSegment("db_seg_2", "pecha_seg_2")
]
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents):
-
- response = await get_version_info(version_id=version_id)
-
- assert response is not None
- assert isinstance(response, VersionDetail)
- assert response.id == version_id
- assert response.title == "Test Version"
- assert response.language == "bo"
- assert response.type == "version"
- assert response.group_id == group_id
- assert response.is_published == True
- assert response.is_selected == True
- assert len(response.table_of_contents) == 2
- assert "toc-1" in response.table_of_contents
- assert "toc-2" in response.table_of_contents
- assert response.source_link == "https://source.com"
- assert response.ranking == 1
- assert response.license == "CC0"
-
-
-@pytest.mark.asyncio
-async def test_get_version_info_text_not_found():
- """Test get_version_info raises 404 when version doesn't exist"""
- version_id = "non-existent-id"
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=False):
- with pytest.raises(HTTPException) as exc_info:
- await get_version_info(version_id=version_id)
-
- assert exc_info.value.status_code == 404
- assert exc_info.value.detail == ErrorConstants.TEXT_NOT_FOUND_MESSAGE
-
-
-@pytest.mark.asyncio
-async def test_get_version_info_empty_table_of_contents():
- """Test get_version_info returns empty table_of_contents when no TOC exists"""
- version_id = "123e4567-e89b-12d3-a456-426614174000"
- group_id = "group_id_1"
-
- mock_text_detail = TextDTO(
- id=version_id,
- title="Version without TOC",
- language="bo",
- group_id=group_id,
- type="version",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="pecha",
- categories=[],
- views=0
+ incoming_toc = TableOfContent(
+ text_id=text_id,
+ type=TableOfContentType.TEXT,
+ sections=[
+ Section(
+ id="section_1",
+ title="Section 1",
+ section_number=1,
+ segments=[
+ TextSegment(segment_id="pecha_seg_1", segment_number=1),
+ TextSegment(segment_id="pecha_seg_2", segment_number=2)
+ ]
+ )
+ ]
)
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[]):
-
- response = await get_version_info(version_id=version_id)
+ with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments):
+ result = await replace_pecha_segment_id_with_segment_id(table_of_content=incoming_toc)
- assert response is not None
- assert isinstance(response, VersionDetail)
- assert response.id == version_id
- assert response.table_of_contents == []
+ assert result is not None
+ assert isinstance(result, TableOfContent)
+ assert result.text_id == text_id
+ assert result.type == TableOfContentType.TEXT
+ assert len(result.sections) == 1
+ assert len(result.sections[0].segments) == 2
+ # Verify segment IDs were replaced
+ assert result.sections[0].segments[0].segment_id == "db_seg_1"
+ assert result.sections[0].segments[1].segment_id == "db_seg_2"
@pytest.mark.asyncio
-async def test_get_version_info_with_optional_fields_none():
- """Test get_version_info handles optional fields as None"""
- version_id = "123e4567-e89b-12d3-a456-426614174000"
- group_id = "group_id_1"
+async def test_replace_pecha_segment_id_with_segment_id_multiple_sections():
+ """Test replace_pecha_segment_id_with_segment_id with multiple sections"""
+ text_id = "text_id_1"
- mock_text_detail = TextDTO(
- id=version_id,
- title="Version with None fields",
- language="bo",
- group_id=group_id,
- type="version",
- is_published=False,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="pecha",
- categories=[],
- views=0,
- source_link=None,
- ranking=None,
- license=None
- )
+ class MockSegment:
+ def __init__(self, segment_id, pecha_segment_id):
+ self.id = segment_id
+ self.pecha_segment_id = pecha_segment_id
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=[]):
-
- response = await get_version_info(version_id=version_id)
-
- assert response is not None
- assert isinstance(response, VersionDetail)
- assert response.source_link is None
- assert response.ranking is None
- assert response.license is None
- assert response.parent_id is None
- assert response.priority is None
- assert response.is_published == False
-
-
-@pytest.mark.asyncio
-async def test_get_version_info_translation_type():
- """Test get_version_info works with translation type versions"""
- version_id = "123e4567-e89b-12d3-a456-426614174000"
- group_id = "group_id_1"
+ mock_segments = [
+ MockSegment("db_seg_1", "pecha_seg_1"),
+ MockSegment("db_seg_2", "pecha_seg_2"),
+ MockSegment("db_seg_3", "pecha_seg_3")
+ ]
- mock_text_detail = TextDTO(
- id=version_id,
- title="English Translation",
- language="en",
- group_id=group_id,
- type="translation",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="translator",
- categories=["category-1"],
- views=100,
- source_link="https://translation.com",
- ranking=2,
- license="CC BY"
+ incoming_toc = TableOfContent(
+ text_id=text_id,
+ type=TableOfContentType.TEXT,
+ sections=[
+ Section(
+ id="section_1",
+ title="Section 1",
+ section_number=1,
+ segments=[
+ TextSegment(segment_id="pecha_seg_1", segment_number=1)
+ ]
+ ),
+ Section(
+ id="section_2",
+ title="Section 2",
+ section_number=2,
+ segments=[
+ TextSegment(segment_id="pecha_seg_2", segment_number=1),
+ TextSegment(segment_id="pecha_seg_3", segment_number=2)
+ ]
+ )
+ ]
)
- mock_table_of_contents = [
- TableOfContent(id="toc-1", text_id=version_id, type=TableOfContentType.TEXT, sections=[])
- ]
-
- with patch("pecha_api.texts.texts_service.TextUtils.validate_text_exists", new_callable=AsyncMock, return_value=True), \
- patch("pecha_api.texts.texts_service.TextUtils.get_text_detail_by_id", new_callable=AsyncMock, return_value=mock_text_detail), \
- patch("pecha_api.texts.texts_service.get_contents_by_id", new_callable=AsyncMock, return_value=mock_table_of_contents):
-
- response = await get_version_info(version_id=version_id)
+ with patch("pecha_api.texts.texts_service.get_segments_by_text_id", new_callable=AsyncMock, return_value=mock_segments):
+ result = await replace_pecha_segment_id_with_segment_id(table_of_content=incoming_toc)
- assert response is not None
- assert isinstance(response, VersionDetail)
- assert response.type == "translation"
- assert response.language == "en"
- assert response.title == "English Translation"
- assert len(response.table_of_contents) == 1
\ No newline at end of file
+ assert result is not None
+ assert len(result.sections) == 2
+ assert result.sections[0].segments[0].segment_id == "db_seg_1"
+ assert result.sections[1].segments[0].segment_id == "db_seg_2"
+ assert result.sections[1].segments[1].segment_id == "db_seg_3"
\ No newline at end of file
diff --git a/tests/texts/test_texts_views.py b/tests/texts/test_texts_views.py
deleted file mode 100644
index e03358fb5..000000000
--- a/tests/texts/test_texts_views.py
+++ /dev/null
@@ -1,1348 +0,0 @@
-from unittest.mock import AsyncMock, patch
-
-import pytest
-from fastapi import HTTPException, status
-from fastapi.testclient import TestClient
-from httpx import AsyncClient, ASGITransport
-
-from pecha_api.app import api
-from pecha_api.texts.texts_response_models import (
- CreateTextRequest,
- TableOfContentResponse,
- TextDTO,
- TextVersionResponse,
- TextVersion,
- TableOfContent,
- TableOfContentType,
- Section,
- TextDetailsRequest,
- TextsByPechaTextIdsRequest,
- TitleSearchResult,
- LanguageResponse,
- AvailableLanguage,
- VersionDetail,
- VersionsResponse
-)
-
-client = TestClient(api)
-
-# Test data
-MOCK_TEXT_DTO = TextDTO(
- id="123e4567-e89b-12d3-a456-426614174000",
- pecha_text_id="test_pecha_id",
- title="Test Text",
- language="bo",
- group_id="123e4567-e89b-12d3-a456-426614174000",
- type="version",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
- categories=[],
- views=0,
- source_link="https://test-source.com",
- ranking=1,
- license="CC0"
-)
-
-# Create a simple section for the table of contents
-mock_section = Section(
- id="123e4567-e89b-12d3-a456-426614174002",
- title="Chapter 1",
- section_number=1,
- parent_id=None,
- segments=[],
- sections=[],
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00"
-)
-
-# Create a table of content with the section
-MOCK_TABLE_OF_CONTENT = TableOfContent(
- id="123e4567-e89b-12d3-a456-426614174001",
- text_id="123e4567-e89b-12d3-a456-426614174000",
- type=TableOfContentType.TEXT,
- sections=[mock_section]
-)
-
-
-
-# Create response objects
-# Create a proper DetailTableOfContentResponse
-MOCK_TABLE_OF_CONTENT_RESPONSE = TableOfContentResponse(
- text_detail=MOCK_TEXT_DTO,
- contents=[MOCK_TABLE_OF_CONTENT]
-)
-
-# Create a TextVersion instance for the response
-MOCK_TEXT_VERSION = TextVersion(
- id="123e4567-e89b-12d3-a456-426614174003",
- title="Test Text Version",
- parent_id=None,
- priority=1,
- language="bo",
- type="version",
- group_id="123e4567-e89b-12d3-a456-426614174004",
- table_of_contents=[],
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user"
-)
-
-MOCK_TEXT_VERSION_RESPONSE = TextVersionResponse(
- text=MOCK_TEXT_DTO,
- versions=[MOCK_TEXT_VERSION]
-)
-
-VALID_TOKEN = "valid_token_123"
-
-@pytest.mark.asyncio
-async def test_get_text_by_text_id(mocker):
- """Test GET /texts with text_id parameter"""
- mock_get_text = mocker.patch(
- 'pecha_api.texts.texts_views.get_text_by_text_id_or_collection',
- new_callable=AsyncMock,
- return_value=MOCK_TEXT_DTO
- )
-
- test_text_id = "123e4567-e89b-12d3-a456-426614174000"
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(f"/texts?text_id={test_text_id}&language=bo&skip=0&limit=10")
-
- # Assertions
- assert response.status_code == 200
- response_data = response.json()
- assert isinstance(response_data, dict)
- assert response_data["id"] == MOCK_TEXT_DTO.id
- assert response_data["title"] == MOCK_TEXT_DTO.title
- assert response_data["language"] == MOCK_TEXT_DTO.language
- mock_get_text.assert_called_once_with(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- collection_id=None,
- language="bo",
- skip=0,
- limit=10
- )
-
-@pytest.mark.asyncio
-async def test_get_text_by_collection_id(mocker):
- """Test GET /texts with collection_id parameter"""
- # Mock the service function
- mock_get_text = mocker.patch(
- 'pecha_api.texts.texts_views.get_text_by_text_id_or_collection',
- new_callable=AsyncMock,
- return_value=MOCK_TEXT_DTO
- )
-
- # The collection ID that will be used in the request
- test_collection_id = "123e4567-e89b-12d3-a456-426614174001"
-
- # Make the request
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(f"/texts?collection_id={test_collection_id}&language=bo&skip=0&limit=10")
-
- # Assertions
- assert response.status_code == 200
- response_data = response.json()
- assert isinstance(response_data, dict)
- assert response_data["id"] == MOCK_TEXT_DTO.id
- assert response_data["title"] == MOCK_TEXT_DTO.title
- assert response_data["language"] == MOCK_TEXT_DTO.language
- # Remove the contents check as it's not part of the TextDTO model
- mock_get_text.assert_called_once_with(
- text_id=None,
- collection_id=test_collection_id,
- language="bo",
- skip=0,
- limit=10
- )
-
-@pytest.mark.asyncio
-async def test_create_text_success(mocker):
- """Test POST /texts with valid data"""
- # Mock the service function
- mock_create_text = mocker.patch(
- 'pecha_api.texts.texts_views.create_new_text',
- new_callable=AsyncMock,
- return_value=MOCK_TEXT_DTO
- )
-
- # Test data - match the CreateTextRequest model
- # Note: The mock returns MOCK_TEXT_DTO which has title="Test Text"
- create_data = {
- "pecha_text_id": "test_pecha_id",
- "title": "Test Text", # Match the mock data
- "language": "bo",
- "isPublished": True,
- "group_id": "123e4567-e89b-12d3-a456-426614174000",
- "type": "version",
- "published_by": "test_user",
- "categories": [],
- "source_link": "https://test-source.com",
- "ranking": 1,
- "license": "CC0"
- }
-
- # Make the request
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/texts",
- json=create_data,
- headers={"Authorization": f"Bearer {VALID_TOKEN}"}
- )
-
- # Assertions - 201 Created is the correct status code for resource creation
- assert response.status_code == 201
- response_data = response.json()
- assert response_data["title"] == create_data["title"]
- assert response_data["language"] == create_data["language"]
- assert response_data["type"] == create_data["type"]
- assert response_data["is_published"] == create_data["isPublished"]
- mock_create_text.assert_called_once()
- call_args = mock_create_text.call_args[1]
- assert call_args["token"] == VALID_TOKEN
- assert isinstance(call_args["create_text_request"], CreateTextRequest)
-
-@pytest.mark.asyncio
-async def test_get_versions_success(mocker):
- """Test GET /texts/{text_id}/versions"""
- # Mock the service function
- mock_get_versions = mocker.patch(
- 'pecha_api.texts.texts_views.get_text_versions_by_group_id',
- new_callable=AsyncMock,
- return_value={
- "text": MOCK_TEXT_DTO,
- "versions": [MOCK_TEXT_VERSION.model_dump()]
- }
- )
-
- # The text ID that will be used in the request
- test_text_id = "123e4567-e89b-12d3-a456-426614174000"
-
- # Make the request
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(f"/texts/{test_text_id}/versions?language=bo&skip=0&limit=10")
-
- # Assertions
- assert response.status_code == 200
- response_data = response.json()
- assert "text" in response_data
- assert "versions" in response_data
- assert isinstance(response_data["versions"], list)
- if response_data["versions"]: # Check only if versions list is not empty
- version = response_data["versions"][0]
- assert "id" in version
- assert "title" in version
- assert "language" in version
- mock_get_versions.assert_called_once_with(
- text_id=test_text_id,
- language="bo",
- skip=0,
- limit=10
- )
-
-@pytest.mark.asyncio
-async def test_get_contents_success(mocker):
- """Test GET /texts/{text_id}/contents"""
- # Mock the service function
- mock_get_contents = mocker.patch(
- 'pecha_api.texts.texts_views.get_table_of_contents_by_text_id',
- new_callable=AsyncMock,
- return_value=MOCK_TABLE_OF_CONTENT_RESPONSE
- )
-
- # Make the request
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/contents")
-
- # Assertions
- assert response.status_code == 200
- response_data = response.json()
- assert "text_detail" in response_data
- assert "contents" in response_data
- assert isinstance(response_data["contents"], list)
- assert len(response_data["contents"]) > 0
- mock_get_contents.assert_called_once_with(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- language=None,
- skip=0,
- limit=10
- )
-
-
-@pytest.mark.asyncio
-async def test_create_table_of_content_success(mocker):
- """Test POST /texts/table-of-content"""
- # Mock the service function
- mock_create_toc = mocker.patch(
- 'pecha_api.texts.texts_views.create_table_of_content',
- new_callable=AsyncMock,
- return_value=MOCK_TABLE_OF_CONTENT
- )
-
- # Test data - match the TableOfContent model
- toc_data = {
- "id": "123e4567-e89b-12d3-a456-426614174001",
- "text_id": "123e4567-e89b-12d3-a456-426614174000",
- "type": "text",
- "sections": [
- {
- "id": "123e4567-e89b-12d3-a456-426614174002",
- "title": "Chapter 1",
- "section_number": 1,
- "parent_id": None,
- "segments": [],
- "sections": [],
- "created_date": "2025-01-01T00:00:00",
- "updated_date": "2025-01-01T00:00:00",
- "published_date": "2025-01-01T00:00:00",
- "published_by": "test_user"
- }
- ]
- }
-
- # Make the request
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/texts/table-of-content",
- json=toc_data,
- headers={"Authorization": f"Bearer {VALID_TOKEN}"}
- )
-
- # Assertions
- assert response.status_code == 200
- response_data = response.json()
- assert "id" in response_data
- assert "text_id" in response_data
- assert "sections" in response_data
- assert response_data["text_id"] == toc_data["text_id"]
- mock_create_toc.assert_called_once()
- call_args = mock_create_toc.call_args[1]
- assert call_args["token"] == VALID_TOKEN
- assert isinstance(call_args["table_of_content_request"], TableOfContent)
-
-# Error case tests
-
-@pytest.mark.asyncio
-async def test_create_text_unauthorized():
- """Test POST /texts without authentication"""
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post("/texts", json={"title": "Test"})
- assert response.status_code == 403
-
-@pytest.mark.asyncio
-async def test_create_table_of_content_unauthorized():
- """Test POST /texts/table-of-content without authentication"""
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post("/texts/table-of-content", json={"text_id": "123e4567-e89b-12d3-a456-426614174000"})
- assert response.status_code == 403
-
-@pytest.mark.asyncio
-async def test_get_contents_not_found(mocker):
- """Test GET /texts/{text_id}/contents with non-existent text"""
- mocker.patch(
- 'pecha_api.texts.texts_views.get_table_of_contents_by_text_id',
- side_effect=HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Text not found")
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/non_existent_id/contents")
-
- assert response.status_code == status.HTTP_404_NOT_FOUND
- assert response.json()["detail"] == "Text not found"
-
-@pytest.mark.asyncio
-async def test_get_commentaries_success(mocker):
- """Test GET /texts/{text_id}/commentaries with valid text_id"""
- mock_commentary_1 = TextDTO(
- id="commentary-1-uuid",
- pecha_text_id="commentary_pecha_1",
- title="Commentary on Heart Sutra",
- language="bo",
- group_id="123e4567-e89b-12d3-a456-426614174000",
- type="commentary",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="commentator_1",
- categories=["123e4567-e89b-12d3-a456-426614174000"],
- views=100,
- source_link="https://commentary-source-1.com",
- ranking=1,
- license="CC0"
- )
-
- mock_commentary_2 = TextDTO(
- id="commentary-2-uuid",
- pecha_text_id="commentary_pecha_2",
- title="Another Commentary on Heart Sutra",
- language="bo",
- group_id="123e4567-e89b-12d3-a456-426614174000",
- type="commentary",
- is_published=True,
- created_date="2025-01-02T00:00:00",
- updated_date="2025-01-02T00:00:00",
- published_date="2025-01-02T00:00:00",
- published_by="commentator_2",
- categories=["123e4567-e89b-12d3-a456-426614174000"],
- views=50,
- source_link="https://commentary-source-2.com",
- ranking=2,
- license="CC0"
- )
-
- mock_commentaries = [mock_commentary_1, mock_commentary_2]
-
- mock_get_commentaries = mocker.patch(
- 'pecha_api.texts.texts_views.get_commentaries_by_text_id',
- return_value=mock_commentaries
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries",
- params={"skip": 0, "limit": 10}
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data) == 2
- assert data[0]["title"] == "Commentary on Heart Sutra"
- assert data[0]["type"] == "commentary"
- assert data[1]["title"] == "Another Commentary on Heart Sutra"
-
- mock_get_commentaries.assert_called_once_with(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- skip=0,
- limit=10
- )
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_empty_list(mocker):
- """Test GET /texts/{text_id}/commentaries when no commentaries exist"""
- mock_get_commentaries = mocker.patch(
- 'pecha_api.texts.texts_views.get_commentaries_by_text_id',
- return_value=[]
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries",
- params={"skip": 0, "limit": 10}
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data) == 0
- assert data == []
-
- mock_get_commentaries.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_with_pagination(mocker):
- """Test GET /texts/{text_id}/commentaries with pagination parameters"""
- mock_commentary = TextDTO(
- id="commentary-uuid",
- pecha_text_id="commentary_pecha",
- title="Paginated Commentary",
- language="bo",
- group_id="123e4567-e89b-12d3-a456-426614174000",
- type="commentary",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="commentator",
- categories=["123e4567-e89b-12d3-a456-426614174000"],
- views=10,
- source_link="https://commentary-source.com",
- ranking=1,
- license="CC0"
- )
-
- mock_get_commentaries = mocker.patch(
- 'pecha_api.texts.texts_views.get_commentaries_by_text_id',
- return_value=[mock_commentary]
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries",
- params={"skip": 5, "limit": 20}
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data) == 1
-
- mock_get_commentaries.assert_called_once_with(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- skip=5,
- limit=20
- )
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_text_not_found(mocker):
- """Test GET /texts/{text_id}/commentaries with non-existent text"""
- mock_get_commentaries = mocker.patch(
- 'pecha_api.texts.texts_views.get_commentaries_by_text_id',
- side_effect=HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Text not found"
- )
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/non-existent-text-id/commentaries",
- params={"skip": 0, "limit": 10}
- )
-
- assert response.status_code == status.HTTP_404_NOT_FOUND
- assert response.json()["detail"] == "Text not found"
-
- mock_get_commentaries.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_default_pagination(mocker):
- """Test GET /texts/{text_id}/commentaries with default pagination values"""
- mock_get_commentaries = mocker.patch(
- 'pecha_api.texts.texts_views.get_commentaries_by_text_id',
- return_value=[]
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries"
- )
-
- assert response.status_code == status.HTTP_200_OK
-
- mock_get_commentaries.assert_called_once_with(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- skip=0,
- limit=10
- )
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_negative_skip(mocker):
- """Test GET /texts/{text_id}/commentaries with negative skip parameter"""
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries",
- params={"skip": -1, "limit": 10}
- )
-
- assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_limit_exceeds_max(mocker):
- """Test GET /texts/{text_id}/commentaries with limit exceeding maximum"""
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries",
- params={"skip": 0, "limit": 150}
- )
-
- assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_single_commentary(mocker):
- """Test GET /texts/{text_id}/commentaries with single commentary"""
- mock_commentary = TextDTO(
- id="single-commentary-uuid",
- pecha_text_id="single_commentary_pecha",
- title="Single Commentary",
- language="en",
- group_id="123e4567-e89b-12d3-a456-426614174000",
- type="commentary",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="single_commentator",
- categories=["123e4567-e89b-12d3-a456-426614174000"],
- views=5,
- source_link="https://single-commentary.com",
- ranking=1,
- license="CC BY"
- )
-
- mock_get_commentaries = mocker.patch(
- 'pecha_api.texts.texts_views.get_commentaries_by_text_id',
- return_value=[mock_commentary]
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries",
- params={"skip": 0, "limit": 10}
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data) == 1
- assert data[0]["id"] == "single-commentary-uuid"
- assert data[0]["title"] == "Single Commentary"
- assert data[0]["type"] == "commentary"
- assert data[0]["language"] == "en"
- assert data[0]["categories"] == ["123e4567-e89b-12d3-a456-426614174000"]
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_with_optional_fields_none(mocker):
- """Test GET /texts/{text_id}/commentaries with optional fields as None"""
- mock_commentary = TextDTO(
- id="commentary-optional-none-uuid",
- pecha_text_id=None,
- title="Commentary with None fields",
- language="bo",
- group_id="123e4567-e89b-12d3-a456-426614174000",
- type="commentary",
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="commentator",
- categories=["123e4567-e89b-12d3-a456-426614174000"],
- views=0,
- source_link=None,
- ranking=None,
- license=None
- )
-
- mock_get_commentaries = mocker.patch(
- 'pecha_api.texts.texts_views.get_commentaries_by_text_id',
- return_value=[mock_commentary]
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries",
- params={"skip": 0, "limit": 10}
- )
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data) == 1
- assert data[0]["pecha_text_id"] is None
- assert data[0]["source_link"] is None
- assert data[0]["ranking"] is None
- assert data[0]["license"] is None
- assert data[0]["views"] == 0
-
-
-@pytest.mark.asyncio
-async def test_get_commentaries_service_error(mocker):
- """Test GET /texts/{text_id}/commentaries when service raises unexpected error"""
- mock_get_commentaries = mocker.patch(
- 'pecha_api.texts.texts_views.get_commentaries_by_text_id',
- side_effect=HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal server error"
- )
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(
- "/texts/123e4567-e89b-12d3-a456-426614174000/commentaries",
- params={"skip": 0, "limit": 10}
- )
-
- assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
- assert response.json()["detail"] == "Internal server error"
-
-
-@pytest.mark.asyncio
-async def test_get_contents_with_details_success(mocker):
- """Test POST /texts/{text_id}/details with valid request"""
- # Create mock response with all required fields
- mock_detail_response = {
- "text_detail": MOCK_TEXT_DTO.model_dump(),
- "content": {
- "id": "123e4567-e89b-12d3-a456-426614174001",
- "text_id": "123e4567-e89b-12d3-a456-426614174000",
- "sections": []
- },
- "size": 10,
- "pagination_direction": "next",
- "current_segment_position": 1,
- "total_segments": 100
- }
-
- # Mock the service function
- mock_get_details = mocker.patch(
- 'pecha_api.texts.texts_views.get_text_details_by_text_id',
- new_callable=AsyncMock,
- return_value=mock_detail_response
- )
-
- # Test data
- test_text_id = "123e4567-e89b-12d3-a456-426614174000"
- details_request = {
- "version_id": "123e4567-e89b-12d3-a456-426614174003",
- "content_id": "123e4567-e89b-12d3-a456-426614174001",
- "segment_id": "123e4567-e89b-12d3-a456-426614174005",
- "size": 10,
- "direction": "next"
- }
-
- # Make the request
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- f"/texts/{test_text_id}/details",
- json=details_request
- )
-
- # Assertions
- assert response.status_code == 200
- response_data = response.json()
- assert "text_detail" in response_data
- assert "content" in response_data
- assert "size" in response_data
- assert "pagination_direction" in response_data
- assert "current_segment_position" in response_data
- assert "total_segments" in response_data
- assert response_data["text_detail"]["id"] == MOCK_TEXT_DTO.id
- assert response_data["size"] == 10
- assert response_data["total_segments"] == 100
- mock_get_details.assert_called_once()
- call_args = mock_get_details.call_args[1]
- assert call_args["text_id"] == test_text_id
- assert isinstance(call_args["text_details_request"], TextDetailsRequest)
-
-
-@pytest.mark.asyncio
-async def test_get_contents_with_details_invalid_text(mocker):
- """Test POST /texts/{text_id}/details with non-existent text"""
- mocker.patch(
- 'pecha_api.texts.texts_views.get_text_details_by_text_id',
- side_effect=HTTPException(status_code=404, detail="Text not found")
- )
-
- details_request = {
- "version_id": "123e4567-e89b-12d3-a456-426614174003",
- "content_id": "123e4567-e89b-12d3-a456-426614174001",
- "segment_id": "123e4567-e89b-12d3-a456-426614174005",
- "size": 10,
- "direction": "next"
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/texts/non_existent_id/details",
- json=details_request
- )
-
- assert response.status_code == 404
-
-
-@pytest.mark.asyncio
-async def test_get_versions_not_found(mocker):
- """Test GET /texts/{text_id}/versions with non-existent text"""
- mocker.patch(
- 'pecha_api.texts.texts_views.get_text_versions_by_group_id',
- side_effect=HTTPException(status_code=404, detail="Text not found")
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/non_existent_id/versions")
-
- assert response.status_code == 404
-
-
-@pytest.mark.asyncio
-async def test_get_text_invalid_parameters(mocker):
- """Test GET /texts with invalid pagination parameters"""
- mock_get_text = mocker.patch(
- 'pecha_api.texts.texts_views.get_text_by_text_id_or_collection',
- new_callable=AsyncMock,
- return_value=MOCK_TEXT_DTO
- )
-
- # Test with negative skip
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts?text_id=123&skip=-1&limit=10")
-
- # FastAPI will validate and return 422 for invalid parameters
- assert response.status_code in [200, 422] # Depends on FastAPI validation
-
-
-@pytest.mark.asyncio
-async def test_create_text_invalid_data():
- """Test POST /texts with invalid data"""
- invalid_data = {
- "title": "Test",
- # Missing required fields
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/texts",
- json=invalid_data,
- headers={"Authorization": f"Bearer {VALID_TOKEN}"}
- )
-
- # Should return 422 for validation error
- assert response.status_code == 422
-
-
-@pytest.mark.asyncio
-async def test_create_text_service_error(mocker):
- """Test POST /texts when service layer raises an error"""
- mocker.patch(
- 'pecha_api.texts.texts_views.create_new_text',
- side_effect=HTTPException(status_code=400, detail="Invalid group_id")
- )
-
- create_data = {
- "pecha_text_id": "test_pecha_id",
- "title": "Test Text",
- "language": "bo",
- "isPublished": True,
- "group_id": "invalid_group_id",
- "type": "version",
- "published_by": "test_user",
- "categories": [],
- "source_link": "https://test-source.com",
- "ranking": 1,
- "license": "CC0"
- }
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.post(
- "/texts",
- json=create_data,
- headers={"Authorization": f"Bearer {VALID_TOKEN}"}
- )
-
- assert response.status_code == 400
- assert response.json()["detail"] == "Invalid group_id"
-
-
-@pytest.mark.asyncio
-async def test_search_titles_success(mocker):
- """Test GET /texts/title-search returns title search results"""
- mock_results = [
- TitleSearchResult(id="text_1", title="A Title"),
- TitleSearchResult(id="text_2", title="Another Title")
- ]
-
- mock_search_titles = mocker.patch(
- "pecha_api.texts.texts_views.get_titles_and_ids_by_query",
- new_callable=AsyncMock,
- return_value=mock_results
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/title-search?title=Test&limit=20&offset=0")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data == [
- {"id": "text_1", "title": "A Title"},
- {"id": "text_2", "title": "Another Title"}
- ]
-
- mock_search_titles.assert_called_once_with(
- title="Test",
- author=None,
- limit=20,
- offset=0
- )
-
-
-# ============================================================================
-# GET /texts/{text_id}/languages Tests
-# ============================================================================
-
-@pytest.mark.asyncio
-async def test_get_languages_success(mocker):
- """Test GET /texts/{text_id}/languages returns available languages"""
- mock_response = LanguageResponse(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- title="Test Text",
- available_languages=[
- AvailableLanguage(language="bo", language_code="bo", version_count=3),
- AvailableLanguage(language="en", language_code="en", version_count=2),
- AvailableLanguage(language="zh", language_code="zh", version_count=1)
- ]
- )
-
- mock_get_languages = mocker.patch(
- 'pecha_api.texts.texts_views.get_text_languages',
- new_callable=AsyncMock,
- return_value=mock_response
- )
-
- test_text_id = "123e4567-e89b-12d3-a456-426614174000"
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(f"/texts/{test_text_id}/languages")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["text_id"] == test_text_id
- assert data["title"] == "Test Text"
- assert len(data["available_languages"]) == 3
- assert data["available_languages"][0]["language"] == "bo"
- assert data["available_languages"][0]["version_count"] == 3
-
- mock_get_languages.assert_called_once_with(text_id=test_text_id)
-
-
-@pytest.mark.asyncio
-async def test_get_languages_empty_list(mocker):
- """Test GET /texts/{text_id}/languages when no languages available"""
- mock_response = LanguageResponse(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- title="Test Text",
- available_languages=[]
- )
-
- mocker.patch(
- 'pecha_api.texts.texts_views.get_text_languages',
- new_callable=AsyncMock,
- return_value=mock_response
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["available_languages"] == []
-
-
-@pytest.mark.asyncio
-async def test_get_languages_text_not_found(mocker):
- """Test GET /texts/{text_id}/languages with non-existent text"""
- mocker.patch(
- 'pecha_api.texts.texts_views.get_text_languages',
- side_effect=HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Text not found"
- )
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/non-existent-id/languages")
-
- assert response.status_code == status.HTTP_404_NOT_FOUND
- assert response.json()["detail"] == "Text not found"
-
-
-@pytest.mark.asyncio
-async def test_get_languages_single_language(mocker):
- """Test GET /texts/{text_id}/languages with single language"""
- mock_response = LanguageResponse(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- title="Single Language Text",
- available_languages=[
- AvailableLanguage(language="bo", language_code="bo", version_count=5)
- ]
- )
-
- mocker.patch(
- 'pecha_api.texts.texts_views.get_text_languages',
- new_callable=AsyncMock,
- return_value=mock_response
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data["available_languages"]) == 1
- assert data["available_languages"][0]["language"] == "bo"
- assert data["available_languages"][0]["version_count"] == 5
-
-
-@pytest.mark.asyncio
-async def test_get_languages_service_error(mocker):
- """Test GET /texts/{text_id}/languages when service raises error"""
- mocker.patch(
- 'pecha_api.texts.texts_views.get_text_languages',
- side_effect=HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal server error"
- )
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages")
-
- assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
- assert response.json()["detail"] == "Internal server error"
-
-
-# ============================================================================
-# GET /texts/{text_id}/languages/{language}/versions Tests
-# ============================================================================
-
-@pytest.mark.asyncio
-async def test_get_language_versions_success(mocker):
- """Test GET /texts/{text_id}/languages/{language}/versions returns versions for a language"""
- mock_version_1 = VersionDetail(
- id="version-1-uuid",
- title="Version 1",
- parent_id=None,
- priority=None,
- language="bo",
- type="version",
- group_id="group-1-uuid",
- table_of_contents=["toc-1"],
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
- source_link="https://source-1.com",
- ranking=1,
- license="CC0",
- is_selected=True
- )
-
- mock_version_2 = VersionDetail(
- id="version-2-uuid",
- title="Version 2",
- parent_id=None,
- priority=None,
- language="bo",
- type="version",
- group_id="group-1-uuid",
- table_of_contents=["toc-2"],
- is_published=True,
- created_date="2025-01-02T00:00:00",
- updated_date="2025-01-02T00:00:00",
- published_date="2025-01-02T00:00:00",
- published_by="test_user_2",
- source_link="https://source-2.com",
- ranking=2,
- license="CC BY",
- is_selected=False
- )
-
- mock_response = VersionsResponse(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- language="bo",
- available_versions=[mock_version_1, mock_version_2]
- )
-
- mock_get_versions = mocker.patch(
- 'pecha_api.texts.texts_views.get_language_versions',
- new_callable=AsyncMock,
- return_value=mock_response
- )
-
- test_text_id = "123e4567-e89b-12d3-a456-426614174000"
- test_language = "bo"
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(f"/texts/{test_text_id}/languages/{test_language}/versions")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["text_id"] == test_text_id
- assert data["language"] == test_language
- assert len(data["available_versions"]) == 2
- assert data["available_versions"][0]["id"] == "version-1-uuid"
- assert data["available_versions"][0]["is_selected"] == True
- assert data["available_versions"][1]["id"] == "version-2-uuid"
- assert data["available_versions"][1]["is_selected"] == False
-
- mock_get_versions.assert_called_once_with(text_id=test_text_id, language=test_language)
-
-
-@pytest.mark.asyncio
-async def test_get_language_versions_empty_list(mocker):
- """Test GET /texts/{text_id}/languages/{language}/versions when no versions for language"""
- mock_response = VersionsResponse(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- language="zh",
- available_versions=[]
- )
-
- mocker.patch(
- 'pecha_api.texts.texts_views.get_language_versions',
- new_callable=AsyncMock,
- return_value=mock_response
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages/zh/versions")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["language"] == "zh"
- assert data["available_versions"] == []
-
-
-@pytest.mark.asyncio
-async def test_get_language_versions_text_not_found(mocker):
- """Test GET /texts/{text_id}/languages/{language}/versions with non-existent text"""
- mocker.patch(
- 'pecha_api.texts.texts_views.get_language_versions',
- side_effect=HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Text not found"
- )
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/non-existent-id/languages/bo/versions")
-
- assert response.status_code == status.HTTP_404_NOT_FOUND
- assert response.json()["detail"] == "Text not found"
-
-
-@pytest.mark.asyncio
-async def test_get_language_versions_single_version(mocker):
- """Test GET /texts/{text_id}/languages/{language}/versions with single version"""
- mock_version = VersionDetail(
- id="single-version-uuid",
- title="Single Version",
- parent_id=None,
- priority=1,
- language="en",
- type="translation",
- group_id="group-1-uuid",
- table_of_contents=["toc-1", "toc-2"],
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="translator",
- source_link="https://translation-source.com",
- ranking=1,
- license="CC BY-SA",
- is_selected=True
- )
-
- mock_response = VersionsResponse(
- text_id="123e4567-e89b-12d3-a456-426614174000",
- language="en",
- available_versions=[mock_version]
- )
-
- mocker.patch(
- 'pecha_api.texts.texts_views.get_language_versions',
- new_callable=AsyncMock,
- return_value=mock_response
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages/en/versions")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data["available_versions"]) == 1
- assert data["available_versions"][0]["id"] == "single-version-uuid"
- assert data["available_versions"][0]["language"] == "en"
- assert data["available_versions"][0]["type"] == "translation"
- assert data["available_versions"][0]["is_selected"] == True
-
-
-@pytest.mark.asyncio
-async def test_get_language_versions_service_error(mocker):
- """Test GET /texts/{text_id}/languages/{language}/versions when service raises error"""
- mocker.patch(
- 'pecha_api.texts.texts_views.get_language_versions',
- side_effect=HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal server error"
- )
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/123e4567-e89b-12d3-a456-426614174000/languages/bo/versions")
-
- assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
- assert response.json()["detail"] == "Internal server error"
-
-
-# ============================================================================
-# GET /texts/versions/{version_id}/info Tests
-# ============================================================================
-
-@pytest.mark.asyncio
-async def test_get_version_info_success(mocker):
- """Test GET /texts/versions/{version_id}/info returns version details"""
- mock_version = VersionDetail(
- id="123e4567-e89b-12d3-a456-426614174000",
- title="Test Version",
- parent_id=None,
- priority=None,
- language="bo",
- type="version",
- group_id="group-1-uuid",
- table_of_contents=["toc-1", "toc-2"],
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
- source_link="https://source.com",
- ranking=1,
- license="CC0",
- is_selected=True
- )
-
- mock_get_version_info = mocker.patch(
- 'pecha_api.texts.texts_views.get_version_info',
- new_callable=AsyncMock,
- return_value=mock_version
- )
-
- test_version_id = "123e4567-e89b-12d3-a456-426614174000"
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get(f"/texts/versions/{test_version_id}/info")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["id"] == test_version_id
- assert data["title"] == "Test Version"
- assert data["language"] == "bo"
- assert data["type"] == "version"
- assert data["group_id"] == "group-1-uuid"
- assert data["table_of_contents"] == ["toc-1", "toc-2"]
- assert data["is_published"] == True
- assert data["is_selected"] == True
- assert data["source_link"] == "https://source.com"
- assert data["ranking"] == 1
- assert data["license"] == "CC0"
-
- mock_get_version_info.assert_called_once_with(version_id=test_version_id)
-
-
-@pytest.mark.asyncio
-async def test_get_version_info_not_found(mocker):
- """Test GET /texts/versions/{version_id}/info with non-existent version"""
- mocker.patch(
- 'pecha_api.texts.texts_views.get_version_info',
- side_effect=HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Text not found"
- )
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/versions/non-existent-id/info")
-
- assert response.status_code == status.HTTP_404_NOT_FOUND
- assert response.json()["detail"] == "Text not found"
-
-
-@pytest.mark.asyncio
-async def test_get_version_info_with_optional_fields_none(mocker):
- """Test GET /texts/versions/{version_id}/info with optional fields as None"""
- mock_version = VersionDetail(
- id="123e4567-e89b-12d3-a456-426614174000",
- title="Version with None fields",
- parent_id=None,
- priority=None,
- language="bo",
- type="version",
- group_id="group-1-uuid",
- table_of_contents=[],
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="test_user",
- source_link=None,
- ranking=None,
- license=None,
- is_selected=True
- )
-
- mocker.patch(
- 'pecha_api.texts.texts_views.get_version_info',
- new_callable=AsyncMock,
- return_value=mock_version
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/versions/123e4567-e89b-12d3-a456-426614174000/info")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["parent_id"] is None
- assert data["priority"] is None
- assert data["source_link"] is None
- assert data["ranking"] is None
- assert data["license"] is None
- assert data["table_of_contents"] == []
-
-
-@pytest.mark.asyncio
-async def test_get_version_info_with_multiple_toc(mocker):
- """Test GET /texts/versions/{version_id}/info with multiple table of contents"""
- mock_version = VersionDetail(
- id="123e4567-e89b-12d3-a456-426614174000",
- title="Version with multiple TOC",
- parent_id=None,
- priority=1,
- language="en",
- type="translation",
- group_id="group-1-uuid",
- table_of_contents=["toc-1", "toc-2", "toc-3"],
- is_published=True,
- created_date="2025-01-01T00:00:00",
- updated_date="2025-01-01T00:00:00",
- published_date="2025-01-01T00:00:00",
- published_by="translator",
- source_link="https://translation.com",
- ranking=2,
- license="CC BY",
- is_selected=True
- )
-
- mocker.patch(
- 'pecha_api.texts.texts_views.get_version_info',
- new_callable=AsyncMock,
- return_value=mock_version
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/versions/123e4567-e89b-12d3-a456-426614174000/info")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert len(data["table_of_contents"]) == 3
- assert data["table_of_contents"] == ["toc-1", "toc-2", "toc-3"]
- assert data["type"] == "translation"
- assert data["priority"] == 1
-
-
-@pytest.mark.asyncio
-async def test_get_version_info_service_error(mocker):
- """Test GET /texts/versions/{version_id}/info when service raises error"""
- mocker.patch(
- 'pecha_api.texts.texts_views.get_version_info',
- side_effect=HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail="Internal server error"
- )
- )
-
- async with AsyncClient(transport=ASGITransport(app=api), base_url="http://test") as ac:
- response = await ac.get("/texts/versions/123e4567-e89b-12d3-a456-426614174000/info")
-
- assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
- assert response.json()["detail"] == "Internal server error"
From 23466cf1dcdc9a8aed331f2600ac4fe23a21d83e Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Mon, 1 Jun 2026 15:51:36 +0530
Subject: [PATCH 04/29] update
---
.../versions/c2653fe6f9de_added_audio_url.py | 6 ++++--
.../c8f7a1d2e3f4_add_start_date_to_plans.py | 16 ++++++++++++----
poetry.lock | 7 ++++---
pyproject.toml | 2 +-
4 files changed, 21 insertions(+), 10 deletions(-)
diff --git a/migrations/versions/c2653fe6f9de_added_audio_url.py b/migrations/versions/c2653fe6f9de_added_audio_url.py
index d9dbee6e5..0a9542d9f 100644
--- a/migrations/versions/c2653fe6f9de_added_audio_url.py
+++ b/migrations/versions/c2653fe6f9de_added_audio_url.py
@@ -69,8 +69,10 @@ def constraint_exists(table_name: str, constraint_name: str) -> bool:
op.drop_index('idx_plans_search', table_name='plans', postgresql_using='gin')
op.create_index('idx_plans_search', 'plans', [sa.text("to_tsvector('english', title || ' ' || COALESCE(description, ''))")], unique=False, postgresql_using='gin')
- # Add audio_url column
- op.add_column('sub_tasks', sa.Column('audio_url', sa.String(length=255), nullable=True))
+ # Add audio_url column only if it doesn't exist
+ sub_tasks_columns = {col['name'] for col in inspector.get_columns('sub_tasks')}
+ if 'audio_url' not in sub_tasks_columns:
+ op.add_column('sub_tasks', sa.Column('audio_url', sa.String(length=255), nullable=True))
def downgrade() -> None:
diff --git a/migrations/versions/c8f7a1d2e3f4_add_start_date_to_plans.py b/migrations/versions/c8f7a1d2e3f4_add_start_date_to_plans.py
index bf0f65a7c..fa76d67a6 100644
--- a/migrations/versions/c8f7a1d2e3f4_add_start_date_to_plans.py
+++ b/migrations/versions/c8f7a1d2e3f4_add_start_date_to_plans.py
@@ -20,10 +20,18 @@
def upgrade() -> None:
- op.add_column(
- "plans",
- sa.Column("start_date", sa.DateTime(timezone=True), nullable=True),
- )
+ from sqlalchemy import inspect
+
+ bind = op.get_bind()
+ inspector = inspect(bind)
+
+ # Add start_date column only if it doesn't exist
+ plans_columns = {col['name'] for col in inspector.get_columns('plans')}
+ if 'start_date' not in plans_columns:
+ op.add_column(
+ "plans",
+ sa.Column("start_date", sa.DateTime(timezone=True), nullable=True),
+ )
def downgrade() -> None:
diff --git a/poetry.lock b/poetry.lock
index 92fa33679..e21e70f95 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -189,6 +189,7 @@ files = [
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
+typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
@@ -1049,7 +1050,7 @@ description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.7"
groups = ["main"]
-markers = "python_version == \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
+markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
files = [
{file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"},
{file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"},
@@ -3138,5 +3139,5 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.1"
-python-versions = "^3.13"
-content-hash = "0a04e981d406043fa3b34d5f04a946938a4c6e701e12342615beec7b3dcd6aa9"
+python-versions = "^3.12"
+content-hash = "bdfb3bb4e89df682ff0597b88109ca92419f7daa999b50a3855e516972f749cf"
diff --git a/pyproject.toml b/pyproject.toml
index 3d0d552c7..e6fe7c776 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,7 +10,7 @@ authors = [ "tenzinsamten ",]
readme = "README.md"
[tool.poetry.dependencies]
-python = "^3.13"
+python = "^3.12"
alembic = "^1.14.0"
bcrypt = "^4.2.1"
fastapi = "^0.115.5"
From e27a085d61d548554c471c6518adbebeb03386da Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Mon, 1 Jun 2026 16:10:57 +0530
Subject: [PATCH 05/29] Fix missing TableOfContentResponse import and migration
duplicate column checks
---
pecha_api/texts/texts_service.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/pecha_api/texts/texts_service.py b/pecha_api/texts/texts_service.py
index 3f6e668e1..fe9601459 100644
--- a/pecha_api/texts/texts_service.py
+++ b/pecha_api/texts/texts_service.py
@@ -14,6 +14,7 @@
)
from .texts_response_models import (
TableOfContent,
+ TableOfContentResponse,
TableOfContentType,
TextDTO,
TextSegment,
From 36edbfef1b62d8a3bb121c1cabd72205d723a569 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Mon, 1 Jun 2026 16:47:24 +0530
Subject: [PATCH 06/29] Remove v2 prefix and tags from collections and texts
routers
---
pecha_api/collections/collections_openpecha_views.py | 4 ++--
pecha_api/texts/texts_openpecha_views.py | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/pecha_api/collections/collections_openpecha_views.py b/pecha_api/collections/collections_openpecha_views.py
index 0877e93ea..d94e0070a 100644
--- a/pecha_api/collections/collections_openpecha_views.py
+++ b/pecha_api/collections/collections_openpecha_views.py
@@ -9,8 +9,8 @@
from .collections_openpecha_service import get_collections_from_openpecha
collections_v2_router = APIRouter(
- prefix="/v2/collections",
- tags=["collections-v2"]
+ prefix="/collections",
+ tags=["collections"]
)
diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py
index 41882335e..47700c833 100644
--- a/pecha_api/texts/texts_openpecha_views.py
+++ b/pecha_api/texts/texts_openpecha_views.py
@@ -19,8 +19,8 @@
from pecha_api.texts.text_openpecha_response_models import TextDetailResponse
texts_v2_router = APIRouter(
- prefix="/v2/texts",
- tags=["texts-v2"],
+ prefix="/texts",
+ tags=["texts"],
)
@texts_v2_router.get(
From 8c47ad7494ba09aa2bbac4ce0e476a6e8e2e8a11 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Mon, 1 Jun 2026 17:05:05 +0530
Subject: [PATCH 07/29] Add v2 suffix to API router tags for collections,
segments, and texts
---
pecha_api/collections/collections_openpecha_views.py | 2 +-
pecha_api/texts/segments/segments_openpecha_views.py | 2 +-
pecha_api/texts/texts_openpecha_views.py | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/pecha_api/collections/collections_openpecha_views.py b/pecha_api/collections/collections_openpecha_views.py
index d94e0070a..102345569 100644
--- a/pecha_api/collections/collections_openpecha_views.py
+++ b/pecha_api/collections/collections_openpecha_views.py
@@ -10,7 +10,7 @@
collections_v2_router = APIRouter(
prefix="/collections",
- tags=["collections"]
+ tags=["collections-v2"]
)
diff --git a/pecha_api/texts/segments/segments_openpecha_views.py b/pecha_api/texts/segments/segments_openpecha_views.py
index 1ed0b58cf..b22dba9a9 100644
--- a/pecha_api/texts/segments/segments_openpecha_views.py
+++ b/pecha_api/texts/segments/segments_openpecha_views.py
@@ -18,7 +18,7 @@
segments_v2_router = APIRouter(
prefix="/segments",
- tags=["Segments"],
+ tags=["Segments-v2"],
)
@segments_v2_router.get("/{segment_id}", status_code=status.HTTP_200_OK)
diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py
index 47700c833..083c8d0e8 100644
--- a/pecha_api/texts/texts_openpecha_views.py
+++ b/pecha_api/texts/texts_openpecha_views.py
@@ -20,7 +20,7 @@
texts_v2_router = APIRouter(
prefix="/texts",
- tags=["texts"],
+ tags=["texts-v2"],
)
@texts_v2_router.get(
From 99d780d9de21348f26d6be82dad6783d08150bdb Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Tue, 2 Jun 2026 12:11:25 +0530
Subject: [PATCH 08/29] Change collection_id from path parameter to query
parameter in texts endpoint
---
pecha_api/texts/texts_openpecha_views.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py
index 083c8d0e8..9b67ea866 100644
--- a/pecha_api/texts/texts_openpecha_views.py
+++ b/pecha_api/texts/texts_openpecha_views.py
@@ -24,13 +24,13 @@
)
@texts_v2_router.get(
- "/collection/{collection_id}",
+ "",
status_code=status.HTTP_200_OK,
summary="Get texts by collection from OpenPecha",
description="Retrieve texts for a collection from OpenPecha API. "
)
async def get_texts_by_collection(
- collection_id: str,
+ collection_id: Annotated[str, Query(description="Collection ID to filter texts")],
language: Annotated[Optional[str], Query(description="Language code filter")] = None,
skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0,
limit: Annotated[int, Query(ge=1, le=100, description="Number of records to return")] = 10,
From 268064f17154de00473a5ee5004aad14c13dbf0e Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Tue, 2 Jun 2026 14:42:58 +0530
Subject: [PATCH 09/29] Update external API configuration defaults and change
text detail endpoint path
---
pecha_api/config.py | 6 +++---
pecha_api/texts/texts_openpecha_views.py | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/pecha_api/config.py b/pecha_api/config.py
index 7490f7d2a..468b0170f 100644
--- a/pecha_api/config.py
+++ b/pecha_api/config.py
@@ -74,9 +74,9 @@
APPLICATION = "webuddhist",
#pecha api configuration
- EXTERNAL_PECHA_API_URL="",
- EXTERNAL_DEV_PECHA_API_URL="",
- EXTERNAL_OPENPECHA_API_KEY="",
+ EXTERNAL_PECHA_API_URL="https://api-l25bgmwqoa-uc.a.run.app",
+ EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160",
+ EXTERNAL_OPENPECHA_API_KEY="EBWpScbRP6iFLt1SVn8dMfmi3_ab-7sx",
EXTERNAL_PECHA_APP_NAME="webuddhist",
EXTERNAL_TITLE_SEARCH_API_URL="",
diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py
index 9b67ea866..0e29b717c 100644
--- a/pecha_api/texts/texts_openpecha_views.py
+++ b/pecha_api/texts/texts_openpecha_views.py
@@ -43,7 +43,7 @@ async def get_texts_by_collection(
)
@texts_v2_router.get(
- "/detail",
+ "/{text_id}/details",
response_model=TextDetailResponse,
status_code=status.HTTP_200_OK,
summary="Get a text with pagination",
From 191c41dfe05bb6c7ee7cdaba7d85cebf43408053 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Tue, 2 Jun 2026 21:15:20 +0530
Subject: [PATCH 10/29] Add new response models for text details endpoint and
restructure text detail service to return nested content with sections and
segments
---
.../texts/text_openpecha_response_models.py | 59 ++++++++++++
pecha_api/texts/texts_openpecha_service.py | 91 +++++++++++++++++--
pecha_api/texts/texts_openpecha_views.py | 8 +-
3 files changed, 147 insertions(+), 11 deletions(-)
diff --git a/pecha_api/texts/text_openpecha_response_models.py b/pecha_api/texts/text_openpecha_response_models.py
index 3a2db032e..7f175c55f 100644
--- a/pecha_api/texts/text_openpecha_response_models.py
+++ b/pecha_api/texts/text_openpecha_response_models.py
@@ -83,3 +83,62 @@ class SegmentContentResponse(BaseModel):
offset: int
limit: int
+
+# ============================================================================
+# New Response Models for /{text_id}/details endpoint
+# ============================================================================
+
+class SegmentDTO(BaseModel):
+ segment_id: str
+ segment_number: int
+ content: str
+ translation: Optional[str] = None
+
+
+class SectionDTO(BaseModel):
+ id: str
+ title: str
+ section_number: int
+ parent_id: Optional[str] = None
+ segments: List[SegmentDTO] = []
+ sections: List["SectionDTO"] = []
+ created_date: Optional[str] = None
+ updated_date: Optional[str] = None
+ published_date: Optional[str] = None
+
+
+class ContentDTO(BaseModel):
+ id: str
+ text_id: str
+ sections: List[SectionDTO] = []
+
+
+class TextDetailDTO(BaseModel):
+ id: str
+ pecha_text_id: str
+ title: str
+ language: str
+ group_id: str
+ type: str
+ summary: str
+ is_published: bool
+ created_date: str
+ updated_date: str
+ published_date: str
+ published_by: str
+ categories: List[str] = []
+ views: int
+ likes: List[str] = []
+ source_link: Optional[str] = None
+ ranking: Optional[int] = None
+ license: Optional[str] = None
+
+
+class TextDetailWithContentResponse(BaseModel):
+ text_detail: TextDetailDTO
+ content: ContentDTO
+ size: int
+ pagination_direction: str
+ current_segment_position: int
+ total_segments: int
+
diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py
index 1fb0e43a0..64c3667e5 100644
--- a/pecha_api/texts/texts_openpecha_service.py
+++ b/pecha_api/texts/texts_openpecha_service.py
@@ -15,7 +15,17 @@
from openpecha_api.text.openpecha_text_service import fetch_texts_by_category, fetch_text_by_id
from openpecha_api.collection.openpecha_collection_service import fetch_category_by_id
from pecha_api.texts.texts_openpecha_api import fetch_critical_editions, fetch_text_detail, fetch_editions_segmentation, fetch_segmentation_segments, fetch_edition_content
-from pecha_api.texts.text_openpecha_response_models import SegmentationSegmentResponseModel, SegmentContentModel, SegmentContentResponse, TextDetailResponse
+from pecha_api.texts.text_openpecha_response_models import (
+ SegmentationSegmentResponseModel,
+ SegmentContentModel,
+ SegmentContentResponse,
+ TextDetailResponse,
+ TextDetailWithContentResponse,
+ TextDetailDTO,
+ ContentDTO,
+ SectionDTO,
+ SegmentDTO,
+)
logger = logging.getLogger(__name__)
@@ -149,7 +159,7 @@ async def get_text_by_id_from_openpecha(text_id: str) -> V2TextDTO:
return _map_external_text_to_dto(data, data.get("language"))
-async def get_text_detail_by_id(text_id: str, offset: int, limit: int) -> TextDetailResponse:
+async def get_text_detail_by_id(text_id: str, offset: int, limit: int) -> TextDetailWithContentResponse:
text_detail = await fetch_text_detail(text_id=text_id)
edition_details = await fetch_critical_editions(text_id=text_id)
if not edition_details:
@@ -157,13 +167,80 @@ async def get_text_detail_by_id(text_id: str, offset: int, limit: int) -> TextDe
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No critical editions found for text with id '{text_id}'",
)
- text_detail.edition_details = edition_details
segmentations = await fetch_editions_segmentation(edition_id=edition_details[0].id)
edition_content = await fetch_edition_content(edition_id=edition_details[0].id)
- segments = await fetch_segmentation_segments(segmentation_id=segmentations[0].id, limit=limit, offset=offset) # noqa: F841
- segment_contents = trim_segment_content(edition_content=edition_content.content, segments=segments)
- text_detail.segments = segment_contents
- return text_detail
+ segments = await fetch_segmentation_segments(segmentation_id=segmentations[0].id, limit=limit, offset=offset)
+
+ # Build segment DTOs
+ segment_dtos = []
+ for i, segment in enumerate(segments.items):
+ content = "".join(edition_content.content[line.start:line.end] for line in segment.lines)
+ segment_dtos.append(SegmentDTO(
+ segment_id=segment.id,
+ segment_number=offset + i + 1,
+ content=content,
+ translation=None
+ ))
+
+ # Extract title as string
+ title_str = _extract_title(text_detail.title, text_detail.language)
+ date_str = text_detail.date or ""
+
+ # Build TextDetailDTO
+ text_detail_dto = TextDetailDTO(
+ id=text_detail.id,
+ pecha_text_id=text_detail.bdrc or text_detail.id,
+ title=title_str,
+ language=text_detail.language,
+ group_id=text_detail.category_id,
+ type="version",
+ summary="",
+ is_published=True,
+ created_date=date_str,
+ updated_date=date_str,
+ published_date=date_str,
+ published_by="",
+ categories=[text_detail.category_id] if text_detail.category_id else [],
+ views=0,
+ likes=[],
+ source_link=text_detail.wiki or "unknown",
+ ranking=None,
+ license=text_detail.license or "unknown"
+ )
+
+ # Build a single section containing all segments
+ section = SectionDTO(
+ id=segmentations[0].id if segmentations else "",
+ title="1",
+ section_number=1,
+ parent_id=None,
+ segments=segment_dtos,
+ sections=[],
+ created_date=None,
+ updated_date=None,
+ published_date=None
+ )
+
+ # Build ContentDTO
+ content_dto = ContentDTO(
+ id=edition_details[0].id,
+ text_id=text_detail.id,
+ sections=[section]
+ )
+
+ # Calculate total segments (estimate based on has_more)
+ total_segments = offset + len(segment_dtos)
+ if segments.has_more:
+ total_segments += 1 # Indicate there are more
+
+ return TextDetailWithContentResponse(
+ text_detail=text_detail_dto,
+ content=content_dto,
+ size=len(segment_dtos),
+ pagination_direction="next",
+ current_segment_position=offset + 1 if segment_dtos else 0,
+ total_segments=total_segments
+ )
def trim_segment_content(edition_content: str, segments: SegmentationSegmentResponseModel) -> SegmentContentResponse:
diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py
index 0e29b717c..a2f689e25 100644
--- a/pecha_api/texts/texts_openpecha_views.py
+++ b/pecha_api/texts/texts_openpecha_views.py
@@ -16,7 +16,7 @@
get_text_versions_from_openpecha,
get_text_commentaries_from_openpecha,
)
-from pecha_api.texts.text_openpecha_response_models import TextDetailResponse
+from pecha_api.texts.text_openpecha_response_models import TextDetailWithContentResponse
texts_v2_router = APIRouter(
prefix="/texts",
@@ -42,14 +42,14 @@ async def get_texts_by_collection(
limit=limit,
)
-@texts_v2_router.get(
+@texts_v2_router.post(
"/{text_id}/details",
- response_model=TextDetailResponse,
+ response_model=TextDetailWithContentResponse,
status_code=status.HTTP_200_OK,
summary="Get a text with pagination",
description="Retrieve a text by its OpenPecha ID, including local edition details with pagination."
)
-async def read_text_by_id(text_id: str, offset: int = Query(default=0), limit: int = Query(default=30)) -> TextDetailResponse:
+async def read_text_by_id(text_id: str, offset: int = Query(default=0), limit: int = Query(default=20)) -> TextDetailWithContentResponse:
return await get_text_detail_by_id(text_id=text_id, offset=offset, limit=limit)
From ca6fa6b5e228bafa628e163257f450c2f04e6726 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Tue, 2 Jun 2026 23:24:19 +0530
Subject: [PATCH 11/29] update
---
pecha_api/config.py | 6 +++---
pecha_api/share/static/img/output.png | Bin 0 -> 22066 bytes
pecha_api/texts/texts_openpecha_views.py | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
create mode 100644 pecha_api/share/static/img/output.png
diff --git a/pecha_api/config.py b/pecha_api/config.py
index 468b0170f..7490f7d2a 100644
--- a/pecha_api/config.py
+++ b/pecha_api/config.py
@@ -74,9 +74,9 @@
APPLICATION = "webuddhist",
#pecha api configuration
- EXTERNAL_PECHA_API_URL="https://api-l25bgmwqoa-uc.a.run.app",
- EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160",
- EXTERNAL_OPENPECHA_API_KEY="EBWpScbRP6iFLt1SVn8dMfmi3_ab-7sx",
+ EXTERNAL_PECHA_API_URL="",
+ EXTERNAL_DEV_PECHA_API_URL="",
+ EXTERNAL_OPENPECHA_API_KEY="",
EXTERNAL_PECHA_APP_NAME="webuddhist",
EXTERNAL_TITLE_SEARCH_API_URL="",
diff --git a/pecha_api/share/static/img/output.png b/pecha_api/share/static/img/output.png
new file mode 100644
index 0000000000000000000000000000000000000000..f44babc4220456c1744ff355f8d4d07bb033f05c
GIT binary patch
literal 22066
zcmeFZX*iVO8#io=WJ?ldOGJ{j>}$v_LdcT6vSr^%l0ia}t!!nNU5FT!7*y6U_C3bF
zGY0dX)Bkzi<9I$j&-3Mdzx;i16lUhW?&~_w-}zhaD81WiG^Z|{A|fK9(NI^>Cn6$g
zAR;1ub&?$ZM_aZkfr#iXRzu~cpa4Lf2EGuu2ns}Ei!(*TPqVu`1<^YX_7)=|C`sKvI>0J^)}A1t$j^f)%^EU?aL?b
z)5Dj$*#GYPnwPPn%8ztbH&)iK!sNJf*F2)ei1xER`F5lv#hLXELt5#}3L_&&_|{)U
zYEzO@lajg>Q>R)qT^!q#STStHp(=Ud31p~E+pXN)l1VJry4F@GTQ6G#r`q`h$<_U~
z8(J!^~Iky-;;&h$QdcZk;LSmXvffnU1Oikl@
znm(2K?I)f~qEVG&y03$#rA@`h)jCcwM0?vDTot~er(PR6)gml=Ry3U%5sneyJ-5VT
zpIn7YmC-2kXG$I4A+;V%UHu8mbpn=r#mYzg%NF^@W!pT4r7g!w(}*V;e6Y$LgX|K@L{f?h?_5t1t_#@
zwt7q4@J*VCx;ffHeTGKtRjsF_5v`?{AY$spKN!o9Ea)rnh6E-@AQUx_-
z78Mnq&;Y+%Dm;!CUp=rLabsoRAG;}!OjoK;53-R!2)$?u;*3q_nrMnrvf)LaLsnu_
z#)a#C(4NB3Zf>}k6gHyw+ghT3CZ9LW(^j$jewOf3p%&`aKRCF?zfd@{q{$*sYFUda
zl@=e>tH*S`R;f0gvuvo&pGn;cdL6jR(|1JnZPA_?5ju0WyhQdW{oTg4+)G<~Eo)O1
z71~UavdA{>NLOTxR~PyEn^0?8rd~-O;wS5bxZI
zS-tavk<4x-H@>~?`}$OS{_53a^N6Np<}+XLQ9{y8PtBdF#`Q=GwP{24#=ef+nRp)A
z_Tvg+pXv!#Bq-HW$&7?GdGL)du%0S3Iu$iGdm!;T+La4F*SEI@8^-K(l2&GR0R!D%
zj=!9JEwIm@Y{jHc=A?_N*g6|9Xk#Bddq#kC=()0MA?_L`sZQ{M3-
zsrDmD+|RDc$8D;pJdoWXT-_T?*rL`+Kbli@WX$(_SLhOSm%nd6UohR#ILN@tBhFv!
zJK`Q1&li!s888_9uCzb%+qUWzZr_BZK=S78J8i8ePaHCfCBF~JgO$IHe+{qrY_nzX
zbgBF~)C*$rJfUaS0TmuclC&w~Cy!susIN4>o6Y#LZp<1nMKd*NH9y0gL4@Enx%TS?
zeUVf~yE+o-$m!EkmojeUU|t>>$tq7ch*1|#=8jiNoz;^f~PiDZ~^-UqqOWe2>!1@-4}&af#o9T$iQ=2{kdn+A7T@>+ji1`A_18qJ=Kn
zwO%PJXz4_`uTRqad6te6)`l{{iu%!sy7IB$vK>!K@aW~|Ng)wdI~tO)15@xJu3hN8
zcuBNfc8+&iYGs}|oh}|TrkRSrzwAXd)S#31kj83~vY&zPV`w+uK8p~io;!7Dy{DEv
z^V-!%P*i+mgW-+tsIyqG9s
zXU{A0e;rQZf7jvu`}6-ZB=FyL`tLgZpCHEnulLD6ggX0Zn$pnV$ui4HiL_{m(dRSh
zvuD0&_#P+k?@kz~ucWVD?Xd2izq+|hs4}8oEh|!5w?ZO$^{y|w>t?-G{M~MN?aB4v
zuAodLjRJWZrZ9(9G`RHA2l!&jW*N%;GfGQ+uL*FJ)ils$c9~77#
zus6g@d6gX8IHjhh0DnY6D0M_{I6TG1*RP$zh@0ZK^5|*!w=)hNZ*|!Mo&+q6f;lS-86;Dj-CI->(jgY)l6ggkcx;gdO{NbSllDF
zxo*NQ>rFEH8*L>DWc{6`yHl}W(r1zAFi#^!^eWngeTx}Yu@H2a!0m%4eL8HQ5Z1K$
zoa>I9l=gx7EwURqH)jjZ7Rw}9?T7-N@Zr2w>MEC7+KOCDRgLSA9!4kfG|;xph)3
zjf$Du=g390NG^K_D@g=xzRjzIeK*ZIz@3$amBno#;4tA|lNHLIDmEMPT;s3stZ*o(
zW_>AdOiz5`eth=rtbp@6Oow(qX5ei+2HCb<4%>;7^+aWfm1ySai|@rs{CSEIYF<~}
z*yjf@CoCIkjY8EP#jfT*PTgKgaS%<9V<@<<+8?#nTp`MBdK_+`&QN^s_D(y;x6Ym3
znrTxaRv9&1_F6u+cVa5$hJo+FyhF%-mkl)ynxhh?*2VavI(nNh`P*_^q^zh5yTg^X_iy(1?oCn}vaPfFlx7-^gl)Sb
z)6FNY8+fo?kKxtR*_sRa&QKsIKZ%x%4YxiZQui&*SqXJQH1e4ElIzUlnW+;psx7la
z`nqD>O6IJ#>t3`8_-15(<@fk>9WH$5p@BHw{`o@-GG-#XFPR^X;+HBa8;Sm0!!yrO
zSsDab$X6t24>Fiwe2SeVd^p3?m$)*~8uArRlj5b+b7dyl%|jDESW@MdH=Nh{yowt_
zl{~s42Mt+qszu3TkuxTr#_>Eb;II`gc1XfT@%J&9WK6X_Oyt!ITzq)aP=NR%(}>d2
zk?W=2AgSv7C*)7H2OR?jp2RqJy><9Ful*oJUMYrYGWu7m`zgD6BaTl@4XeL|=+n1I
z03Yv;Zk>w3$(|y-?k6@x#*p@c(@`I4-H*4#bZ?e?mO|&BsBZ9Fm)FkmtlWraSi1
z`k+u|Cc4jpEtMM%$OHLcnHRs@EIztk;+Tk)kd1yCkeO!@G_n@yawK-Vd5gk={q_S|
zIO5meiD$feNTXYxv3aWu&@*wMm0V}2MG`9u1q&+`YyM|s$;?w*>$w1pnnM
zffoSMYAyLm4Nzb-p8aXaOuuydJ}KTJqE
zt`aNMynXAud93q|q46DG$+DX%KT2iVc3fGjI*E{#wkIVr;v{6L`YfX`RJJKkgd3O<6=m$ViDfc#
zwkqPL&~?2_jZ^pD2n8|b)jIoqYc*NxpPZpBY}|`?3?yPcYRuFdDxrJj*jV<-ZuHSy
zvVh-Zq}vwpMCOr{T!O^Oq|=Y62?1z
zsOz%ye1}coHbQ;%K&HZ_>3mh^j7!|4UYpmMksui#&bt9##k-ON*jT%FH-hhc*I}CC
zmwAvy^S1f^8rmQBsyBLGx?uCO6O^dvsg2~t%C(8`Pz5G^FS(7nJIRXtGJhFW9w;yg
zf9310or>V{3BTS*f624#$M$peJlj7@k}2(1)$H=_OeQ{zSxgvL^IDCjU8;uL
z<6yRNo9II4kzk&LD4%yFRcdcY#5+N56JH*q`mj~r=vtkRx(64h?lHxEX;?_(k8(Nc
zM~bp^<=bqSMX*}G!UP5V@w^LdBoWmVY{PUzC1^eq71X(nD3r&Lcf|T*KSx+|?~Te`
zTUK&knR4q3kGR_eci47NL9nI6E9T&dw(3yWuFwSZreOnle-nFBFJ|)gG5ocox??Fz
z4&yT0JkPoEi+3BBRU6(TdNbuh7xgF8U+zAdKS1@fqogP39TE`tWV_s5Sx#;Un8Kgy_n(XdjCEY@2nRd8Uoc~|rb}yuXY=@=
zvdF!;$X_>Lj_A73o+Q(bs+l$!+GuL4a4)9D?#K2j2QM|L)w`Ss_Zr!MaV4i>D{30Q
zvKO8_dbHOYvR8~d=Oce}Fd1A_{*u)k=Zmq^&*%_#gSd&1B{#~m=wXTlc
z?k}rmwi*0&xXc_Gqt`3J0p^1zCei+^GRD_z+ZIpP9qY{6nyCfHNQl0>$Ao`fdBT{E
ziqc0=6)L1T{_pfaJFY~W{;_O`N`cmLeOQnUQ*8P}^N}Xn-M=ILgz;al+dyI%Cvwj|
z&gh9?O*nLQ*oUZ2Uh+=&$;uU_*T9qfC~Zo39O#DbYg|HVy;PKkb(-gU$yX}p?Y=Ke7$Vr=?bYXM5q
zC@B{IWItMC!f3$`V4#-p)I7)`6TbeX;E-yZCdZrCiI
zq5LC^d(mWd!0%Rk4UPmS@`JSSd+Dmlh4aq{H5%-c0PxV>e48n0NOl*1<8}cJ0
zw2wTIn&rDUJc3ys1y2faDc$_t&XE_?vqVhzXD)l`rLcnn`kSyaVI
zLum2K(?zEnmzuQjc_WA;zjeez5nP?GRf;?`n~FV!G7c;8_-q>qJjG>Gr6)_9D>D4F6QLnaq;rPj7Vq2>1p>OKl6f!hYi$weEwDaG<}
z&%wJudb9@Y6s)0U-vqC3%7r=}CZZV9_s)Fqyiattw$UYI!6IUpE5@eGc8+o_XY=!P
zQe7RAX1Q*D@lIS!XCS3_ehB;1#y#YNCO3cXks0*-yVv3lDK|23!2mmmTC8uvjpZKv
z@_1e62SWjmD6XzWhW;#X6s^U1d%dH3wPzfVFN1<)(&Iu%La>*Lk{YOV3y@wf({;Ny
z+HlbAS$D>q8zxcD7c-QDMqDug)1KMv*Vk_G3m-8;^L-#{5z%z@r(GhKsKKbmycFRS
z$yetGlf%s`4Y|%1hs-=w>}C^cW&rxY6>j)~#h%N7_@UcGLipLJu@GKp6U?mza#pZm
zG(!i4k8pA_J{OB5cVlwhda4h2rbj{)N)KpvlcWZYr)+2IU{MsYA0+hDt9uW=n(A?Wc`fp^k^0Kzk)yNU>ig8#<`*dkdcBV6Hpm>ysWj@1CT=X(93C$#
zz5dbJ^!jmN&M+5Dm#1O2nm3FE+hK?inB3OV}}&S3K6)
zLli<29lNg{hopGnnqlWxVvT{YB}R3yt$Sx@w096{&&I94r&Ev2;SSCSS>ABG$Wb4d
zl8CwBo6g%L7yATR=$McgJt2D9hYjj_*s)G+Ou(@hip!6y>STYW&gZEH`q0|t6ywuy
zOOzaiO6VFG`TK6wJfw*l!)HMUC@?vEK&?yE$CNwOu-1n~tEtzFN=?fe)iYc<#)+Qe
zAGADt|D;@ogyLUC<0jmTtMPyNPfA#*08ctVF&@xn&6IWUV{Zn4l}DFFbMxxcX@TZx
z?z%fPOH%9;mN#7sDkN{8Ua-J7x<^nE1zse3IWQu=%sO@NPuivi>lZ~-_8P~zYvlG6
z0Zp>shVT4rL)9|kZQpFX1X_|-x11d%*o~YXhVt+p;2i9`YZ-gO4^XI@$7@CMt^T^2T&w%0@^N6$yCl}A-IE4`Ja8JW
z>V7`=6g@D}4Y-x{$|xP}Z{gr(Uf}?ee#6Y|Z#G9p^$Q38
z$;2{t{B833Ayw?$^;HCiKRJ%#<3S-HEUAi7cQYPyJoLxWfM5OIJfGARQ9a344
zORw?eW_a1Ripp*3Wv`khs2W5WikA^mn>;VlIX3M*v!Yg&4cYPH)Vfc#_ZXU6@5Q35
z%97>mivK=x)IBzjbNN}IZ2e2sl4<-M(+np0V})R`6fyiy(2`9@|yRuxp+F8e(BINE@
zZwF{wzB!IjxHMAbvwd*cw-1gDW@J5VniQe2QYb;6zW3T1MH7n^m;ENPRDoxG+AKzB
zhyOOug*mGG;Szt;Ct5RE?TY8n0J%>hG#u9&CDAQHzmFbgpW`iP5ec?WL2l}I>0q$m
zk=C@Wl21I}j=MQ{M`egvTAMQzAlzE%uK}u|ca&s8b%6%Qdni8+#vSsE|g##jG8|sN{k7YC8&T*i&X-fC4mvNTOU)P@#JwhJF
zH7C{o^pd+jjHPY?!L^
z$DdmV6w0hrq3h)JOZG^u=EzCchy(m6I@s6f5()*??0D`tB|kjTysclc{~L`ZJ7Nj(
zFtLBW*u`T~#rm`@ZJq&Shb+lHsr~?=qj$%(e@N}@r71{UhuXf%c$u%IIMuH*WO(x-
zBvk4>=TaKgDMXpuVo0yOGpwGipBBj=^2`X~jZMsb^_X|$hzwdRUwW;uaHkvI>`SgE
zO)Mp=haj{m!w_*IIHhbp+DAhZ+@pTSlhN(GKGWzEK|OUX6)zDQ02DQeo$z~SzFPXr
z(mV{Q`om%_UDm`3^05gPTPM=k_;R0ycN
z70TZI)D>eQ_f^^5X3i=we0nSjt}TvP(`J6gHpA)0|5#ucQi*!ma5@*SYhU_peA-cx
zLO1Kwmsj5{pIk$>rA^0GhrSoflTd2DXNeM)CCVKeWJSS-PMR&1iCo+7i?rW9BZGgD
zzLI-fY$hU0yx4gt8n_lrgWa#`Jw+R);HBD_6v`2Vn3DM_9B;0HMtKYY|Y_@yUgxn87?UYQ7z31)es_g2xKnf?3kWP{*d3dD}~
zYEy9nuZeCSR^jUIs+4$Y>0Qukte8ZhbducVr7tl3-d({p-j;S}3E5P5jU~%-Ctssl
z;km@9WI4(65G%%$9OHU3fuvy29z;5e>q7V805peT|iZn59w$Mw5KNFCMMv8$)P
ze5r4X8-}viOB1q==aCDtHfABW<5fjL5`AoT$9(nC8x1vFK&q;OvEM*0Oq+nr
zZRP{qoT=iP&V=++k`>)1%Yr$4eV-q5-bA8*JC0rO%1#~)WoS<@r?Auk79npTV*<2-apMkws_40%JhWDZ87Xgu|%DNtWXc_5?nZHv#I|jr{U)5iJ
zu5({p>$Wa)-KPTiQ3fjEhu!V2DH^d}w}C+|1_#xZDK=5-S;bm!8r`S$*B|qRPah@o
z1Gz@h|C4~&ft1k)><5#?kD6l-6QND3ufp103pr)k%@
zrPXMkQ4!FmS*saA>u{1zE^9jz89Lsr@{>4;=jToW8k;YynU2OURY0p&ii3dXi`X%+aJy|En#`(%kD4M_8CIo&b|2^a{hh&
zb%4pa)h~j3u(>t;$89(OOmU`m<T5gwo8809Od2E{?1T*S$gjlz>%71;maYHx%Yaj
z{yK3j4mM#!Hl8o?S*|;FFg3ss1u7OJNP(Z^wo9!U>=|V6n&k&}9p@Tezp$M!zrYcc
z<^BhUc&L4QGW`(&8W}m99*ph`blA`uu4vya=0wiKZMzK6b1Rmhg(g1sjU8T^MdNtt*{rBtR~#~7qm!X3+ICznB4@gO)Z#PX
zti%roPDEaQ+c0&{P@pkGstY$?F7PJ(+e$QQ--CZwHs%SxG=a1ub>(wWkTxlGFW-2r
zeC$vWvHggyaZW)p=$j?-(VxmJ<|O{u;65u1k;-3Wx!)K0Kam)r&I|?gG!}@T7ni9U
z*7C|;7BNgn{M|6Pw_8BX08NV^qZ?=FT}T)K&N+8JtRENP@LSP-LxH?iF795jgvw_O
zN!I7SK`2poO_hEbBg-YZpG^l;mrukc9I+MxCY|7k*d%j``h{e@vU|AeiVQ}p7y?L#
zF%j+A)t{T*PI6c4vSG{`ZS7ZZ
z@A%GgP#R40F-xpQdC`FEm1d+S_+v;}oy^}`s84N>S-lj3Ce1#T7rpRg^Y64uvdF;i
zZNvpi2}@0{&P1{z-K!Z;kH*eRFgVJ_mEO%9u0_sIJ*krtAJZMz<{VSqiCPm%wD(iy
z8qgOQD`oF}XQaMNrS$%wN^|MMgZfNh8)ad@)d8g!$n@q8QEj1Wr~tYJ8QSwRIm7HZ
z2RVDT%3dO%|{8&}P}4pJh@Qa@lXvrwSXn
z&5mPcOuR~vl>CEgbMAwuGrUE5{DlOM8ks-(ZWO=)ni7|K({yO^^~z(f%B5Hl-Fxb*
z?~i)+dV<B9Vtf5&!>Gfh|B-3{()*oQCc3?}DP7ljsjQWni74EQr9z;|
zYAv|TzDur*yJE>TH-?-r^!A-lNdL4E05DN=t$yvzvFySAY?kzN3;gd
zrQ^PfEUi%f(ewGm@3u_Ro)pqk^;fG!`t7l`T%59w{ZQ^8`BJKPd)XDs{kL~UqMCx}
z#dt$bTH5V?-M|f`{@!ur;g}#kYkaam9z)vV{)2SwOIZ-7_roVql_$!`Ua;JgR7NCh
z%OCyL*LD3}$+EX|JC434wsrSp1e_!}h118{uFRHRtHfQ&OCyR!N1Rs6F|xbKk3%Bm
z_8MM;?(`nvSs*=?-kj03TbduzMlO!Pum!HzKP-Rf^UQLLfaRz^3jSB)c<#;CRDh=dW5<(-Y9Q9P|ukeMR@wvR;qJ7@uMG`g8mvGzg7?DRY>G
z)Q?gv^A2+8riCn@16@)xo!-(7GS4CCG#~-LS<{&W@{p(wWC_#SNjVQJ4k~8iLz`t9#huh&)
z=zh_B9|+L*Yc~iN_~jL=iN~72uliYuUm+OVUm-&YzmSJrD)S9QSH{QGYYqc{V83J@
zk^rS5e3Ym{9xFHb+Cb8Tff|B&i=$O{yfl+H=i{=Ri(tj?rfArNPfsxSLk%9c0_%ckv!6N
zqEj=wTq}A?_t^3q&=!Q1g+@k-on6&08&mOFzEFf~7n(lXoBf1ujYUW*$a%B7I_{%x
zP>91d+nR?00=~#Mpn(X)#67oBzrB1+5xDx~o`ae3Q?S@X=LRb8cf`Jb_}jQb)S@N)
zu@#Ef)FauVr2AdV{Krb=52OId0!T8u%8Q)QB_eaW%_n$5{6*ol5f%r*
z6Vre_^Wi1btL4;7T}h#0ZM)XTB*|DRIku^S@rEDRm%9g^HG2-b<2c7&7k=gjF6)%z
zUOi&|5uLpR%Pd@!&`Z;ZZ!2yq<{^a!hYSc3|9NjCKAhpqdXpe-qU8dTUKZ9RA`uwt(eo}l`Ve;gkKS}Shd
zm|bBvd4cn4j_FX~gp*qOH8CSsQ;>N!*g`%4c=T)5`IC7>Gt5y>i|VwUKUlaI$S>y<
z9lgPlSN8dJ%M~rtxdU-4`@jvdqV;4ULU%8rJ5;#Pi4{jKm_PG=c)D1|cHikh0;I=!
z>)L69z_k|lYt*`@0j}JsEvXhwH!$+as{FihQ@-w<7b=@8BVzhTNhwO<+bGkxWq__z
zQplU6FQr!-C9wOn!|YcDGyj%Jp6g?2sqh4(-IA!J>Q5J8VOt*tb}Sg&${2M;PK5e)
z8&>~YSHrb~H2{CWPuRd>6zLuSSrbfjyzbg+*dW^5d^ZCr>9FMUIZO(=fE-wvV+_&eO0{Dr!7)jzkEM7ZJ9wem=#((
zcYux7Hm$2eCt35GQA>CGwL>Qm6j);{Qi!g`jmjJLpGH!&u3o)qEo01|c?wJu(i@sb~#@p*#c(ge%&ERp$pT6=<>1j%)uC4JSGM+#
z0@qh=WZ;z#=b|HTSl+Z7P|#{V+sZ#u73orl;}1S
zPGL^!Q_0%1dy>LA0Y^|ZK)eG@9`td(oZVOx2O#9ApL*fA67Jtcb(}TsVNOYt+Qf$K
z8O5#1CKwRL_yIu=BCx0d8>i@ATl1|V&34R#RVe=kVz2G+?@Jf#2^ap-cbjhR3k2^Y
z%WeZmruUBBfy0WAbLFkTW#-Opz(gc6RQrLd(CEyUWAQn;hOk(}z~>t)%Qa0(9&|(F
z-dx{H%bcG$9F6txklaT68OLcVMDF^QZ?w()hhGE71ck`w?@y;_;vZYyF;_W0AKe!oNba`$qQ;jJ&`6kDK@`_Z?Yfbmwh3NYi?Y=<|eLlsO;g
z3`zmASIdcDiZzE1UfsS?1hx(${SrK132!iWjJTbx^iTR!4RFZOUh3NcLqrF|f{DMa
z`^SMI1`3}-=?0;mUhaGi)3=yMu(~BY%A9o+1kY#QQuh#+3>bNE{$+Hx<$lPkU)CPH
zI?VAzw~$Fhulfy;>P))%Y-GJ+Y_d4MpGA`k|I6^vi8h?p^Hp&?>@2Q?nm*#bLObMJeV$+mva7_Rb<)DmA2R1+I&HSnnqJ|TcdzAojJ*9t;q
zYE+M_LHqMkz@9K&4RUt~zIWyR9#YJTuHokQDNWzvwAUW_eL0^rnsXjtQN*CFq6+w3
zSt)^K_t+<_()Wr=(9?)WEr&$FI|~Pu-p%=H@_pU%2iW(?QsqaOZUN`9tnFLUEplo`
zMKtJ;>j-5DNm-k#Bx`;@Yqs2{N1mt+7U}jLuLmv!EoZ|NTmP98^@JQHt9vVW%u#gM
zZs;(1s>}pD#|svLOWgg-!qlt!5
zj(4elq#o8b1;&l@5u=kGMDatpD8yq5)0)XdvZYQ|U;o?IZi(c3C+;sFzM;6!FL&CH
z_p|>^Yb$JiAv1#vqdn8>K=jaTi3J$sjD)NEch(avZyA%JB@==qH$c%Wk8`ObMJAU!+X5WqJ(`
z<)C;eZ6DXcKW|oEaV$t>EJ*+8;)&?vq0xN>W+NI!d|qavPuIKKi&KnJL4=zC+LR5G
zz8{wkSH=?XdN48XU-B%Fol2j%+SR#3x@u0>SFvdze;&vv%RWo_smjfbbkHI)CTAae
z%iOh}Pau^;y^nXfqv|W^Qt61<0d=T-$5rsCAl)ZHY}d%vZ|dnLd1=KcH?;W4peQZb!>gSB8X5MDkXjSMsM&+;aL(lBS{6?OF4OeACe?6BWU6?AjfcJ=#i&j_M!A!UQX
z9Uo39X21SsnfnpFJT$J6!+lLe2$=sSu9kFr+~|Hq9^ae~JL@(e1*_$1%fO6da-Y&T
zy)jQs8BrQW2V~m4HGJoo5Rwh<5z;~KlKtR0#Qg1p7@He@nYyeJJ!%B
zCxp~SjLc@#G^j6|UM8gL{sW%0tT({*mN4qci{RCJ
zd+2eo4(W|Mp449ZzMEh(`O~W-;zrJ})$3Nd`I(XYYlOGJ&a%rMU!`uoUN~1jaC6v>
zFbOIUi)DjB&lydPZ2RlmdBIx<+q7TudwW&bnInn-B*B%
zrHYa+vc&w)i$^?+#^tDG*2w2A
zYoSO3VA|fRCm9kkCQ<3S+M4&&J}fJJ_^cmR_*F*Gwz^%f?*v&&KQNI$|XJQ#g4R++er!wl*-Ot(^Sad%sdDDQcO-Q6deht&bC8EmH1SGj;zE
zi*F{Jvld{BmK*o_RpuL00LuC)wOlTO3=Dj3vF@rsF9A0pH3=CG6bu9#iMqabXHR+-
zUrX3nYx!qxfDjEUajY%<#0KxQ%<+w}yY$I~poL#^p9PK7cVGr$mFjm2_`qca<~#b-
zP@s9LY1gXsN<(Ju5Co_9cP~I%CCdwUs@QzO%UD^(PF0T%GDFmFMm;4En9Nnj$jDzO
zp^4C^zW)^ZG_q}<5Zi&TkA#@nf4~2C9sauy{{J}#{`p@>E?r^(GF;_>#S08S*T|Px
zun%KOdvE^!Asrq0%MehXXm;wc=361GSWO}@3Ih?OJm2v0OHOOLGK5dJq)zLTQCc-#Y?9kMX#P~
zN(Ci>eQ+bf;Gv}*EzSHBI@iyX5L^Qn6m}m4KJ|=GOGF@oHAB1Qn79r0Y5kw)=u-(1
ztLv|<55R#!lLFku_m16{!Nt5!At)IyCG>Ts?8DYZ+?OJDNNQn!boOSJI7^h!A=ANq
z$N|oeTh1e{v>!)7xBwboE6?^aOcjXC>v}Owj+8*kA0}!6g`p07sg6Us(`~F|d*dF#
z;t00$L{wu;t0z8UfTAq*&(vu^Kdd1`N{5Wm6;v9p1@M<@c8pIo!IHs_{-ubl^fzSn
zr8%y&6>gx1(y9ok@M|YDRHs7IO+lccAPi~~&bKHuTIW2wQ29dT*F?<{Di|h;(`JH^6vEQlC146jlDU7n9fr?%I`Txa{PaA!c
zKpcahh!E1mg{@_69CF03nCXJsn$Sw-ZPD20AD6%<6VX3BfIbPyQ6`F+0j^g
z2!t@lZk-%~3p+kZcu!ZH0GNw&ZRcoaXGXM8Fe_zE6}wKNrM`03%rQaufNxUUG@YgE
zNZv1qy9OU$8D5s>v_mkN0-JFM3=BEejVu}J~c{jf8cGcE#hxm}P3FTvZ7Byr90N6#SJ
zoWa#!Z+n>t*g7=OsO?^*Z~sV}4c!L|#1){TTae{clW0QpKyL;Mi`TYW+)_aWOU@&<
zOadBJXbt09&fhn2Q(zJ)w2zL|&dr`-(NLjH1v5QZ<(q|NArLDGn-2ht8Ud}T*PfZ!
z{nEPud77wBe@?{IdA}c^vg+CeW|Ci&-qVCcYyx^mf|XybV8i@C;`l5P_%gv1V#~~9
z^^_PEh!>9dXN-*!&BFxDp%D0npT83XHw?MuP|(%5FXYsu!fm^(!t)&U*&AwgiI}{{&Op
z^a%cG`cjLfyLsXA#(e{it})mV1d9MH)Jem1l`Id+mZ?+Zkh6w`W#$1l`9>leX)Gz4
z5EUVfy;T%^0x`w5i!@^2!wZx4lcMfTnpi
zBYZSR$PlGG*`+CQLmbgb=9sRL{<7ZLRDB!OS$Bq-H2)Q6%??xUT(7pJR1{mt_iY5?
zlWN#P1JewpVIsSRudF7}`v^=nST=;UCnCk2=VoB%6I)_GSkDVtZcBq==ynRX=tz_z
zX@mg#br92mG^Iy%y!=c~pL!m)`hzRrLUR2~uUk$VGDHFlCTOie
zmEoXXay?iL45NYEfS6GYAtvPpF6HRpF0}^+$aS$F6kWXDNj())~s1(yeX&xIsMF~-2&@R5MBSf?nfV0g|
z6a%jZL{VOU!9zGibAA`WLOGy7L2#$cZAgaS2-l{-qK&{s09?PJK(ItA{%oKzO|$Y&
zC7nLFm~cF?=Xs*iLvo?c&qR0fC(4aGk$_`lBk?yc1Y&>zHmdPO2ZAj7aT2}He~uyD
z^D-oNLAefc>{8tKc4Lk98VPG2HwLGTaYf2P8WWTku!^U~s<|)ALM$M`vZ-g(ANgQA;eR$Rn{!;NtFKzl#idn+Lj_;hfjb>34_b?BH!2}mc#hjOk*g{66W8Tj+aa`CTgyhs6*Mfi49wh-&X;3{I09IBP
z{_&ff{V#Y;evb05+2S~KJX|#*j0@aS2Xw2UBAx
z=y+`O)O!yDxNcZ%dy1yxb8&@ct@V;`JMkjGbkt`aepg(35Dz}Fp{^qt8N3fWM6iI=
z2$hhITkV$sL;0CLmb`=6ID%zM0RN{^
zotK1Kt3^~+3Nu@wEf%?)u;04>BO~xWC8)=>^dq}Y(0tkF1f=%oX}aBZYXLWW2-(6z
zitzeg_+Hga*TYGZ)ZV~a97nQ&dTnB91&CP=feX^7+RqUcB_Si)miZrH_o9g+6#JuY
zq~R=l#@sm(5PoM0z
z%nGNymD-53gDuC&a>b5m~GeN^Y2@?T4lvW
zmqdkwz2vZ1>u+RNN}MN6&I167241yfuQ3BmSBe!B)wrmxFk6Pl_3P!Og?`Sjz-AHw
zEXx?iG7Iu!ckpRtdcJ>?3-0@HA?`KE-S;0AYw>ofJv_{^_H}!f2?>u~wQ()Su5@U%
z5osaI<3GZJ$BDqT@NIpcV7($T8DcN?q8JaI*=$dm-Z_s$l7kI82?znca0@U)TmC!D
znYydvzKy7+bfwDhnuzhcrpD+FukNE+rVTK97^X{pSOwoWaGbXaR$ul^F!gE8Z47pA
zEYNTbze-$c-d5!q%|MOhX~r??XAj&}c|Z$KD@c7GWWPmd{$xeT*7kwzJEhGmjyZr+
z=!+Dno(9?Qj_C$v5Lz`u(ZgL&{O$T9&I5@tRS=IyNL_Qm;osx7x<}}uK<;bno_1<8
z{RnGXAak@uS{t)hD7$*0elX}fs>5bJ0Uo7bk22SB#;ui$rrZ7*&H`5bisicIveKzO
zme!hH(2$@y
zXaX%;asIx=4e>$F*Y9eTp@gXk+X%C(wlJ}6KrqLJoZ$9lLg7v8LY?>?=YgPhwUkTt
zIj>1r`ciYJ57We!xdP(e;o_Bdd-~GHxGDNf9nLY+pm#y^stGIpr*uC%{F(o-WvMD(
z7h(0=10Ll8fGYR2=ItpaY!0a#BWYR#U=+iTY+cq^9HB#lnF+R;`C)~7Fs2%*5sQ$lw?41!VqVu;
zAfP#>@>cNt$+s4EfrIhrBRWvdpu3+ONbg(?k&6TA08S8Sd*@Iw8S9<&KU1*$v0Oj`
zd*^pi^eM|DA<$_HoitUcGWQSFLuQC()SGd3HZp%PdBdLdO!-#y!eOQENjY{XG?AXT!B>c
zvbpo@QpY(x5JsT&g%(}*dsZ!J6bphSA$U;i%z+T{K(EyK>CCtq$;%W~LW|t|=`V9-+g*-g~NU<$Uk(qQL7x
zzmuPJ5|wRfw*f>;faL7($dB`1YeZr0dOaNI%J&HYk;1;}7eUS}DeQ;#RE4G=;8+Y6_&nh(GPAF$+l;KUDK
z9n1$M>NQJ2d#>63Y&);1d;zdN@PSaGq5qX#d55o@b_0b$???*VyE!2^5J7i<3vUjnDF?3HZ@~Y0*I%&pT`L4q~*?E|*5N3yz%|rg%Wi0@TSnwxN#MfnE}b
z-#X~sIqMrJlelR+p|WM)l<(Rc=XOd1CRH*}MSgQu&nvqa3r6oD#@VA7-dix!S8AiGBDT<%q3*|GV)z>L9Deo_>jw0
z;!Fpd{Dy@e%SL)gi*PU@=M0+x5(T_~K-!C=@pP!p1ZV7C0gDbZBEoIU{!iK=XR$UN
zGkNRviROw*(2>CLr82nqu0VFx1&Gd1%#uW?4no~S<6<&zG+IM4G{PT(akEfOIw2S
z
ztc#cG%3uA|6^{uj-VPdiX50gV;LPK$h~d25#?TYpZx6{e0%mqT1m+imv=qC8
zb$HOg?w>9~;t1B)8nk{0)D)O|umJ<2I%ZV+YWP1Mc2^eQ<9pNjBLwJ9bni}%sINAH
zW|c~IRTr$nW|6LVQax6KA&Akf-fq+WN&MQe%Q%r4tM;gFs|D)k
zSr`x;bsM7qo3kE>`8NZMk%Ie>0v;BE0TIOXyV4wZG8kkD|4jxS$*tTbPQ6BFJBuxM
zX`}I{GiZWXy6rcH$;{svT{8gLwfWgypnEW#iBChDf{5V_pa=R0A|yl~DrMFZ@>0vq
zXZ2lh>{TArM;G9sMO-LL8F7efZ_5VrgP?u}22n`hPnMYy3`{~a-UNdbM6(#-{)8uD
zava;eo4^0ngTeKaf)^VM@NWo{K+Cj5T)?OvqOkPtOB>kn?gC{=!
zT=8H02~y6~d%09}g`}DZo$3<8
z1~}__InGu}2-OMp^pAeZIshSImg%luObF9`t-H((@-aB|3FQnDk4f5H7f!V&Dc9%0
z(-UmjljB^ox0*QemQ#}Z+&X<9HR5%EGl&@Mg9k8dpa>@uZF>ryvysC7!LyWbBB6~~
zfOHZ%V`74#?f7eXE6yA-gwgfHD--yfxCl~E{v
ziAKZeWr2AHT=a2OqZ^hCiaA)1tN%|mXZqCS8AWlZg9;WI77+`SRTxNx3bKQ?5Q<45
zh!q%FiY$ZDDgg(@fPsnv0tJRrib0LCG@vYQEztx^SrY*p2OPyhSR|ALLfmM?1fl2k
zzv!oYdNN5S?|tq)_jgWtt|@A4fxf0_DUg*tn>+fB`v!4(=z5^0>cRr41*mxb*B4#z
zTVV-}i)AS=g*nw~!YSvub_S1~efsl2&5($XObkK`{W`$9f6%Z+uvF0Gsc1!yX-=Y_
zd*7I`%})0)^Q>edd#`+DP;;=^ciJ3-cAsgA9n{=?Vp)>f{SjXo0qnT%`FcCWiIxsc
zvqBhH!eR^=I`T{nk&Y<8oY{;qnvc
zBlztd3G6s#ay=L_Du@{m6r3XZnmK-^)V-n|obq!~
z#zk|NPObIp$N#ZVB&t}54ML(x9p*o@vy{6s|9KIwN_c7OBS@2>DX}Gqr|gX-2yp~u
zSPcu|j*xMrn3(bZWb@nV=wj|Q?`hsnd$-M0xrzjjN5&q8D<(|1ayFt5LoJX3_y7gx
z+Z8oWgJ|E_=~e4(;dsp9GnY4L1BGEWSH|<5T@Le_cW(b6+T1DJBON8amUl$XMwCr(
zj+Ssge$lShMurYAEHe5+a35P)l&kwe#8%mX&fJVx4%m+Y?c1V`AvUmNrcBSErDR%p
zV>I6geFCLv)hro;1JM#8&v3vv<{@Tu%4EMq^)E?23Gxo?lZVp4Em;SZK6i>K9f>V<
zs3<>X`sVN9MbcjG4bN^jM}?((g_Ul=_P67uNaf&^q&7l2wv8_4PXs2z*-h`cKyv$-
zlsM%EU_D!LuLk@`J)ZuT%LBqb_mNjpbvrz7P5B7y7?902-kD_F|Jb7Tz@6_;e{X$Ci5edg^L+iz=TKY_=bZohj(mXls
zNK+7CV=$3s6jbGZTPyUcF-)W4a8?G*ig_TPA^0mk__eSdvK|a3&KNt>TXy03xi##n
zzH*%}hnU;6zkjDJP;hLP(d#z})-tDmA8nLf|q^tcD)h?v+
z30czgQ)+I;l<{pfT25mDH8j(WddZ)9zj|g`>Sw@`J?$v7661{&0vDDS~`T
q
Date: Wed, 3 Jun 2026 13:10:16 +0530
Subject: [PATCH 12/29] Remove v2 prefix from test endpoint URLs for
collections and texts
---
.../test_collections_openpecha_views.py | 20 +++----
.../test_segments_openpecha_service.py | 2 +
tests/texts/test_texts_openpecha_views.py | 60 +++++++++----------
3 files changed, 42 insertions(+), 40 deletions(-)
diff --git a/tests/collection/test_collections_openpecha_views.py b/tests/collection/test_collections_openpecha_views.py
index 379e3150c..8b118730e 100644
--- a/tests/collection/test_collections_openpecha_views.py
+++ b/tests/collection/test_collections_openpecha_views.py
@@ -38,7 +38,7 @@ def test_get_collections_success(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/collections?language=en")
+ response = client.get("/collections?language=en")
assert response.status_code == 200
data = response.json()
@@ -73,7 +73,7 @@ def test_get_collections_with_parent_id(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/collections?parent_id=parent-1&language=en")
+ response = client.get("/collections?parent_id=parent-1&language=en")
assert response.status_code == 200
data = response.json()
@@ -99,7 +99,7 @@ def test_get_collections_with_pagination(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/collections?skip=10&limit=5")
+ response = client.get("/collections?skip=10&limit=5")
assert response.status_code == 200
data = response.json()
@@ -116,7 +116,7 @@ def test_get_collections_empty(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/collections")
+ response = client.get("/collections")
assert response.status_code == 200
data = response.json()
@@ -142,7 +142,7 @@ def test_get_collections_tibetan_language(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/collections?language=bo")
+ response = client.get("/collections?language=bo")
assert response.status_code == 200
data = response.json()
@@ -152,15 +152,15 @@ def test_get_collections_tibetan_language(self, mock_service):
class TestCollectionsV2ValidationErrors:
def test_invalid_skip_negative(self):
- response = client.get("/v2/collections?skip=-1")
+ response = client.get("/collections?skip=-1")
assert response.status_code == 422
def test_invalid_limit_zero(self):
- response = client.get("/v2/collections?limit=0")
+ response = client.get("/collections?limit=0")
assert response.status_code == 422
def test_invalid_limit_too_large(self):
- response = client.get("/v2/collections?limit=101")
+ response = client.get("/collections?limit=101")
assert response.status_code == 422
@@ -174,7 +174,7 @@ def test_get_collections_upstream_error(self, mock_service):
detail="Failed to fetch collections from upstream service"
)
- response = client.get("/v2/collections")
+ response = client.get("/collections")
assert response.status_code == 502
assert "upstream" in response.json()["detail"].lower()
@@ -188,6 +188,6 @@ def test_get_collections_internal_error(self, mock_service):
detail="Internal server error"
)
- response = client.get("/v2/collections")
+ response = client.get("/collections")
assert response.status_code == 500
diff --git a/tests/segments/test_segments_openpecha_service.py b/tests/segments/test_segments_openpecha_service.py
index 4fd2d161a..821923eb2 100644
--- a/tests/segments/test_segments_openpecha_service.py
+++ b/tests/segments/test_segments_openpecha_service.py
@@ -5,7 +5,9 @@
from pecha_api.texts.segments.segments_openpecha_service import (
_classify_text,
_fetch_segment_content_safe,
+ get_openpecha_segment_details_by_id,
get_translations_by_segment_id_from_openpecha,
+ get_commentaries_by_segment_id_from_openpecha,
)
from pecha_api.texts.segments.segments_response_models import (
V2SegmentCommentariesResponse,
diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py
index 366679bb3..11549e893 100644
--- a/tests/texts/test_texts_openpecha_views.py
+++ b/tests/texts/test_texts_openpecha_views.py
@@ -91,7 +91,7 @@ def test_get_texts_by_collection_success(self, mock_service):
limit=10,
)
- response = client.get("/v2/texts/collection/cat-1")
+ response = client.get("/texts/collection/cat-1")
assert response.status_code == 200
data = response.json()
@@ -107,7 +107,7 @@ def test_get_text_by_id_success(self, mock_service):
license="CC0",
)
- response = client.get("/v2/texts/t1")
+ response = client.get("/texts/t1")
assert response.status_code == 200
data = response.json()
@@ -117,11 +117,11 @@ def test_get_text_by_id_success(self, mock_service):
class TestTextsV2ValidationErrors:
def test_invalid_skip_negative(self):
- response = client.get("/v2/texts/collection/cat-1?skip=-1")
+ response = client.get("/texts/collection/cat-1?skip=-1")
assert response.status_code == 422
def test_invalid_limit_zero(self):
- response = client.get("/v2/texts/collection/cat-1?limit=0")
+ response = client.get("/texts/collection/cat-1?limit=0")
assert response.status_code == 422
@@ -133,7 +133,7 @@ def test_get_texts_upstream_error(self, mock_service):
detail="Failed to fetch texts from upstream service",
)
- response = client.get("/v2/texts/collection/cat-1")
+ response = client.get("/texts/collection/cat-1")
assert response.status_code == 502
assert "upstream" in response.json()["detail"].lower()
@@ -145,7 +145,7 @@ def test_get_text_by_id_not_found(self, mock_service):
detail="Text with id 'missing' not found",
)
- response = client.get("/v2/texts/missing")
+ response = client.get("/texts/missing")
assert response.status_code == 404
@@ -165,7 +165,7 @@ def test_get_text_versions_success(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/texts/text-123/versions")
+ response = client.get("/texts/text-123/versions")
assert response.status_code == 200
data = response.json()
@@ -191,7 +191,7 @@ def test_get_text_versions_with_language_filter(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/texts/text-123/versions?language=en")
+ response = client.get("/texts/text-123/versions?language=en")
assert response.status_code == 200
data = response.json()
@@ -212,7 +212,7 @@ def test_get_text_versions_with_pagination(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/texts/text-123/versions?skip=1&limit=5")
+ response = client.get("/texts/text-123/versions?skip=1&limit=5")
assert response.status_code == 200
data = response.json()
@@ -232,7 +232,7 @@ def test_get_text_versions_empty_versions(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/texts/text-123/versions")
+ response = client.get("/texts/text-123/versions")
assert response.status_code == 200
data = response.json()
@@ -247,7 +247,7 @@ def test_get_text_versions_with_all_parameters(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/texts/text-123/versions?language=bo&skip=2&limit=20")
+ response = client.get("/texts/text-123/versions?language=bo&skip=2&limit=20")
assert response.status_code == 200
mock_service.assert_called_once_with(
@@ -268,7 +268,7 @@ def test_get_text_versions_not_found(self, mock_service):
detail="Text with id 'nonexistent' not found"
)
- response = client.get("/v2/texts/nonexistent/versions")
+ response = client.get("/texts/nonexistent/versions")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
@@ -280,7 +280,7 @@ def test_get_text_versions_upstream_error(self, mock_service):
detail="Failed to fetch text from external API"
)
- response = client.get("/v2/texts/text-123/versions")
+ response = client.get("/texts/text-123/versions")
assert response.status_code == 502
assert "external" in response.json()["detail"].lower() or "failed" in response.json()["detail"].lower()
@@ -292,7 +292,7 @@ def test_get_text_versions_internal_error(self, mock_service):
detail="Internal server error"
)
- response = client.get("/v2/texts/text-123/versions")
+ response = client.get("/texts/text-123/versions")
assert response.status_code == 500
@@ -308,7 +308,7 @@ def test_response_contains_all_text_fields(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/texts/text-123/versions")
+ response = client.get("/texts/text-123/versions")
assert response.status_code == 200
text_data = response.json()["text"]
@@ -328,7 +328,7 @@ def test_response_contains_all_version_fields(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/texts/text-123/versions")
+ response = client.get("/texts/text-123/versions")
assert response.status_code == 200
version_data = response.json()["versions"][0]
@@ -365,7 +365,7 @@ def test_response_with_optional_fields_none(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/v2/texts/text-123/versions")
+ response = client.get("/texts/text-123/versions")
assert response.status_code == 200
version_data = response.json()["versions"][0]
@@ -405,7 +405,7 @@ def test_get_text_commentaries_success(self, mock_service):
)
mock_service.return_value = [mock_commentary]
- response = client.get("/v2/texts/text-123/commentaries")
+ response = client.get("/texts/text-123/commentaries")
assert response.status_code == 200
data = response.json()
@@ -443,7 +443,7 @@ def test_get_text_commentaries_multiple(self, mock_service):
]
mock_service.return_value = mock_commentaries
- response = client.get("/v2/texts/text-123/commentaries")
+ response = client.get("/texts/text-123/commentaries")
assert response.status_code == 200
data = response.json()
@@ -456,7 +456,7 @@ def test_get_text_commentaries_multiple(self, mock_service):
def test_get_text_commentaries_empty(self, mock_service):
mock_service.return_value = []
- response = client.get("/v2/texts/text-123/commentaries")
+ response = client.get("/texts/text-123/commentaries")
assert response.status_code == 200
data = response.json()
@@ -466,7 +466,7 @@ def test_get_text_commentaries_empty(self, mock_service):
def test_get_text_commentaries_with_pagination(self, mock_service):
mock_service.return_value = []
- response = client.get("/v2/texts/text-123/commentaries?skip=5&limit=20")
+ response = client.get("/texts/text-123/commentaries?skip=5&limit=20")
assert response.status_code == 200
mock_service.assert_called_once_with(
@@ -479,7 +479,7 @@ def test_get_text_commentaries_with_pagination(self, mock_service):
def test_get_text_commentaries_with_skip_only(self, mock_service):
mock_service.return_value = []
- response = client.get("/v2/texts/text-123/commentaries?skip=10")
+ response = client.get("/texts/text-123/commentaries?skip=10")
assert response.status_code == 200
mock_service.assert_called_once_with(
@@ -492,7 +492,7 @@ def test_get_text_commentaries_with_skip_only(self, mock_service):
def test_get_text_commentaries_with_limit_only(self, mock_service):
mock_service.return_value = []
- response = client.get("/v2/texts/text-123/commentaries?limit=50")
+ response = client.get("/texts/text-123/commentaries?limit=50")
assert response.status_code == 200
mock_service.assert_called_once_with(
@@ -502,11 +502,11 @@ def test_get_text_commentaries_with_limit_only(self, mock_service):
)
def test_get_text_commentaries_invalid_skip(self):
- response = client.get("/v2/texts/text-123/commentaries?skip=-1")
+ response = client.get("/texts/text-123/commentaries?skip=-1")
assert response.status_code == 422
def test_get_text_commentaries_invalid_limit(self):
- response = client.get("/v2/texts/text-123/commentaries?limit=101")
+ response = client.get("/texts/text-123/commentaries?limit=101")
assert response.status_code == 422
@@ -520,7 +520,7 @@ def test_get_text_commentaries_not_found(self, mock_service):
detail="Text with id 'nonexistent' not found"
)
- response = client.get("/v2/texts/nonexistent/commentaries")
+ response = client.get("/texts/nonexistent/commentaries")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
@@ -532,7 +532,7 @@ def test_get_text_commentaries_upstream_error(self, mock_service):
detail="Failed to fetch commentaries from external API"
)
- response = client.get("/v2/texts/text-123/commentaries")
+ response = client.get("/texts/text-123/commentaries")
assert response.status_code == 502
@@ -543,7 +543,7 @@ def test_get_text_commentaries_internal_error(self, mock_service):
detail="Internal server error"
)
- response = client.get("/v2/texts/text-123/commentaries")
+ response = client.get("/texts/text-123/commentaries")
assert response.status_code == 500
@@ -573,7 +573,7 @@ def test_commentary_contains_all_required_fields(self, mock_service):
)
mock_service.return_value = [mock_commentary]
- response = client.get("/v2/texts/text-123/commentaries")
+ response = client.get("/texts/text-123/commentaries")
assert response.status_code == 200
data = response.json()[0]
@@ -616,7 +616,7 @@ def test_commentary_with_optional_fields_none(self, mock_service):
)
mock_service.return_value = [mock_commentary]
- response = client.get("/v2/texts/text-123/commentaries")
+ response = client.get("/texts/text-123/commentaries")
assert response.status_code == 200
data = response.json()[0]
From 43e7aaccf9db1261ec7a2cb6412e3498948e80d6 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Wed, 3 Jun 2026 13:17:17 +0530
Subject: [PATCH 13/29] test update
---
tests/texts/test_texts_openpecha_services.py | 14 ++++++++------
tests/texts/test_texts_openpecha_views.py | 8 ++++----
2 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/tests/texts/test_texts_openpecha_services.py b/tests/texts/test_texts_openpecha_services.py
index a28296dbc..64e3f7c94 100644
--- a/tests/texts/test_texts_openpecha_services.py
+++ b/tests/texts/test_texts_openpecha_services.py
@@ -6,6 +6,7 @@
from pecha_api.texts.texts_openpecha_service import get_text_detail_by_id, trim_segment_content
from pecha_api.texts.text_openpecha_response_models import (
TextDetailResponse,
+ TextDetailWithContentResponse,
CriticalEditionModel,
ContributionModel,
SegmentationResponseModel,
@@ -87,12 +88,13 @@ async def test_get_text_detail_by_id_success(mocker):
result = await get_text_detail_by_id(text_id=TEXT_ID, offset=0, limit=30)
- assert result.id == TEXT_ID
- assert result.edition_details == MOCK_EDITIONS
- assert len(result.segments.contents) == 2
- assert result.segments.has_more is False
- assert result.segments.offset == 0
- assert result.segments.limit == 30
+ assert isinstance(result, TextDetailWithContentResponse)
+ assert result.text_detail.id == TEXT_ID
+ assert result.text_detail.title == "Test Text"
+ assert result.size == 2
+ assert result.current_segment_position == 1
+ assert len(result.content.sections) == 1
+ assert len(result.content.sections[0].segments) == 2
@pytest.mark.asyncio
diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py
index 11549e893..3a2a3936a 100644
--- a/tests/texts/test_texts_openpecha_views.py
+++ b/tests/texts/test_texts_openpecha_views.py
@@ -91,7 +91,7 @@ def test_get_texts_by_collection_success(self, mock_service):
limit=10,
)
- response = client.get("/texts/collection/cat-1")
+ response = client.get("/texts?collection_id=cat-1")
assert response.status_code == 200
data = response.json()
@@ -117,11 +117,11 @@ def test_get_text_by_id_success(self, mock_service):
class TestTextsV2ValidationErrors:
def test_invalid_skip_negative(self):
- response = client.get("/texts/collection/cat-1?skip=-1")
+ response = client.get("/texts?collection_id=cat-1&skip=-1")
assert response.status_code == 422
def test_invalid_limit_zero(self):
- response = client.get("/texts/collection/cat-1?limit=0")
+ response = client.get("/texts?collection_id=cat-1&limit=0")
assert response.status_code == 422
@@ -133,7 +133,7 @@ def test_get_texts_upstream_error(self, mock_service):
detail="Failed to fetch texts from upstream service",
)
- response = client.get("/texts/collection/cat-1")
+ response = client.get("/texts?collection_id=cat-1")
assert response.status_code == 502
assert "upstream" in response.json()["detail"].lower()
From 571495f1c1ed90e7c7b02d4a23e4e8e36d08f8d7 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Wed, 3 Jun 2026 13:29:35 +0530
Subject: [PATCH 14/29] feat(segments): add pagination safety limits and
iteration cap to prevent unbounded loops
- Added constants for maximum pagination skip (10000), limit (100), and fetch iterations (100)
- Sanitized user-controlled skip and limit inputs in `_fetch_matching_related_segments_by_text_id` to prevent unbounded loops
- Added iteration counter to break out of fetch loop after MAX_FETCH_ITERATIONS to prevent infinite loops
- Applied safe_skip and safe_limit throughout pagination logic for consistent bounds
---
.../segments/segments_openpecha_service.py | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/pecha_api/texts/segments/segments_openpecha_service.py b/pecha_api/texts/segments/segments_openpecha_service.py
index 06b398c22..ae3ec6e4b 100644
--- a/pecha_api/texts/segments/segments_openpecha_service.py
+++ b/pecha_api/texts/segments/segments_openpecha_service.py
@@ -29,6 +29,9 @@
TRANSLATION = "translation"
COMMENTARY = "commentary"
RELATED_SEGMENTS_MAX_PAGE_SIZE = 100
+MAX_PAGINATION_SKIP = 10000
+MAX_PAGINATION_LIMIT = 100
+MAX_FETCH_ITERATIONS = 100
def _classify_text(text_payload: Dict[str, Any]) -> Optional[str]:
@@ -151,12 +154,18 @@ async def _fetch_matching_related_segments_by_text_id(
skip: int,
limit: int,
) -> Tuple[List[Dict[str, Any]], bool]:
+ # Sanitize user-controlled inputs to prevent unbounded loops
+ safe_skip = max(0, min(skip, MAX_PAGINATION_SKIP))
+ safe_limit = max(1, min(limit, MAX_PAGINATION_LIMIT))
+
matching_items: List[Dict[str, Any]] = []
upstream_offset = 0
upstream_has_more = True
- target_count = skip + limit + 1
+ target_count = safe_skip + safe_limit + 1
+ iterations = 0
- while upstream_has_more and len(matching_items) < target_count:
+ while upstream_has_more and len(matching_items) < target_count and iterations < MAX_FETCH_ITERATIONS:
+ iterations += 1
related_page = await fetch_related_segments(
segment_id=segment_id,
limit=RELATED_SEGMENTS_MAX_PAGE_SIZE,
@@ -171,8 +180,8 @@ async def _fetch_matching_related_segments_by_text_id(
break
upstream_offset += len(items)
- paginated_items = matching_items[skip : skip + limit]
- has_more = len(matching_items) > skip + limit
+ paginated_items = matching_items[safe_skip : safe_skip + safe_limit]
+ has_more = len(matching_items) > safe_skip + safe_limit
return paginated_items, has_more
From 2bf77b1d8714bb8abb1563cdb1b91510a3a0f038 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Wed, 3 Jun 2026 15:26:19 +0530
Subject: [PATCH 15/29] segment_info_migration_implementation
---
.../segments/segments_openpecha_service.py | 53 +++++++++++++++++++
.../segments/segments_openpecha_views.py | 16 ++++++
.../segments/segments_response_models.py | 21 ++++++++
3 files changed, 90 insertions(+)
diff --git a/pecha_api/texts/segments/segments_openpecha_service.py b/pecha_api/texts/segments/segments_openpecha_service.py
index ae3ec6e4b..1d5e9684b 100644
--- a/pecha_api/texts/segments/segments_openpecha_service.py
+++ b/pecha_api/texts/segments/segments_openpecha_service.py
@@ -14,8 +14,12 @@
from .segments_response_models import (
ParentSegment,
+ SegmentInfo,
+ SegmentRelatedText,
+ SegmentResources,
V2RelatedSegmentItem,
V2SegmentCommentariesResponse,
+ V2SegmentInfoResponse,
V2SegmentResponse,
V2SegmentRootTextResponse,
V2SegmentTextDetail,
@@ -307,3 +311,52 @@ async def get_commentaries_by_segment_id_from_openpecha(
limit=limit,
has_more=has_more,
)
+
+
+async def get_segment_info_by_id_from_openpecha(
+ segment_id: str,
+) -> V2SegmentInfoResponse:
+ try:
+ segment_details = await fetch_segment_details(segment_id)
+ except Exception:
+ logger.exception("Failed to fetch segment details for %s", segment_id)
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Segment with id '{segment_id}' not found",
+ )
+
+ text_id = segment_details.get("text_id")
+ if not text_id:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Text ID not found for segment '{segment_id}'",
+ )
+
+ text_payload = await _fetch_text_safe(text_id)
+ if not text_payload:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Text with id '{text_id}' not found",
+ )
+
+ translations_count = len(text_payload.get("translations", []))
+ commentaries_count = len(text_payload.get("commentaries", []))
+
+ root_text_count = 0
+ if text_payload.get("commentary_of") or text_payload.get("translation_of"):
+ root_text_count = 1
+
+ segment_info = SegmentInfo(
+ segment_id=segment_id,
+ text_id=text_id,
+ translations=translations_count,
+ related_text=SegmentRelatedText(
+ commentaries=commentaries_count,
+ root_text=root_text_count,
+ ),
+ resources=SegmentResources(
+ sheets=0,
+ ),
+ )
+
+ return V2SegmentInfoResponse(segment_info=segment_info)
diff --git a/pecha_api/texts/segments/segments_openpecha_views.py b/pecha_api/texts/segments/segments_openpecha_views.py
index b22dba9a9..1ee40cea4 100644
--- a/pecha_api/texts/segments/segments_openpecha_views.py
+++ b/pecha_api/texts/segments/segments_openpecha_views.py
@@ -7,10 +7,12 @@
get_commentaries_by_segment_id_from_openpecha,
get_openpecha_segment_details_by_id,
get_root_text_by_segment_id_from_openpecha,
+ get_segment_info_by_id_from_openpecha,
get_translations_by_segment_id_from_openpecha,
)
from .segments_response_models import (
V2SegmentCommentariesResponse,
+ V2SegmentInfoResponse,
V2SegmentResponse,
V2SegmentTranslationsResponse,
V2SegmentRootTextResponse,
@@ -79,3 +81,17 @@ async def get_commentaries_for_segment_v2(
skip=skip,
limit=limit,
)
+
+
+@segments_v2_router.get(
+ "/{segment_id}/info",
+ status_code=status.HTTP_200_OK,
+ summary="Get Segment Info",
+ description="Retrieve segment information including translation count, related text counts, and resources.",
+)
+async def get_segment_info_v2(
+ segment_id: str,
+) -> V2SegmentInfoResponse:
+ return await get_segment_info_by_id_from_openpecha(
+ segment_id=segment_id,
+ )
diff --git a/pecha_api/texts/segments/segments_response_models.py b/pecha_api/texts/segments/segments_response_models.py
index 41d81a4cf..4a1d0008c 100644
--- a/pecha_api/texts/segments/segments_response_models.py
+++ b/pecha_api/texts/segments/segments_response_models.py
@@ -125,3 +125,24 @@ class V2SegmentResponse(BaseModel):
segment_id: str
content: str
text: Optional[V2SegmentTextDetail] = None
+
+
+class SegmentRelatedText(BaseModel):
+ commentaries: int
+ root_text: int
+
+
+class SegmentResources(BaseModel):
+ sheets: int
+
+
+class SegmentInfo(BaseModel):
+ segment_id: str
+ text_id: str
+ translations: int
+ related_text: SegmentRelatedText
+ resources: SegmentResources
+
+
+class V2SegmentInfoResponse(BaseModel):
+ segment_info: SegmentInfo
From 45b8094f7d19bb3d6c189ffb3a03901c79996dc0 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Wed, 3 Jun 2026 15:30:46 +0530
Subject: [PATCH 16/29] test update
---
.../test_segments_openpecha_service.py | 235 ++++++++++++++++++
.../segments/test_segments_openpecha_views.py | 133 ++++++++++
2 files changed, 368 insertions(+)
diff --git a/tests/segments/test_segments_openpecha_service.py b/tests/segments/test_segments_openpecha_service.py
index 821923eb2..bef0dfad4 100644
--- a/tests/segments/test_segments_openpecha_service.py
+++ b/tests/segments/test_segments_openpecha_service.py
@@ -6,11 +6,13 @@
_classify_text,
_fetch_segment_content_safe,
get_openpecha_segment_details_by_id,
+ get_segment_info_by_id_from_openpecha,
get_translations_by_segment_id_from_openpecha,
get_commentaries_by_segment_id_from_openpecha,
)
from pecha_api.texts.segments.segments_response_models import (
V2SegmentCommentariesResponse,
+ V2SegmentInfoResponse,
V2SegmentResponse,
V2SegmentRootTextResponse,
V2SegmentTranslationsResponse,
@@ -562,3 +564,236 @@ async def content_side_effect(segment_id: str):
assert len(result.translations) == 1
assert len(result.translations[0].segments) == 1
assert result.translations[0].segments[0].id == "seg-trans-2"
+
+
+class TestGetSegmentInfoByIdFromOpenpecha:
+ """Tests for the get_segment_info_by_id_from_openpecha service function."""
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ async def test_returns_segment_info_with_translations_and_commentaries(
+ self,
+ mock_fetch_segment_details,
+ mock_fetch_text,
+ ):
+ """Test successful retrieval of segment info with translations and commentaries counts."""
+ mock_fetch_segment_details.return_value = {"text_id": ROOT_TEXT_ID}
+ mock_fetch_text.return_value = {
+ "id": ROOT_TEXT_ID,
+ "title": {"bo": "Root Text Title"},
+ "language": "bo",
+ "translations": ["trans-1", "trans-2", "trans-3"],
+ "commentaries": ["comm-1", "comm-2"],
+ "translation_of": None,
+ "commentary_of": None,
+ }
+
+ result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID)
+
+ assert isinstance(result, V2SegmentInfoResponse)
+ assert result.segment_info.segment_id == PARENT_SEGMENT_ID
+ assert result.segment_info.text_id == ROOT_TEXT_ID
+ assert result.segment_info.translations == 3
+ assert result.segment_info.related_text.commentaries == 2
+ assert result.segment_info.related_text.root_text == 0
+ assert result.segment_info.resources.sheets == 0
+
+ mock_fetch_segment_details.assert_awaited_once_with(PARENT_SEGMENT_ID)
+ mock_fetch_text.assert_awaited_once_with(ROOT_TEXT_ID)
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ async def test_returns_root_text_count_1_when_text_is_translation(
+ self,
+ mock_fetch_segment_details,
+ mock_fetch_text,
+ ):
+ """Test that root_text count is 1 when the text is a translation of another text."""
+ mock_fetch_segment_details.return_value = {"text_id": TRANSLATION_TEXT_ID}
+ mock_fetch_text.return_value = {
+ "id": TRANSLATION_TEXT_ID,
+ "title": {"en": "Translation Title"},
+ "language": "en",
+ "translations": [],
+ "commentaries": [],
+ "translation_of": "root-text-id",
+ "commentary_of": None,
+ }
+
+ result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID)
+
+ assert result.segment_info.related_text.root_text == 1
+ assert result.segment_info.translations == 0
+ assert result.segment_info.related_text.commentaries == 0
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ async def test_returns_root_text_count_1_when_text_is_commentary(
+ self,
+ mock_fetch_segment_details,
+ mock_fetch_text,
+ ):
+ """Test that root_text count is 1 when the text is a commentary of another text."""
+ mock_fetch_segment_details.return_value = {"text_id": COMMENTARY_TEXT_ID}
+ mock_fetch_text.return_value = {
+ "id": COMMENTARY_TEXT_ID,
+ "title": {"bo": "Commentary Title"},
+ "language": "bo",
+ "translations": ["trans-1"],
+ "commentaries": [],
+ "translation_of": None,
+ "commentary_of": "root-text-id",
+ }
+
+ result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID)
+
+ assert result.segment_info.related_text.root_text == 1
+ assert result.segment_info.translations == 1
+ assert result.segment_info.related_text.commentaries == 0
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ async def test_returns_empty_counts_when_no_related_texts(
+ self,
+ mock_fetch_segment_details,
+ mock_fetch_text,
+ ):
+ """Test that counts are 0 when text has no translations or commentaries."""
+ mock_fetch_segment_details.return_value = {"text_id": ROOT_TEXT_ID}
+ mock_fetch_text.return_value = {
+ "id": ROOT_TEXT_ID,
+ "title": {"bo": "Root Text"},
+ "language": "bo",
+ "translations": [],
+ "commentaries": [],
+ "translation_of": None,
+ "commentary_of": None,
+ }
+
+ result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID)
+
+ assert result.segment_info.translations == 0
+ assert result.segment_info.related_text.commentaries == 0
+ assert result.segment_info.related_text.root_text == 0
+ assert result.segment_info.resources.sheets == 0
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ async def test_raises_404_when_segment_not_found(
+ self,
+ mock_fetch_segment_details,
+ ):
+ """Test that 404 is raised when segment details cannot be fetched."""
+ mock_fetch_segment_details.side_effect = Exception("Segment not found")
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_segment_info_by_id_from_openpecha(segment_id="missing-segment")
+
+ assert exc_info.value.status_code == 404
+ assert "missing-segment" in exc_info.value.detail
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ async def test_raises_404_when_text_id_not_in_segment_details(
+ self,
+ mock_fetch_segment_details,
+ mock_fetch_text,
+ ):
+ """Test that 404 is raised when segment details don't contain text_id."""
+ mock_fetch_segment_details.return_value = {"other_field": "value"}
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID)
+
+ assert exc_info.value.status_code == 404
+ assert "Text ID not found" in exc_info.value.detail
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ async def test_raises_404_when_text_not_found(
+ self,
+ mock_fetch_segment_details,
+ mock_fetch_text,
+ ):
+ """Test that 404 is raised when text cannot be fetched."""
+ mock_fetch_segment_details.return_value = {"text_id": ROOT_TEXT_ID}
+ mock_fetch_text.return_value = None
+
+ with pytest.raises(HTTPException) as exc_info:
+ await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID)
+
+ assert exc_info.value.status_code == 404
+ assert ROOT_TEXT_ID in exc_info.value.detail
+
+ @pytest.mark.asyncio
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_text_by_id",
+ new_callable=AsyncMock,
+ )
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_service.fetch_segment_details",
+ new_callable=AsyncMock,
+ )
+ async def test_handles_missing_translations_and_commentaries_keys(
+ self,
+ mock_fetch_segment_details,
+ mock_fetch_text,
+ ):
+ """Test that missing translations/commentaries keys default to empty lists."""
+ mock_fetch_segment_details.return_value = {"text_id": ROOT_TEXT_ID}
+ mock_fetch_text.return_value = {
+ "id": ROOT_TEXT_ID,
+ "title": {"bo": "Root Text"},
+ "language": "bo",
+ }
+
+ result = await get_segment_info_by_id_from_openpecha(segment_id=PARENT_SEGMENT_ID)
+
+ assert result.segment_info.translations == 0
+ assert result.segment_info.related_text.commentaries == 0
+ assert result.segment_info.related_text.root_text == 0
diff --git a/tests/segments/test_segments_openpecha_views.py b/tests/segments/test_segments_openpecha_views.py
index 7be0b94d6..6aa007ff2 100644
--- a/tests/segments/test_segments_openpecha_views.py
+++ b/tests/segments/test_segments_openpecha_views.py
@@ -6,8 +6,12 @@
from pecha_api.app import api
from pecha_api.texts.segments.segments_response_models import (
ParentSegment,
+ SegmentInfo,
+ SegmentRelatedText,
+ SegmentResources,
V2RelatedSegmentItem,
V2SegmentCommentariesResponse,
+ V2SegmentInfoResponse,
V2SegmentResponse,
V2SegmentTextDetail,
V2SegmentTextGroup,
@@ -258,3 +262,132 @@ def test_get_commentaries_upstream_error(self, mock_service):
assert response.status_code == 502
assert "upstream" in response.json()["detail"].lower()
+
+
+class TestSegmentsV2InfoEndpoint:
+ """Tests for the GET /segments/{segment_id}/info endpoint."""
+
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha",
+ new_callable=AsyncMock,
+ )
+ def test_get_segment_info_success(self, mock_service):
+ """Test successful retrieval of segment info."""
+ mock_service.return_value = V2SegmentInfoResponse(
+ segment_info=SegmentInfo(
+ segment_id="048576e2-b2bc-4275-9d6c-220ca7357f3c",
+ text_id="e159959d-2c0c-4f48-b02c-fbdc8c4a98e3",
+ translations=5,
+ related_text=SegmentRelatedText(
+ commentaries=7,
+ root_text=0,
+ ),
+ resources=SegmentResources(
+ sheets=0,
+ ),
+ )
+ )
+
+ response = client.get(f"{SEGMENTS_ROOT}/048576e2-b2bc-4275-9d6c-220ca7357f3c/info")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["segment_info"]["segment_id"] == "048576e2-b2bc-4275-9d6c-220ca7357f3c"
+ assert data["segment_info"]["text_id"] == "e159959d-2c0c-4f48-b02c-fbdc8c4a98e3"
+ assert data["segment_info"]["translations"] == 5
+ assert data["segment_info"]["related_text"]["commentaries"] == 7
+ assert data["segment_info"]["related_text"]["root_text"] == 0
+ assert data["segment_info"]["resources"]["sheets"] == 0
+
+ mock_service.assert_awaited_once_with(
+ segment_id="048576e2-b2bc-4275-9d6c-220ca7357f3c",
+ )
+
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha",
+ new_callable=AsyncMock,
+ )
+ def test_get_segment_info_with_root_text(self, mock_service):
+ """Test segment info when text is a translation (has root_text)."""
+ mock_service.return_value = V2SegmentInfoResponse(
+ segment_info=SegmentInfo(
+ segment_id="seg-trans-1",
+ text_id="text-trans-1",
+ translations=0,
+ related_text=SegmentRelatedText(
+ commentaries=0,
+ root_text=1,
+ ),
+ resources=SegmentResources(
+ sheets=0,
+ ),
+ )
+ )
+
+ response = client.get(f"{SEGMENTS_ROOT}/seg-trans-1/info")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["segment_info"]["related_text"]["root_text"] == 1
+ assert data["segment_info"]["translations"] == 0
+
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha",
+ new_callable=AsyncMock,
+ )
+ def test_get_segment_info_with_no_related_texts(self, mock_service):
+ """Test segment info when text has no translations or commentaries."""
+ mock_service.return_value = V2SegmentInfoResponse(
+ segment_info=SegmentInfo(
+ segment_id="seg-1",
+ text_id="text-1",
+ translations=0,
+ related_text=SegmentRelatedText(
+ commentaries=0,
+ root_text=0,
+ ),
+ resources=SegmentResources(
+ sheets=0,
+ ),
+ )
+ )
+
+ response = client.get(f"{SEGMENTS_ROOT}/seg-1/info")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["segment_info"]["translations"] == 0
+ assert data["segment_info"]["related_text"]["commentaries"] == 0
+ assert data["segment_info"]["related_text"]["root_text"] == 0
+
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha",
+ new_callable=AsyncMock,
+ )
+ def test_get_segment_info_not_found(self, mock_service):
+ """Test 404 when segment is not found."""
+ mock_service.side_effect = HTTPException(
+ status_code=404,
+ detail="Segment with id 'missing-segment' not found",
+ )
+
+ response = client.get(f"{SEGMENTS_ROOT}/missing-segment/info")
+
+ assert response.status_code == 404
+ assert "missing-segment" in response.json()["detail"]
+
+ @patch(
+ "pecha_api.texts.segments.segments_openpecha_views.get_segment_info_by_id_from_openpecha",
+ new_callable=AsyncMock,
+ )
+ def test_get_segment_info_text_not_found(self, mock_service):
+ """Test 404 when text associated with segment is not found."""
+ mock_service.side_effect = HTTPException(
+ status_code=404,
+ detail="Text with id 'text-123' not found",
+ )
+
+ response = client.get(f"{SEGMENTS_ROOT}/seg-1/info")
+
+ assert response.status_code == 404
+ assert "text-123" in response.json()["detail"].lower()
From f6434c26b6e746ba585dcf9c9c1ed9cef39009d4 Mon Sep 17 00:00:00 2001
From: tenkus47
Date: Wed, 3 Jun 2026 15:35:32 +0530
Subject: [PATCH 17/29] feat(texts): add title filtering to text fetching
endpoints
- Introduced an optional `title` parameter in the `fetch_texts_by_category` function to filter results based on a case-insensitive substring of the title.
- Updated related functions and views to support the new title filtering feature.
- Enhanced tests to verify the correct behavior of title filtering in various scenarios, ensuring that the title parameter is correctly passed and handled.
- Set coverage threshold to 80% in the CI configuration.
---
.github/workflows/github-ci.yml | 2 +-
coverage.xml | 7296 +++++++++++++++++
openpecha_api/text/openpecha_text_service.py | 3 +
pecha_api/texts/texts_openpecha_service.py | 4 +
pecha_api/texts/texts_openpecha_views.py | 2 +
pyproject.toml | 1 +
.../test_openpecha_segment_service.py | 109 +
.../test_openpecha_text_service.py | 53 +
tests/texts/test_texts_openpecha_service.py | 56 +
tests/texts/test_texts_openpecha_views.py | 20 +
10 files changed, 7545 insertions(+), 1 deletion(-)
create mode 100644 coverage.xml
create mode 100644 tests/openpecha_api/test_openpecha_segment_service.py
diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml
index 0a5a64cdb..29764b266 100644
--- a/.github/workflows/github-ci.yml
+++ b/.github/workflows/github-ci.yml
@@ -98,7 +98,7 @@ jobs:
- name: Run tests with coverage
run: |
- poetry run pytest --cov=pecha_api --cov-report=xml
+ poetry run pytest --cov=pecha_api --cov=openpecha_api --cov-report=xml --cov-fail-under=80
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v5
diff --git a/coverage.xml b/coverage.xml
new file mode 100644
index 000000000..f55fd19f9
--- /dev/null
+++ b/coverage.xml
@@ -0,0 +1,7296 @@
+
+
+
+
+
+ D:\work\WeBuddhist-Backend\pecha_api
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/openpecha_api/text/openpecha_text_service.py b/openpecha_api/text/openpecha_text_service.py
index 7906cc98f..f70c860b2 100644
--- a/openpecha_api/text/openpecha_text_service.py
+++ b/openpecha_api/text/openpecha_text_service.py
@@ -9,6 +9,7 @@
async def fetch_texts_by_category(
category_id: str,
language: Optional[str] = None,
+ title: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> Dict[str, Any]:
@@ -19,6 +20,8 @@ async def fetch_texts_by_category(
}
if language:
params["language"] = language
+ if title:
+ params["title"] = title
client = get_authenticated_open_pecha_client()
http_client = client.get_async_httpx_client()
diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py
index 64c3667e5..6ff99fe3c 100644
--- a/pecha_api/texts/texts_openpecha_service.py
+++ b/pecha_api/texts/texts_openpecha_service.py
@@ -84,10 +84,12 @@ async def _get_texts_by_collection_id(
collection_id: str,
skip: int,
limit: int,
+ title: Optional[str] = None,
) -> Tuple[List[V2TextDTO], bool]:
try:
page = await fetch_texts_by_category(
category_id=collection_id,
+ title=title,
offset=skip,
limit=limit,
)
@@ -107,11 +109,13 @@ async def _get_texts_by_collection_id(
async def get_texts_by_collection_from_openpecha(
collection_id: str,
language: Optional[str] = None,
+ title: Optional[str] = None,
skip: int = 0,
limit: int = 10,
) -> V2TextsCategoryResponse:
texts, has_more = await _get_texts_by_collection_id(
collection_id=collection_id,
+ title=title,
skip=skip,
limit=limit,
)
diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py
index 7821d6636..0d2aee947 100644
--- a/pecha_api/texts/texts_openpecha_views.py
+++ b/pecha_api/texts/texts_openpecha_views.py
@@ -32,12 +32,14 @@
async def get_texts_by_collection(
collection_id: Annotated[str, Query(description="Collection ID to filter texts")],
language: Annotated[Optional[str], Query(description="Language code filter")] = None,
+ title: Annotated[Optional[str], Query(description="Filter texts by title (case-insensitive substring)")] = None,
skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0,
limit: Annotated[int, Query(ge=1, le=100, description="Number of records to return")] = 10,
) -> V2TextsCategoryResponse:
return await get_texts_by_collection_from_openpecha(
collection_id=collection_id,
language=language,
+ title=title,
skip=skip,
limit=limit,
)
diff --git a/pyproject.toml b/pyproject.toml
index e6fe7c776..b3e64e57a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,6 +51,7 @@ start = "uvicorn:main"
omit = [ "*/*_repository.py", "*/*_models.py", "*/*_init__.py", "*/db/*", "*/external_clients/*",]
[tool.coverage.report]
+fail_under = 80
exclude_lines = [ "pragma: no cover", "def __repr__", "if self.debug:", "raise NotImplementedError", "if __name__ == .__main__.:", "pass", "raise ImportError",]
[tool.poetry.dependencies.uvicorn]
diff --git a/tests/openpecha_api/test_openpecha_segment_service.py b/tests/openpecha_api/test_openpecha_segment_service.py
new file mode 100644
index 000000000..b5221d9d2
--- /dev/null
+++ b/tests/openpecha_api/test_openpecha_segment_service.py
@@ -0,0 +1,109 @@
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from openpecha_api.segments.openpecha_segment_service import (
+ fetch_related_segments,
+ fetch_segment_content,
+ fetch_segment_details,
+)
+
+
+def _mock_http_client(response_payload):
+ mock_response = MagicMock()
+ mock_response.json.return_value = response_payload
+ mock_response.raise_for_status = MagicMock()
+
+ mock_http = AsyncMock()
+ mock_http.get.return_value = mock_response
+ return mock_http
+
+
+@pytest.mark.asyncio
+@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client")
+async def test_fetch_related_segments(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"items": [{"id": "seg-1"}]}
+ )
+
+ result = await fetch_related_segments("seg-1", limit=5, offset=2)
+
+ assert result["items"][0]["id"] == "seg-1"
+ http = mock_get_client.return_value.get_async_httpx_client.return_value
+ http.get.assert_awaited_once_with(
+ "/v2/segments/seg-1/related",
+ params={"limit": 5, "offset": 2},
+ )
+
+
+@pytest.mark.asyncio
+@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client")
+async def test_fetch_segment_content_string_response(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ "segment text"
+ )
+
+ result = await fetch_segment_content("seg-1")
+
+ assert result == "segment text"
+
+
+@pytest.mark.asyncio
+@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client")
+async def test_fetch_segment_content_dict_with_content_key(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"content": "from content key"}
+ )
+
+ result = await fetch_segment_content("seg-1")
+
+ assert result == "from content key"
+
+
+@pytest.mark.asyncio
+@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client")
+async def test_fetch_segment_content_dict_with_text_key(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"text": "from text key"}
+ )
+
+ result = await fetch_segment_content("seg-1")
+
+ assert result == "from text key"
+
+
+@pytest.mark.asyncio
+@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client")
+async def test_fetch_segment_content_dict_with_value_key(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"value": "from value key"}
+ )
+
+ result = await fetch_segment_content("seg-1")
+
+ assert result == "from value key"
+
+
+@pytest.mark.asyncio
+@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client")
+async def test_fetch_segment_content_unrecognized_dict_returns_none(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"other": "value"}
+ )
+
+ result = await fetch_segment_content("seg-1")
+
+ assert result is None
+
+
+@pytest.mark.asyncio
+@patch("openpecha_api.segments.openpecha_segment_service.get_authenticated_open_pecha_client")
+async def test_fetch_segment_details(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"id": "seg-1", "type": "segment"}
+ )
+
+ result = await fetch_segment_details("seg-1")
+
+ assert result["id"] == "seg-1"
+ http = mock_get_client.return_value.get_async_httpx_client.return_value
+ http.get.assert_awaited_once_with("/v2/segments/seg-1")
diff --git a/tests/openpecha_api/test_openpecha_text_service.py b/tests/openpecha_api/test_openpecha_text_service.py
index f7d893090..01b11ad14 100644
--- a/tests/openpecha_api/test_openpecha_text_service.py
+++ b/tests/openpecha_api/test_openpecha_text_service.py
@@ -31,6 +31,59 @@ async def test_fetch_texts_by_category(mock_get_client):
)
+@pytest.mark.asyncio
+@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client")
+async def test_fetch_texts_by_category_omits_title_when_not_provided(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"items": []}
+ )
+
+ await fetch_texts_by_category("cat-1", limit=10, offset=0)
+
+ http = mock_get_client.return_value.get_async_httpx_client.return_value
+ params = http.get.await_args.kwargs["params"]
+ assert "title" not in params
+
+
+@pytest.mark.asyncio
+@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client")
+async def test_fetch_texts_by_category_omits_title_when_empty_string(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"items": []}
+ )
+
+ await fetch_texts_by_category("cat-1", title="", limit=10, offset=0)
+
+ http = mock_get_client.return_value.get_async_httpx_client.return_value
+ params = http.get.await_args.kwargs["params"]
+ assert "title" not in params
+
+
+@pytest.mark.asyncio
+@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client")
+async def test_fetch_texts_by_category_with_title_filter(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"items": [{"id": "t1"}]}
+ )
+
+ result = await fetch_texts_by_category(
+ "cat-1", language="en", title="sutra", limit=10, offset=0
+ )
+
+ assert result["items"][0]["id"] == "t1"
+ http = mock_get_client.return_value.get_async_httpx_client.return_value
+ http.get.assert_awaited_once_with(
+ "/v2/texts",
+ params={
+ "category_id": "cat-1",
+ "limit": 10,
+ "offset": 0,
+ "language": "en",
+ "title": "sutra",
+ },
+ )
+
+
@pytest.mark.asyncio
@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client")
async def test_fetch_text_by_id(mock_get_client):
diff --git a/tests/texts/test_texts_openpecha_service.py b/tests/texts/test_texts_openpecha_service.py
index f3892b847..4478f4203 100644
--- a/tests/texts/test_texts_openpecha_service.py
+++ b/tests/texts/test_texts_openpecha_service.py
@@ -87,6 +87,10 @@ def test_extract_title_none_language(self):
result = _extract_title(title_payload, None)
assert result == "English Title"
+ def test_extract_title_invalid_payload_returns_empty_string(self):
+ assert _extract_title(None) == ""
+ assert _extract_title(123) == ""
+
class TestMapExternalTextToDto:
"""Tests for map_external_text_to_dto function (legacy TextDTO mapper)."""
@@ -293,10 +297,35 @@ async def test_passes_skip_and_limit_to_upstream_without_language(self, mock_fet
mock_fetch_texts.assert_awaited_once_with(
category_id="cat-1",
+ title=None,
offset=5,
limit=3,
)
+ @pytest.mark.asyncio
+ @patch("pecha_api.texts.texts_openpecha_service.fetch_texts_by_category", new_callable=AsyncMock)
+ async def test_passes_title_filter_to_upstream(self, mock_fetch_texts):
+ mock_fetch_texts.return_value = {
+ "items": [_text_item("t-en", "en")],
+ "has_more": False,
+ }
+
+ texts, has_more = await _get_texts_by_collection_id(
+ collection_id="cat-1",
+ skip=0,
+ limit=10,
+ title="heart",
+ )
+
+ assert len(texts) == 1
+ assert has_more is False
+ mock_fetch_texts.assert_awaited_once_with(
+ category_id="cat-1",
+ title="heart",
+ offset=0,
+ limit=10,
+ )
+
@pytest.mark.asyncio
@patch("pecha_api.texts.texts_openpecha_service.fetch_texts_by_category", new_callable=AsyncMock)
async def test_returns_empty_list_when_no_items(self, mock_fetch_texts):
@@ -376,6 +405,32 @@ async def test_get_texts_success(self, mock_fetch_texts, mock_fetch_category):
mock_fetch_texts.assert_awaited_once_with(
category_id="cat-1",
+ title=None,
+ offset=0,
+ limit=10,
+ )
+
+ @pytest.mark.asyncio
+ @patch("pecha_api.texts.texts_openpecha_service.fetch_category_by_id", new_callable=AsyncMock)
+ @patch("pecha_api.texts.texts_openpecha_service.fetch_texts_by_category", new_callable=AsyncMock)
+ async def test_get_texts_with_title_filter(self, mock_fetch_texts, mock_fetch_category):
+ mock_fetch_texts.return_value = {
+ "items": [{"id": "t-en", "title": {"en": "Heart Sutra"}, "language": "en"}],
+ "has_more": False,
+ }
+ mock_fetch_category.return_value = {"title": {"en": "Collection"}}
+
+ result = await get_texts_by_collection_from_openpecha(
+ collection_id="cat-1",
+ title="heart",
+ skip=0,
+ limit=10,
+ )
+
+ assert len(result.texts) == 1
+ mock_fetch_texts.assert_awaited_once_with(
+ category_id="cat-1",
+ title="heart",
offset=0,
limit=10,
)
@@ -406,6 +461,7 @@ async def test_get_texts_with_pagination(self, mock_fetch_texts, mock_fetch_cate
mock_fetch_texts.assert_awaited_once_with(
category_id="cat-1",
+ title=None,
offset=1,
limit=1,
)
diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py
index 3a2a3936a..ae47f49bc 100644
--- a/tests/texts/test_texts_openpecha_views.py
+++ b/tests/texts/test_texts_openpecha_views.py
@@ -98,6 +98,26 @@ def test_get_texts_by_collection_success(self, mock_service):
assert data["collection"]["title"] == "Discourses"
assert len(data["texts"]) == 2
+ @patch("pecha_api.texts.texts_openpecha_views.get_texts_by_collection_from_openpecha")
+ def test_get_texts_by_collection_with_title_filter(self, mock_service):
+ mock_service.return_value = V2TextsCategoryResponse(
+ collection=V2CollectionModel(id="cat-1", title="Discourses"),
+ texts=[V2TextDTO(id="t1", title="Heart Sutra", language="en")],
+ skip=0,
+ limit=10,
+ )
+
+ response = client.get("/texts?collection_id=cat-1&title=heart&skip=0&limit=10")
+
+ assert response.status_code == 200
+ mock_service.assert_awaited_once_with(
+ collection_id="cat-1",
+ language=None,
+ title="heart",
+ skip=0,
+ limit=10,
+ )
+
@patch("pecha_api.texts.texts_openpecha_views.get_text_by_id_from_openpecha")
def test_get_text_by_id_success(self, mock_service):
mock_service.return_value = V2TextDTO(
From 5a1b6d11dae3fa7c3f617b95af5d3543960ba832 Mon Sep 17 00:00:00 2001
From: tenkus47
Date: Wed, 3 Jun 2026 15:56:55 +0530
Subject: [PATCH 18/29] feat(mongo_database): enhance MongoDB connection
handling in lifespan
- Added validation for the MongoDB connection string to ensure it is configured before attempting to initialize the client.
- Implemented error handling for invalid connection strings and other exceptions during client initialization, with appropriate logging.
- Refactored the initialization of Beanie document models to use a constant list for better maintainability.
- Updated tests to cover scenarios where the connection string is empty or invalid, ensuring robust error handling.
---
pecha_api/db/mongo_database.py | 61 +++++++++++++++++++-----
tests/db/test_mongo_database_lifespan.py | 54 +++++++++++++++++++--
2 files changed, 100 insertions(+), 15 deletions(-)
diff --git a/pecha_api/db/mongo_database.py b/pecha_api/db/mongo_database.py
index 687024e6b..3805532e9 100644
--- a/pecha_api/db/mongo_database.py
+++ b/pecha_api/db/mongo_database.py
@@ -4,6 +4,7 @@
from beanie import init_beanie
from fastapi import FastAPI
from motor.motor_asyncio import AsyncIOMotorClient
+from pymongo.errors import ConfigurationError
from ..topics.topics_models import Topic
from ..collections.collections_models import Collection
@@ -14,26 +15,64 @@
from ..texts.groups.groups_models import Group
from ..config import get
+logger = logging.getLogger(__name__)
+
mongodb_client = None
mongodb = None
+BEANIE_DOCUMENT_MODELS = [
+ Collection,
+ Term,
+ Topic,
+ Text,
+ Segment,
+ TableOfContent,
+ Group,
+]
+
+
+def _is_mongo_connection_string_configured(connection_string: str) -> bool:
+ return bool(connection_string and connection_string.strip())
+
@asynccontextmanager
async def lifespan(api: FastAPI):
global mongodb_client, mongodb
- # Initialize the MongoDB client and database
- mongodb_client = AsyncIOMotorClient(get("MONGO_CONNECTION_STRING"))
- mongodb = mongodb_client[get("MONGO_DATABASE_NAME")]
- api.mongodb = mongodb # Attach the database instance to the FastAPI app
+ api.mongodb = None
+
+ connection_string = get("MONGO_CONNECTION_STRING")
+ if not _is_mongo_connection_string_configured(connection_string):
+ logger.warning(
+ "MONGO_CONNECTION_STRING is not set; MongoDB and Beanie will not be initialized."
+ )
+ yield
+ return
+
+ try:
+ mongodb_client = AsyncIOMotorClient(connection_string)
+ mongodb = mongodb_client[get("MONGO_DATABASE_NAME")]
+ api.mongodb = mongodb
+ except ConfigurationError:
+ logger.exception(
+ "Invalid MONGO_CONNECTION_STRING; MongoDB will not be initialized."
+ )
+ yield
+ return
+ except Exception:
+ logger.exception(
+ "Failed to create MongoDB client; MongoDB will not be initialized."
+ )
+ yield
+ return
- # Initialize collections and indexes if necessary
try:
- await init_beanie(database=mongodb,document_models=[Collection, Term, Topic, Text, Segment, TableOfContent, Group])
- logging.info("Beanie initialized with the 'terms' collection.")
-
- except Exception as e:
- logging.error(f"Error during collection initialization: {e}")
- raise
+ await init_beanie(
+ database=mongodb,
+ document_models=BEANIE_DOCUMENT_MODELS,
+ )
+ logger.info("Beanie initialized with MongoDB document models.")
+ except Exception:
+ logger.exception("Error during Beanie initialization.")
yield
diff --git a/tests/db/test_mongo_database_lifespan.py b/tests/db/test_mongo_database_lifespan.py
index e3c22bef9..14c0b413f 100644
--- a/tests/db/test_mongo_database_lifespan.py
+++ b/tests/db/test_mongo_database_lifespan.py
@@ -1,10 +1,18 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
+from pymongo.errors import ConfigurationError
from pecha_api.db.mongo_database import lifespan
+def _mongo_get_side_effect(key: str) -> str:
+ return {
+ "MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
+ "MONGO_DATABASE_NAME": "testdb",
+ }[key]
+
+
@pytest.mark.asyncio
async def test_lifespan_closes_mongo_client_on_shutdown():
api = MagicMock()
@@ -14,10 +22,7 @@ async def test_lifespan_closes_mongo_client_on_shutdown():
new_callable=AsyncMock,
), patch(
"pecha_api.db.mongo_database.get",
- side_effect=lambda key: {
- "MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
- "MONGO_DATABASE_NAME": "testdb",
- }[key],
+ side_effect=_mongo_get_side_effect,
):
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
@@ -28,3 +33,44 @@ async def test_lifespan_closes_mongo_client_on_shutdown():
assert api.mongodb is mock_db
mock_client.close.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_lifespan_skips_mongo_when_connection_string_empty():
+ api = MagicMock()
+
+ with patch("pecha_api.db.mongo_database.AsyncIOMotorClient") as mock_client_cls, patch(
+ "pecha_api.db.mongo_database.init_beanie",
+ new_callable=AsyncMock,
+ ), patch(
+ "pecha_api.db.mongo_database.get",
+ side_effect=lambda key: {
+ "MONGO_CONNECTION_STRING": "",
+ "MONGO_DATABASE_NAME": "testdb",
+ }[key],
+ ):
+ async with lifespan(api):
+ assert api.mongodb is None
+
+ mock_client_cls.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_lifespan_skips_mongo_when_connection_string_invalid():
+ api = MagicMock()
+
+ with patch("pecha_api.db.mongo_database.AsyncIOMotorClient") as mock_client_cls, patch(
+ "pecha_api.db.mongo_database.init_beanie",
+ new_callable=AsyncMock,
+ ), patch(
+ "pecha_api.db.mongo_database.get",
+ side_effect=_mongo_get_side_effect,
+ ):
+ mock_client_cls.side_effect = ConfigurationError(
+ "Empty host (or extra comma in host list)."
+ )
+
+ async with lifespan(api):
+ assert api.mongodb is None
+
+ mock_client_cls.assert_called_once()
From 802eaba270257b06449804e076a08b8c1d7fd9c8 Mon Sep 17 00:00:00 2001
From: tenkus47
Date: Wed, 3 Jun 2026 17:16:54 +0530
Subject: [PATCH 19/29] feat(texts): make category_id and collection_id
optional in text fetching functions
- Updated the `fetch_texts_by_category` and `get_texts_by_collection_from_openpecha` functions to accept `category_id` and `collection_id` as optional parameters.
- Adjusted related service and view functions to handle cases where these IDs are not provided, ensuring proper functionality without them.
- Enhanced tests to verify the behavior of the updated functions when no category or collection ID is supplied, confirming that the parameters are omitted as expected.
---
openpecha_api/text/openpecha_text_service.py | 5 +--
pecha_api/texts/texts_openpecha_service.py | 34 ++++++++++---------
pecha_api/texts/texts_openpecha_views.py | 2 +-
pecha_api/texts/texts_response_models.py | 2 +-
.../test_openpecha_text_service.py | 15 ++++++++
tests/texts/test_texts_openpecha_service.py | 21 ++++++++++++
tests/texts/test_texts_openpecha_views.py | 23 +++++++++++++
7 files changed, 82 insertions(+), 20 deletions(-)
diff --git a/openpecha_api/text/openpecha_text_service.py b/openpecha_api/text/openpecha_text_service.py
index f70c860b2..7d3b28e67 100644
--- a/openpecha_api/text/openpecha_text_service.py
+++ b/openpecha_api/text/openpecha_text_service.py
@@ -7,17 +7,18 @@
async def fetch_texts_by_category(
- category_id: str,
+ category_id: Optional[str] = None,
language: Optional[str] = None,
title: Optional[str] = None,
limit: int = 20,
offset: int = 0,
) -> Dict[str, Any]:
params: Dict[str, Any] = {
- "category_id": category_id,
"limit": limit,
"offset": offset,
}
+ if category_id:
+ params["category_id"] = category_id
if language:
params["language"] = language
if title:
diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py
index 6ff99fe3c..e936f9fb1 100644
--- a/pecha_api/texts/texts_openpecha_service.py
+++ b/pecha_api/texts/texts_openpecha_service.py
@@ -81,7 +81,7 @@ def map_external_text_to_dto(item: Dict[str, Any], language: Optional[str] = Non
async def _get_texts_by_collection_id(
- collection_id: str,
+ collection_id: Optional[str],
skip: int,
limit: int,
title: Optional[str] = None,
@@ -107,7 +107,7 @@ async def _get_texts_by_collection_id(
async def get_texts_by_collection_from_openpecha(
- collection_id: str,
+ collection_id: Optional[str] = None,
language: Optional[str] = None,
title: Optional[str] = None,
skip: int = 0,
@@ -120,22 +120,24 @@ async def get_texts_by_collection_from_openpecha(
limit=limit,
)
- category_title = ""
- try:
- category_data = await fetch_category_by_id(collection_id, language=language)
- if category_data:
- category_title = _extract_title(category_data.get("title", {}), language)
- except Exception:
- raise HTTPException(
- status_code=status.HTTP_502_BAD_GATEWAY,
- detail="Failed to fetch category title from upstream service",
+ collection: Optional[V2CollectionModel] = None
+ if collection_id:
+ category_title = ""
+ try:
+ category_data = await fetch_category_by_id(collection_id, language=language)
+ if category_data:
+ category_title = _extract_title(category_data.get("title", {}), language)
+ except Exception:
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail="Failed to fetch category title from upstream service",
+ )
+
+ collection = V2CollectionModel(
+ id=collection_id,
+ title=category_title,
)
- collection = V2CollectionModel(
- id=collection_id,
- title=category_title,
- )
-
return V2TextsCategoryResponse(
collection=collection,
texts=texts,
diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py
index 0d2aee947..08b346062 100644
--- a/pecha_api/texts/texts_openpecha_views.py
+++ b/pecha_api/texts/texts_openpecha_views.py
@@ -30,7 +30,7 @@
description="Retrieve texts for a collection from OpenPecha API. "
)
async def get_texts_by_collection(
- collection_id: Annotated[str, Query(description="Collection ID to filter texts")],
+ collection_id: Annotated[Optional[str], Query(description="Collection ID to filter texts")] = None,
language: Annotated[Optional[str], Query(description="Language code filter")] = None,
title: Annotated[Optional[str], Query(description="Filter texts by title (case-insensitive substring)")] = None,
skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0,
diff --git a/pecha_api/texts/texts_response_models.py b/pecha_api/texts/texts_response_models.py
index 1adbb69a5..724bd38e9 100644
--- a/pecha_api/texts/texts_response_models.py
+++ b/pecha_api/texts/texts_response_models.py
@@ -173,7 +173,7 @@ class TextsCategoryResponse(BaseModel):
class V2TextsCategoryResponse(BaseModel):
- collection: V2CollectionModel
+ collection: Optional[V2CollectionModel] = None
texts: List[V2TextDTO]
skip: int
limit: int
diff --git a/tests/openpecha_api/test_openpecha_text_service.py b/tests/openpecha_api/test_openpecha_text_service.py
index 01b11ad14..deb216fc5 100644
--- a/tests/openpecha_api/test_openpecha_text_service.py
+++ b/tests/openpecha_api/test_openpecha_text_service.py
@@ -31,6 +31,21 @@ async def test_fetch_texts_by_category(mock_get_client):
)
+@pytest.mark.asyncio
+@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client")
+async def test_fetch_texts_by_category_omits_category_id_when_not_provided(mock_get_client):
+ mock_get_client.return_value.get_async_httpx_client.return_value = _mock_http_client(
+ {"items": []}
+ )
+
+ await fetch_texts_by_category(limit=10, offset=0)
+
+ http = mock_get_client.return_value.get_async_httpx_client.return_value
+ params = http.get.await_args.kwargs["params"]
+ assert "category_id" not in params
+ assert params == {"limit": 10, "offset": 0}
+
+
@pytest.mark.asyncio
@patch("openpecha_api.text.openpecha_text_service.get_authenticated_open_pecha_client")
async def test_fetch_texts_by_category_omits_title_when_not_provided(mock_get_client):
diff --git a/tests/texts/test_texts_openpecha_service.py b/tests/texts/test_texts_openpecha_service.py
index 4478f4203..326d506b4 100644
--- a/tests/texts/test_texts_openpecha_service.py
+++ b/tests/texts/test_texts_openpecha_service.py
@@ -489,6 +489,27 @@ async def test_get_texts_category_upstream_error(self, mock_fetch_texts, mock_fe
assert exc_info.value.status_code == 502
assert "category" in exc_info.value.detail.lower()
+ @pytest.mark.asyncio
+ @patch("pecha_api.texts.texts_openpecha_service.fetch_category_by_id", new_callable=AsyncMock)
+ @patch("pecha_api.texts.texts_openpecha_service.fetch_texts_by_category", new_callable=AsyncMock)
+ async def test_get_texts_without_collection_id(self, mock_fetch_texts, mock_fetch_category):
+ mock_fetch_texts.return_value = {
+ "items": [{"id": "t1", "title": {"en": "Text 1"}, "language": "en"}],
+ "has_more": False,
+ }
+
+ result = await get_texts_by_collection_from_openpecha(skip=0, limit=10)
+
+ assert result.collection is None
+ assert len(result.texts) == 1
+ mock_fetch_texts.assert_awaited_once_with(
+ category_id=None,
+ title=None,
+ offset=0,
+ limit=10,
+ )
+ mock_fetch_category.assert_not_awaited()
+
# =============================================================================
# Service Function Tests - get_text_by_id_from_openpecha (V2)
diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py
index ae47f49bc..4845d562d 100644
--- a/tests/texts/test_texts_openpecha_views.py
+++ b/tests/texts/test_texts_openpecha_views.py
@@ -79,6 +79,29 @@
# =============================================================================
class TestTextsV2Endpoint:
+ @patch("pecha_api.texts.texts_openpecha_views.get_texts_by_collection_from_openpecha")
+ def test_get_texts_without_collection_id(self, mock_service):
+ mock_service.return_value = V2TextsCategoryResponse(
+ collection=None,
+ texts=[V2TextDTO(id="t1", title="Text 1", language="en")],
+ skip=0,
+ limit=10,
+ )
+
+ response = client.get("/texts")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["collection"] is None
+ assert len(data["texts"]) == 1
+ mock_service.assert_awaited_once_with(
+ collection_id=None,
+ language=None,
+ title=None,
+ skip=0,
+ limit=10,
+ )
+
@patch("pecha_api.texts.texts_openpecha_views.get_texts_by_collection_from_openpecha")
def test_get_texts_by_collection_success(self, mock_service):
mock_service.return_value = V2TextsCategoryResponse(
From 07462dced9c282604778f6d05510e809018fa805 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Wed, 3 Jun 2026 17:35:32 +0530
Subject: [PATCH 20/29] language_filter_removed_for_version
---
pecha_api/texts/texts_openpecha_service.py | 14 +-------------
pecha_api/texts/texts_openpecha_views.py | 2 --
2 files changed, 1 insertion(+), 15 deletions(-)
diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py
index 64c3667e5..88bb5ac5d 100644
--- a/pecha_api/texts/texts_openpecha_service.py
+++ b/pecha_api/texts/texts_openpecha_service.py
@@ -286,15 +286,6 @@ def map_external_text_to_text_version(item: Dict[str, Any], language: Optional[s
)
-def filter_versions_by_language(
- versions: List[TextVersion],
- language: Optional[str]
-) -> List[TextVersion]:
- if not language:
- return versions
- return [v for v in versions if v.language == language]
-
-
def paginate_versions(
versions: List[TextVersion],
skip: int,
@@ -305,7 +296,6 @@ def paginate_versions(
async def get_text_versions_from_openpecha(
text_id: str,
- language: Optional[str] = None,
skip: int = 0,
limit: int = 10
) -> TextVersionResponse:
@@ -342,9 +332,7 @@ async def get_text_versions_from_openpecha(
for item in translation_details
]
- filtered_versions = filter_versions_by_language(versions, language)
-
- paginated_versions = paginate_versions(filtered_versions, skip, limit)
+ paginated_versions = paginate_versions(versions, skip, limit)
return TextVersionResponse(
text=root_text,
diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py
index 7821d6636..833b24f00 100644
--- a/pecha_api/texts/texts_openpecha_views.py
+++ b/pecha_api/texts/texts_openpecha_views.py
@@ -66,13 +66,11 @@ async def get_text_by_id(text_id: str) -> V2TextDTO:
@texts_v2_router.get("/{text_id}/versions", status_code=status.HTTP_200_OK)
async def get_text_versions(
text_id: str,
- language: Annotated[Optional[str], Query(description="Language code filter")] = None,
skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0,
limit: Annotated[int, Query(ge=1, le=100, description="Number of records to return")] = 10
) -> TextVersionResponse:
return await get_text_versions_from_openpecha(
text_id=text_id,
- language=language,
skip=skip,
limit=limit
)
From 76843c7e1f8b5ad7130addb4423d45d47d0b2ca5 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Wed, 3 Jun 2026 17:41:01 +0530
Subject: [PATCH 21/29] test update
---
tests/texts/test_texts_openpecha_service.py | 90 ---------------------
tests/texts/test_texts_openpecha_views.py | 26 +-----
2 files changed, 1 insertion(+), 115 deletions(-)
diff --git a/tests/texts/test_texts_openpecha_service.py b/tests/texts/test_texts_openpecha_service.py
index f3892b847..66dc9c0e7 100644
--- a/tests/texts/test_texts_openpecha_service.py
+++ b/tests/texts/test_texts_openpecha_service.py
@@ -8,7 +8,6 @@
_get_texts_by_collection_id,
map_external_text_to_dto,
map_external_text_to_text_version,
- filter_versions_by_language,
paginate_versions,
fetch_translation_details,
fetch_commentary_details,
@@ -168,70 +167,6 @@ def test_map_commentary_data(self):
assert result.parent_id == "text-123"
-class TestFilterVersionsByLanguage:
- """Tests for filter_versions_by_language function."""
-
- def test_filter_with_language(self):
- versions = [
- TextVersion(
- id="v1", title="English", parent_id=None, priority=1,
- language="en", type="translation", group_id="g1",
- table_of_contents=[], is_published=True,
- created_date="2025-01-01", updated_date="2025-01-01",
- published_date="2025-01-01", published_by="user"
- ),
- TextVersion(
- id="v2", title="Tibetan", parent_id=None, priority=2,
- language="bo", type="translation", group_id="g1",
- table_of_contents=[], is_published=True,
- created_date="2025-01-01", updated_date="2025-01-01",
- published_date="2025-01-01", published_by="user"
- )
- ]
-
- result = filter_versions_by_language(versions, "en")
-
- assert len(result) == 1
- assert result[0].language == "en"
-
- def test_filter_without_language(self):
- versions = [
- TextVersion(
- id="v1", title="English", parent_id=None, priority=1,
- language="en", type="translation", group_id="g1",
- table_of_contents=[], is_published=True,
- created_date="2025-01-01", updated_date="2025-01-01",
- published_date="2025-01-01", published_by="user"
- ),
- TextVersion(
- id="v2", title="Tibetan", parent_id=None, priority=2,
- language="bo", type="translation", group_id="g1",
- table_of_contents=[], is_published=True,
- created_date="2025-01-01", updated_date="2025-01-01",
- published_date="2025-01-01", published_by="user"
- )
- ]
-
- result = filter_versions_by_language(versions, None)
-
- assert len(result) == 2
-
- def test_filter_no_matches(self):
- versions = [
- TextVersion(
- id="v1", title="English", parent_id=None, priority=1,
- language="en", type="translation", group_id="g1",
- table_of_contents=[], is_published=True,
- created_date="2025-01-01", updated_date="2025-01-01",
- published_date="2025-01-01", published_by="user"
- )
- ]
-
- result = filter_versions_by_language(versions, "zh")
-
- assert len(result) == 0
-
-
class TestPaginateVersions:
"""Tests for paginate_versions function."""
@@ -494,7 +429,6 @@ async def test_get_text_versions_success(self, mock_fetch_translations, mock_fet
result = await get_text_versions_from_openpecha(
text_id="text-123",
- language=None,
skip=0,
limit=10
)
@@ -513,7 +447,6 @@ async def test_get_text_versions_text_not_found(self, mock_fetch_text):
with pytest.raises(HTTPException) as exc_info:
await get_text_versions_from_openpecha(
text_id="nonexistent",
- language=None,
skip=0,
limit=10
)
@@ -529,7 +462,6 @@ async def test_get_text_versions_upstream_error(self, mock_fetch_text):
with pytest.raises(HTTPException) as exc_info:
await get_text_versions_from_openpecha(
text_id="text-123",
- language=None,
skip=0,
limit=10
)
@@ -549,7 +481,6 @@ async def test_get_text_versions_no_translations(self, mock_fetch_text):
result = await get_text_versions_from_openpecha(
text_id="text-123",
- language=None,
skip=0,
limit=10
)
@@ -557,26 +488,6 @@ async def test_get_text_versions_no_translations(self, mock_fetch_text):
assert result.text is not None
assert len(result.versions) == 0
- @pytest.mark.asyncio
- @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
- @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details')
- async def test_get_text_versions_with_language_filter(self, mock_fetch_translations, mock_fetch_text):
- mock_fetch_text.return_value = MOCK_EXTERNAL_TEXT_DATA
- mock_fetch_translations.return_value = [
- {"id": "t1", "title": {"en": "English"}, "language": "en", "translation_of": "text-123"},
- {"id": "t2", "title": {"bo": "Tibetan"}, "language": "bo", "translation_of": "text-123"}
- ]
-
- result = await get_text_versions_from_openpecha(
- text_id="text-123",
- language="en",
- skip=0,
- limit=10
- )
-
- assert len(result.versions) == 1
- assert result.versions[0].language == "en"
-
@pytest.mark.asyncio
@patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
@patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details')
@@ -589,7 +500,6 @@ async def test_get_text_versions_with_pagination(self, mock_fetch_translations,
result = await get_text_versions_from_openpecha(
text_id="text-123",
- language=None,
skip=2,
limit=2
)
diff --git a/tests/texts/test_texts_openpecha_views.py b/tests/texts/test_texts_openpecha_views.py
index 3a2a3936a..c81cb5d7b 100644
--- a/tests/texts/test_texts_openpecha_views.py
+++ b/tests/texts/test_texts_openpecha_views.py
@@ -178,28 +178,6 @@ def test_get_text_versions_success(self, mock_service):
assert data["versions"][1]["id"] == "version-2"
mock_service.assert_called_once_with(
text_id="text-123",
- language=None,
- skip=0,
- limit=10
- )
-
- @patch('pecha_api.texts.texts_openpecha_views.get_text_versions_from_openpecha')
- def test_get_text_versions_with_language_filter(self, mock_service):
- mock_response = TextVersionResponse(
- text=MOCK_TEXT_DTO,
- versions=[MOCK_TEXT_VERSION_1]
- )
- mock_service.return_value = mock_response
-
- response = client.get("/texts/text-123/versions?language=en")
-
- assert response.status_code == 200
- data = response.json()
- assert len(data["versions"]) == 1
- assert data["versions"][0]["language"] == "en"
- mock_service.assert_called_once_with(
- text_id="text-123",
- language="en",
skip=0,
limit=10
)
@@ -219,7 +197,6 @@ def test_get_text_versions_with_pagination(self, mock_service):
assert len(data["versions"]) == 1
mock_service.assert_called_once_with(
text_id="text-123",
- language=None,
skip=1,
limit=5
)
@@ -247,12 +224,11 @@ def test_get_text_versions_with_all_parameters(self, mock_service):
)
mock_service.return_value = mock_response
- response = client.get("/texts/text-123/versions?language=bo&skip=2&limit=20")
+ response = client.get("/texts/text-123/versions?skip=2&limit=20")
assert response.status_code == 200
mock_service.assert_called_once_with(
text_id="text-123",
- language="bo",
skip=2,
limit=20
)
From 765e91441a1fd76a71f70ed18b8e21df792e9b12 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Thu, 4 Jun 2026 11:18:14 +0530
Subject: [PATCH 22/29] missing_versions_fetch
---
pecha_api/config.py | 6 +-
pecha_api/texts/texts_openpecha_service.py | 97 ++++++++++++++++++++++
2 files changed, 100 insertions(+), 3 deletions(-)
diff --git a/pecha_api/config.py b/pecha_api/config.py
index 95bc8beaa..2587bcc5c 100644
--- a/pecha_api/config.py
+++ b/pecha_api/config.py
@@ -74,9 +74,9 @@
APPLICATION = "webuddhist",
#pecha api configuration
- EXTERNAL_PECHA_API_URL="",
- EXTERNAL_DEV_PECHA_API_URL="",
- EXTERNAL_OPENPECHA_API_KEY="",
+ EXTERNAL_PECHA_API_URL="https://api-l25bgmwqoa-uc.a.run.app",
+ EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160",
+ EXTERNAL_OPENPECHA_API_KEY="EBWpScbRP6iFLt1SVn8dMfmi3_ab-7sx",
EXTERNAL_PECHA_APP_NAME="webuddhist",
EXTERNAL_TITLE_SEARCH_API_URL="",
diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py
index 88bb5ac5d..3a8e82e3d 100644
--- a/pecha_api/texts/texts_openpecha_service.py
+++ b/pecha_api/texts/texts_openpecha_service.py
@@ -317,6 +317,25 @@ async def get_text_versions_from_openpecha(
root_text = map_external_text_to_dto(text_data, text_data.get("language"))
+ commentary_of = text_data.get("commentary_of")
+ translation_of = text_data.get("translation_of")
+
+ # If translation_of is not null, fetch versions from the parent text
+ if translation_of:
+ return await _fetch_versions_from_parent(translation_of, root_text, skip, limit)
+
+ # If both commentary_of and translation_of are null, check translations and commentaries lists
+ if not commentary_of and not translation_of:
+ translation_ids = text_data.get("translations", [])
+ commentary_ids = text_data.get("commentaries", [])
+
+ # If there are translations or commentaries, fetch versions from the first available ID
+ related_ids = translation_ids + commentary_ids
+ if related_ids:
+ # Fetch versions from the first related text
+ return await _fetch_versions_from_related(related_ids[0], root_text, skip, limit)
+
+ # Default: fetch translations directly from this text
translation_ids = text_data.get("translations", [])
if not translation_ids:
@@ -340,6 +359,84 @@ async def get_text_versions_from_openpecha(
)
+async def _fetch_versions_from_parent(
+ parent_id: str,
+ original_text: TextDTO,
+ skip: int,
+ limit: int
+) -> TextVersionResponse:
+ """Fetch versions from a parent text (translation_of)."""
+ try:
+ parent_data = await fetch_text_by_id(parent_id)
+ except Exception:
+ logger.warning(f"Failed to fetch parent text {parent_id}, returning empty versions")
+ return TextVersionResponse(text=original_text, versions=[])
+
+ if not parent_data:
+ return TextVersionResponse(text=original_text, versions=[])
+
+ translation_ids = parent_data.get("translations", [])
+
+ if not translation_ids:
+ return TextVersionResponse(text=original_text, versions=[])
+
+ translation_details = await fetch_translation_details(translation_ids)
+
+ versions = [
+ map_external_text_to_text_version(item, item.get("language"))
+ for item in translation_details
+ ]
+
+ paginated_versions = paginate_versions(versions, skip, limit)
+
+ return TextVersionResponse(
+ text=original_text,
+ versions=paginated_versions
+ )
+
+
+async def _fetch_versions_from_related(
+ related_id: str,
+ original_text: TextDTO,
+ skip: int,
+ limit: int
+) -> TextVersionResponse:
+ """Fetch versions from a related text (from translations or commentaries list)."""
+ try:
+ related_data = await fetch_text_by_id(related_id)
+ except Exception:
+ logger.warning(f"Failed to fetch related text {related_id}, returning empty versions")
+ return TextVersionResponse(text=original_text, versions=[])
+
+ if not related_data:
+ return TextVersionResponse(text=original_text, versions=[])
+
+ # Check if the related text has a translation_of pointing to a parent
+ translation_of = related_data.get("translation_of")
+ if translation_of:
+ return await _fetch_versions_from_parent(translation_of, original_text, skip, limit)
+
+ # Otherwise, get translations from the related text itself
+ translation_ids = related_data.get("translations", [])
+
+ if not translation_ids:
+ return TextVersionResponse(text=original_text, versions=[])
+
+ translation_details = await fetch_translation_details(translation_ids)
+
+ versions = [
+ map_external_text_to_text_version(item, item.get("language"))
+ for item in translation_details
+ ]
+
+ paginated_versions = paginate_versions(versions, skip, limit)
+
+ return TextVersionResponse(
+ text=original_text,
+ versions=paginated_versions
+ )
+
+
async def fetch_commentary_details(commentary_ids: List[str]) -> List[Dict[str, Any]]:
commentary_details = []
for commentary_id in commentary_ids:
From 03a88eb1b46d554615f619a409f2df2038724f94 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Thu, 4 Jun 2026 11:28:52 +0530
Subject: [PATCH 23/29] etch_missing_commentary
---
pecha_api/texts/texts_openpecha_service.py | 84 ++++++++++++++++++++++
1 file changed, 84 insertions(+)
diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py
index 3a8e82e3d..ca24d59e6 100644
--- a/pecha_api/texts/texts_openpecha_service.py
+++ b/pecha_api/texts/texts_openpecha_service.py
@@ -471,6 +471,24 @@ async def get_text_commentaries_from_openpecha(
detail=f"Text with id '{text_id}' not found",
)
+ commentary_of = text_data.get("commentary_of")
+ translation_of = text_data.get("translation_of")
+
+ # If commentary_of is not null, fetch commentaries from the parent text
+ if commentary_of:
+ return await _fetch_commentaries_from_parent(commentary_of, skip, limit)
+
+ # If both commentary_of and translation_of are null, check translations and commentaries lists
+ if not commentary_of and not translation_of:
+ translation_ids = text_data.get("translations", [])
+ commentary_ids = text_data.get("commentaries", [])
+
+ # If there are translations or commentaries, fetch commentaries from the first available ID
+ related_ids = translation_ids + commentary_ids
+ if related_ids:
+ return await _fetch_commentaries_from_related(related_ids[0], skip, limit)
+
+ # Default: fetch commentaries directly from this text
commentary_ids = text_data.get("commentaries", [])
if not commentary_ids:
@@ -486,3 +504,69 @@ async def get_text_commentaries_from_openpecha(
paginated_commentaries = commentaries[skip:skip + limit]
return paginated_commentaries
+
+
+async def _fetch_commentaries_from_parent(
+ parent_id: str,
+ skip: int,
+ limit: int
+) -> List[TextDTO]:
+ """Fetch commentaries from a parent text (commentary_of)."""
+ try:
+ parent_data = await fetch_text_by_id(parent_id)
+ except Exception:
+ logger.warning(f"Failed to fetch parent text {parent_id}, returning empty commentaries")
+ return []
+
+ if not parent_data:
+ return []
+
+ commentary_ids = parent_data.get("commentaries", [])
+
+ if not commentary_ids:
+ return []
+
+ commentary_details = await fetch_commentary_details(commentary_ids)
+
+ commentaries = [
+ map_external_text_to_dto(item, item.get("language"))
+ for item in commentary_details
+ ]
+
+ return commentaries[skip:skip + limit]
+
+
+async def _fetch_commentaries_from_related(
+ related_id: str,
+ skip: int,
+ limit: int
+) -> List[TextDTO]:
+ """Fetch commentaries from a related text (from translations or commentaries list)."""
+ try:
+ related_data = await fetch_text_by_id(related_id)
+ except Exception:
+ logger.warning(f"Failed to fetch related text {related_id}, returning empty commentaries")
+ return []
+
+ if not related_data:
+ return []
+
+ # Check if the related text has a commentary_of pointing to a parent
+ commentary_of = related_data.get("commentary_of")
+ if commentary_of:
+ return await _fetch_commentaries_from_parent(commentary_of, skip, limit)
+
+ # Otherwise, get commentaries from the related text itself
+ commentary_ids = related_data.get("commentaries", [])
+
+ if not commentary_ids:
+ return []
+
+ commentary_details = await fetch_commentary_details(commentary_ids)
+
+ commentaries = [
+ map_external_text_to_dto(item, item.get("language"))
+ for item in commentary_details
+ ]
+
+ return commentaries[skip:skip + limit]
From 4356b5dceea615f60c6c12ab29c3a4d4a4f2b069 Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Thu, 4 Jun 2026 11:41:22 +0530
Subject: [PATCH 24/29] test update
---
pecha_api/config.py | 6 +-
tests/texts/test_texts_openpecha_service.py | 390 +++++++++++++++++++-
2 files changed, 392 insertions(+), 4 deletions(-)
diff --git a/pecha_api/config.py b/pecha_api/config.py
index 2587bcc5c..95bc8beaa 100644
--- a/pecha_api/config.py
+++ b/pecha_api/config.py
@@ -74,9 +74,9 @@
APPLICATION = "webuddhist",
#pecha api configuration
- EXTERNAL_PECHA_API_URL="https://api-l25bgmwqoa-uc.a.run.app",
- EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160",
- EXTERNAL_OPENPECHA_API_KEY="EBWpScbRP6iFLt1SVn8dMfmi3_ab-7sx",
+ EXTERNAL_PECHA_API_URL="",
+ EXTERNAL_DEV_PECHA_API_URL="",
+ EXTERNAL_OPENPECHA_API_KEY="",
EXTERNAL_PECHA_APP_NAME="webuddhist",
EXTERNAL_TITLE_SEARCH_API_URL="",
diff --git a/tests/texts/test_texts_openpecha_service.py b/tests/texts/test_texts_openpecha_service.py
index 66dc9c0e7..9f81c7d08 100644
--- a/tests/texts/test_texts_openpecha_service.py
+++ b/tests/texts/test_texts_openpecha_service.py
@@ -6,6 +6,10 @@
_extract_title,
_map_external_text_to_dto,
_get_texts_by_collection_id,
+ _fetch_versions_from_parent,
+ _fetch_versions_from_related,
+ _fetch_commentaries_from_parent,
+ _fetch_commentaries_from_related,
map_external_text_to_dto,
map_external_text_to_text_version,
paginate_versions,
@@ -424,7 +428,22 @@ class TestGetTextVersionsFromOpenpecha:
@patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
@patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details')
async def test_get_text_versions_success(self, mock_fetch_translations, mock_fetch_text):
- mock_fetch_text.return_value = MOCK_EXTERNAL_TEXT_DATA
+ """Test default behavior when commentary_of is set (skips related text lookup)."""
+ # Text with commentary_of set - goes to default behavior
+ text_data = {
+ "id": "text-123",
+ "bdrc": "bdrc-123",
+ "title": {"en": "Heart Sutra", "bo": "ཤེས་རབ་སྙིང་པོ།"},
+ "language": "bo",
+ "category_id": "cat-1",
+ "date": "2025-01-01",
+ "license": "CC0",
+ "commentary_of": "parent-text", # Has commentary_of, so default behavior applies
+ "translation_of": None,
+ "translations": ["trans-1", "trans-2"],
+ "commentaries": ["comm-1"]
+ }
+ mock_fetch_text.return_value = text_data
mock_fetch_translations.return_value = [MOCK_TRANSLATION_DATA]
result = await get_text_versions_from_openpecha(
@@ -506,6 +525,154 @@ async def test_get_text_versions_with_pagination(self, mock_fetch_translations,
assert len(result.versions) == 2
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details')
+ async def test_get_text_versions_with_translation_of(self, mock_fetch_translations, mock_fetch_text):
+ """When translation_of is not null, fetch versions from the parent text."""
+ # First call: the requested text has translation_of pointing to parent
+ translation_text = {
+ "id": "trans-1",
+ "title": {"en": "Translation"},
+ "language": "en",
+ "translation_of": "root-text-123",
+ "commentary_of": None,
+ "translations": [],
+ "commentaries": []
+ }
+ # Second call: the parent text has translations
+ parent_text = {
+ "id": "root-text-123",
+ "title": {"bo": "Root Text"},
+ "language": "bo",
+ "translations": ["trans-1", "trans-2"],
+ "commentaries": []
+ }
+ mock_fetch_text.side_effect = [translation_text, parent_text]
+ mock_fetch_translations.return_value = [
+ {"id": "trans-1", "title": {"en": "Translation 1"}, "language": "en", "translation_of": "root-text-123"},
+ {"id": "trans-2", "title": {"en": "Translation 2"}, "language": "zh", "translation_of": "root-text-123"}
+ ]
+
+ result = await get_text_versions_from_openpecha(text_id="trans-1", skip=0, limit=10)
+
+ assert result.text.id == "trans-1"
+ assert len(result.versions) == 2
+ assert result.versions[0].id == "trans-1"
+ assert result.versions[1].id == "trans-2"
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details')
+ async def test_get_text_versions_both_null_with_translations_list(self, mock_fetch_translations, mock_fetch_text):
+ """When both commentary_of and translation_of are null, check translations list."""
+ # Root text with translations list
+ root_text = {
+ "id": "root-123",
+ "title": {"bo": "Root Text"},
+ "language": "bo",
+ "translation_of": None,
+ "commentary_of": None,
+ "translations": ["trans-1"],
+ "commentaries": []
+ }
+ # The translation has translation_of pointing to a parent
+ translation_text = {
+ "id": "trans-1",
+ "title": {"en": "Translation"},
+ "language": "en",
+ "translation_of": "root-123",
+ "translations": [],
+ "commentaries": []
+ }
+ # Parent text (root-123) has translations
+ parent_text = {
+ "id": "root-123",
+ "title": {"bo": "Root Text"},
+ "language": "bo",
+ "translations": ["trans-1", "trans-2"],
+ "commentaries": []
+ }
+ mock_fetch_text.side_effect = [root_text, translation_text, parent_text]
+ mock_fetch_translations.return_value = [
+ {"id": "trans-1", "title": {"en": "Translation 1"}, "language": "en"},
+ {"id": "trans-2", "title": {"en": "Translation 2"}, "language": "zh"}
+ ]
+
+ result = await get_text_versions_from_openpecha(text_id="root-123", skip=0, limit=10)
+
+ assert result.text.id == "root-123"
+ assert len(result.versions) == 2
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ async def test_get_text_versions_translation_of_parent_not_found(self, mock_fetch_text):
+ """When translation_of parent is not found, return empty versions."""
+ translation_text = {
+ "id": "trans-1",
+ "title": {"en": "Translation"},
+ "language": "en",
+ "translation_of": "missing-parent",
+ "commentary_of": None
+ }
+ mock_fetch_text.side_effect = [translation_text, None]
+
+ result = await get_text_versions_from_openpecha(text_id="trans-1", skip=0, limit=10)
+
+ assert result.text.id == "trans-1"
+ assert len(result.versions) == 0
+
+
+# =============================================================================
+# Service Function Tests - _fetch_versions_from_parent
+# =============================================================================
+
+class TestFetchVersionsFromParent:
+ """Tests for _fetch_versions_from_parent helper function."""
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_translation_details')
+ async def test_fetch_versions_from_parent_success(self, mock_fetch_translations, mock_fetch_text):
+ original_text = MagicMock(spec=TextDTO)
+ original_text.id = "trans-1"
+ parent_data = {
+ "id": "root-123",
+ "translations": ["trans-1", "trans-2"]
+ }
+ mock_fetch_text.return_value = parent_data
+ mock_fetch_translations.return_value = [
+ {"id": "trans-1", "title": {"en": "Trans 1"}, "language": "en"},
+ {"id": "trans-2", "title": {"en": "Trans 2"}, "language": "zh"}
+ ]
+
+ result = await _fetch_versions_from_parent("root-123", original_text, 0, 10)
+
+ assert result.text == original_text
+ assert len(result.versions) == 2
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ async def test_fetch_versions_from_parent_not_found(self, mock_fetch_text):
+ original_text = MagicMock(spec=TextDTO)
+ mock_fetch_text.return_value = None
+
+ result = await _fetch_versions_from_parent("missing", original_text, 0, 10)
+
+ assert result.text == original_text
+ assert len(result.versions) == 0
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ async def test_fetch_versions_from_parent_error(self, mock_fetch_text):
+ original_text = MagicMock(spec=TextDTO)
+ mock_fetch_text.side_effect = Exception("Connection error")
+
+ result = await _fetch_versions_from_parent("root-123", original_text, 0, 10)
+
+ assert result.text == original_text
+ assert len(result.versions) == 0
+
# =============================================================================
# Service Function Tests - get_text_commentaries_from_openpecha
@@ -691,6 +858,227 @@ async def test_get_commentaries_partial_fetch_failure(self, mock_fetch_commentar
assert len(result) == 1
assert result[0].id == "c1"
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details')
+ async def test_get_commentaries_with_commentary_of(self, mock_fetch_commentaries, mock_fetch_text):
+ """When commentary_of is not null, fetch commentaries from the parent text."""
+ # First call: the requested text has commentary_of pointing to parent
+ commentary_text = {
+ "id": "comm-1",
+ "title": {"en": "Commentary"},
+ "language": "bo",
+ "commentary_of": "root-text-123",
+ "translation_of": None,
+ "translations": [],
+ "commentaries": []
+ }
+ # Second call: the parent text has commentaries
+ parent_text = {
+ "id": "root-text-123",
+ "title": {"bo": "Root Text"},
+ "language": "bo",
+ "commentaries": ["comm-1", "comm-2"]
+ }
+ mock_fetch_text.side_effect = [commentary_text, parent_text]
+ mock_fetch_commentaries.return_value = [
+ {"id": "comm-1", "title": {"en": "Commentary 1"}, "language": "bo"},
+ {"id": "comm-2", "title": {"en": "Commentary 2"}, "language": "bo"}
+ ]
+
+ result = await get_text_commentaries_from_openpecha(text_id="comm-1", skip=0, limit=10)
+
+ assert len(result) == 2
+ assert result[0].id == "comm-1"
+ assert result[1].id == "comm-2"
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details')
+ async def test_get_commentaries_both_null_with_translations_list(self, mock_fetch_commentaries, mock_fetch_text):
+ """When both commentary_of and translation_of are null, check translations/commentaries list."""
+ # Root text with translations list
+ root_text = {
+ "id": "root-123",
+ "title": {"bo": "Root Text"},
+ "language": "bo",
+ "translation_of": None,
+ "commentary_of": None,
+ "translations": ["trans-1"],
+ "commentaries": []
+ }
+ # The translation has commentary_of pointing to a parent
+ translation_text = {
+ "id": "trans-1",
+ "title": {"en": "Translation"},
+ "language": "en",
+ "commentary_of": "root-123",
+ "translations": [],
+ "commentaries": []
+ }
+ # Parent text (root-123) has commentaries
+ parent_text = {
+ "id": "root-123",
+ "title": {"bo": "Root Text"},
+ "language": "bo",
+ "commentaries": ["comm-1", "comm-2"]
+ }
+ mock_fetch_text.side_effect = [root_text, translation_text, parent_text]
+ mock_fetch_commentaries.return_value = [
+ {"id": "comm-1", "title": {"en": "Commentary 1"}, "language": "bo"},
+ {"id": "comm-2", "title": {"en": "Commentary 2"}, "language": "bo"}
+ ]
+
+ result = await get_text_commentaries_from_openpecha(text_id="root-123", skip=0, limit=10)
+
+ assert len(result) == 2
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ async def test_get_commentaries_commentary_of_parent_not_found(self, mock_fetch_text):
+ """When commentary_of parent is not found, return empty commentaries."""
+ commentary_text = {
+ "id": "comm-1",
+ "title": {"en": "Commentary"},
+ "language": "bo",
+ "commentary_of": "missing-parent",
+ "translation_of": None
+ }
+ mock_fetch_text.side_effect = [commentary_text, None]
+
+ result = await get_text_commentaries_from_openpecha(text_id="comm-1", skip=0, limit=10)
+
+ assert len(result) == 0
+
+
+# =============================================================================
+# Service Function Tests - _fetch_commentaries_from_parent
+# =============================================================================
+
+class TestFetchCommentariesFromParent:
+ """Tests for _fetch_commentaries_from_parent helper function."""
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details')
+ async def test_fetch_commentaries_from_parent_success(self, mock_fetch_commentaries, mock_fetch_text):
+ parent_data = {
+ "id": "root-123",
+ "commentaries": ["comm-1", "comm-2"]
+ }
+ mock_fetch_text.return_value = parent_data
+ mock_fetch_commentaries.return_value = [
+ {"id": "comm-1", "title": {"en": "Commentary 1"}, "language": "bo"},
+ {"id": "comm-2", "title": {"en": "Commentary 2"}, "language": "bo"}
+ ]
+
+ result = await _fetch_commentaries_from_parent("root-123", 0, 10)
+
+ assert len(result) == 2
+ assert result[0].id == "comm-1"
+ assert result[1].id == "comm-2"
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ async def test_fetch_commentaries_from_parent_not_found(self, mock_fetch_text):
+ mock_fetch_text.return_value = None
+
+ result = await _fetch_commentaries_from_parent("missing", 0, 10)
+
+ assert len(result) == 0
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ async def test_fetch_commentaries_from_parent_error(self, mock_fetch_text):
+ mock_fetch_text.side_effect = Exception("Connection error")
+
+ result = await _fetch_commentaries_from_parent("root-123", 0, 10)
+
+ assert len(result) == 0
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ async def test_fetch_commentaries_from_parent_no_commentaries(self, mock_fetch_text):
+ parent_data = {
+ "id": "root-123",
+ "commentaries": []
+ }
+ mock_fetch_text.return_value = parent_data
+
+ result = await _fetch_commentaries_from_parent("root-123", 0, 10)
+
+ assert len(result) == 0
+
+
+# =============================================================================
+# Service Function Tests - _fetch_commentaries_from_related
+# =============================================================================
+
+class TestFetchCommentariesFromRelated:
+ """Tests for _fetch_commentaries_from_related helper function."""
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details')
+ async def test_fetch_commentaries_from_related_with_commentary_of(self, mock_fetch_commentaries, mock_fetch_text):
+ """When related text has commentary_of, fetch from that parent."""
+ related_data = {
+ "id": "trans-1",
+ "commentary_of": "root-123",
+ "commentaries": []
+ }
+ parent_data = {
+ "id": "root-123",
+ "commentaries": ["comm-1"]
+ }
+ mock_fetch_text.side_effect = [related_data, parent_data]
+ mock_fetch_commentaries.return_value = [
+ {"id": "comm-1", "title": {"en": "Commentary"}, "language": "bo"}
+ ]
+
+ result = await _fetch_commentaries_from_related("trans-1", 0, 10)
+
+ assert len(result) == 1
+ assert result[0].id == "comm-1"
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_commentary_details')
+ async def test_fetch_commentaries_from_related_direct(self, mock_fetch_commentaries, mock_fetch_text):
+ """When related text has no commentary_of, fetch its own commentaries."""
+ related_data = {
+ "id": "trans-1",
+ "commentary_of": None,
+ "commentaries": ["comm-1", "comm-2"]
+ }
+ mock_fetch_text.return_value = related_data
+ mock_fetch_commentaries.return_value = [
+ {"id": "comm-1", "title": {"en": "Commentary 1"}, "language": "bo"},
+ {"id": "comm-2", "title": {"en": "Commentary 2"}, "language": "bo"}
+ ]
+
+ result = await _fetch_commentaries_from_related("trans-1", 0, 10)
+
+ assert len(result) == 2
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ async def test_fetch_commentaries_from_related_not_found(self, mock_fetch_text):
+ mock_fetch_text.return_value = None
+
+ result = await _fetch_commentaries_from_related("missing", 0, 10)
+
+ assert len(result) == 0
+
+ @pytest.mark.asyncio
+ @patch('pecha_api.texts.texts_openpecha_service.fetch_text_by_id')
+ async def test_fetch_commentaries_from_related_error(self, mock_fetch_text):
+ mock_fetch_text.side_effect = Exception("Connection error")
+
+ result = await _fetch_commentaries_from_related("trans-1", 0, 10)
+
+ assert len(result) == 0
+
class TestFetchCommentaryDetails:
"""Tests for fetch_commentary_details function."""
From 6b49dcc016f93c17ff7d8f85fd3c383ffeb78c5a Mon Sep 17 00:00:00 2001
From: TenzDelek
Date: Thu, 4 Jun 2026 15:30:26 +0530
Subject: [PATCH 25/29] changes
---
.../segments/openpecha_segment_service.py | 3 ++
.../segments/segments_openpecha_service.py | 39 +++++--------------
2 files changed, 13 insertions(+), 29 deletions(-)
diff --git a/openpecha_api/segments/openpecha_segment_service.py b/openpecha_api/segments/openpecha_segment_service.py
index edea57bd4..a0ae16704 100644
--- a/openpecha_api/segments/openpecha_segment_service.py
+++ b/openpecha_api/segments/openpecha_segment_service.py
@@ -6,12 +6,15 @@ async def fetch_related_segments(
segment_id: str,
limit: int = 10,
offset: int = 0,
+ text_id: Optional[str] = None,
) -> Dict[str, Any]:
params: Dict[str, Any] = {
"limit": limit,
"offset": offset,
}
+ if text_id:
+ params["text_id"] = text_id
client = get_authenticated_open_pecha_client()
http_client = client.get_async_httpx_client()
response = await http_client.get(
diff --git a/pecha_api/texts/segments/segments_openpecha_service.py b/pecha_api/texts/segments/segments_openpecha_service.py
index 1d5e9684b..ad6094107 100644
--- a/pecha_api/texts/segments/segments_openpecha_service.py
+++ b/pecha_api/texts/segments/segments_openpecha_service.py
@@ -32,10 +32,8 @@
TRANSLATION = "translation"
COMMENTARY = "commentary"
-RELATED_SEGMENTS_MAX_PAGE_SIZE = 100
MAX_PAGINATION_SKIP = 10000
MAX_PAGINATION_LIMIT = 100
-MAX_FETCH_ITERATIONS = 100
def _classify_text(text_payload: Dict[str, Any]) -> Optional[str]:
@@ -158,35 +156,18 @@ async def _fetch_matching_related_segments_by_text_id(
skip: int,
limit: int,
) -> Tuple[List[Dict[str, Any]], bool]:
- # Sanitize user-controlled inputs to prevent unbounded loops
safe_skip = max(0, min(skip, MAX_PAGINATION_SKIP))
safe_limit = max(1, min(limit, MAX_PAGINATION_LIMIT))
-
- matching_items: List[Dict[str, Any]] = []
- upstream_offset = 0
- upstream_has_more = True
- target_count = safe_skip + safe_limit + 1
- iterations = 0
-
- while upstream_has_more and len(matching_items) < target_count and iterations < MAX_FETCH_ITERATIONS:
- iterations += 1
- related_page = await fetch_related_segments(
- segment_id=segment_id,
- limit=RELATED_SEGMENTS_MAX_PAGE_SIZE,
- offset=upstream_offset,
- )
- items: List[Dict[str, Any]] = related_page.get("items", []) or []
- matching_items.extend(
- item for item in items if item.get("text_id") == text_id
- )
- upstream_has_more = bool(related_page.get("has_more", False))
- if not items:
- break
- upstream_offset += len(items)
-
- paginated_items = matching_items[safe_skip : safe_skip + safe_limit]
- has_more = len(matching_items) > safe_skip + safe_limit
- return paginated_items, has_more
+
+ related_page = await fetch_related_segments(
+ segment_id=segment_id,
+ limit=safe_limit,
+ offset=safe_skip,
+ text_id=text_id,
+ )
+ items: List[Dict[str, Any]] = related_page.get("items", []) or []
+ has_more = bool(related_page.get("has_more", False))
+ return items, has_more
async def get_root_text_by_segment_id_from_openpecha(
From a1cb31484b6e6b6628accee9c94db18938ef4bef Mon Sep 17 00:00:00 2001
From: Lungsangg
Date: Thu, 4 Jun 2026 22:32:19 +0530
Subject: [PATCH 26/29] text_id_requirement
---
pecha_api/texts/segments/segments_openpecha_views.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pecha_api/texts/segments/segments_openpecha_views.py b/pecha_api/texts/segments/segments_openpecha_views.py
index 1ee40cea4..ffbc9ca4f 100644
--- a/pecha_api/texts/segments/segments_openpecha_views.py
+++ b/pecha_api/texts/segments/segments_openpecha_views.py
@@ -37,8 +37,8 @@ async def get_segment_v2(
summary="Get Root Text for Segment",
)
async def get_root_text_for_segment_v2(
- text_id: str,
segment_id: str,
+ text_id: str = Query(...),
skip: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 10,
) -> V2SegmentRootTextResponse:
From fd02091af8b873e3f3561d47ca26e72988aac24a Mon Sep 17 00:00:00 2001
From: tenkus47
Date: Tue, 9 Jun 2026 11:12:08 +0530
Subject: [PATCH 27/29] feat(texts): add search_text_content function to enable
content-based text searches
---
openpecha_api/text/openpecha_text_service.py | 26 ++++++++++++++++++++
pecha_api/texts/texts_openpecha_service.py | 18 +++++++++++++-
pecha_api/texts/texts_openpecha_views.py | 2 ++
3 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/openpecha_api/text/openpecha_text_service.py b/openpecha_api/text/openpecha_text_service.py
index 7d3b28e67..a134830fb 100644
--- a/openpecha_api/text/openpecha_text_service.py
+++ b/openpecha_api/text/openpecha_text_service.py
@@ -37,3 +37,29 @@ async def fetch_text_by_id(text_id: str) -> Optional[Dict[str, Any]]:
response = await http_client.get(f"/v2/texts/{text_id}")
response.raise_for_status()
return response.json()
+
+
+async def search_by_content(
+ query: str,
+ search_type: Optional[str] = None,
+ limit: Optional[int] = 10,
+ text_id: Optional[str] = None,
+ edition_id: Optional[str] = None,
+) -> Dict[str, Any]:
+
+ params: Dict[str, Any] = {
+ "query": query,
+ "limit": limit,
+ }
+ if search_type:
+ params["search_type"] = search_type
+ if text_id:
+ params["text_id"] = text_id
+ if edition_id:
+ params["edition_id"] = edition_id
+
+ client = get_authenticated_open_pecha_client()
+ http_client = client.get_async_httpx_client()
+ response = await http_client.get("/v2/content-search", params=params)
+ response.raise_for_status()
+ return response.json()
diff --git a/pecha_api/texts/texts_openpecha_service.py b/pecha_api/texts/texts_openpecha_service.py
index 6594b0801..ac92f1d02 100644
--- a/pecha_api/texts/texts_openpecha_service.py
+++ b/pecha_api/texts/texts_openpecha_service.py
@@ -12,7 +12,7 @@
V2TextsCategoryResponse,
)
from pecha_api.collections.collections_response_models import V2CollectionModel
-from openpecha_api.text.openpecha_text_service import fetch_texts_by_category, fetch_text_by_id
+from openpecha_api.text.openpecha_text_service import fetch_texts_by_category, fetch_text_by_id, search_by_content
from openpecha_api.collection.openpecha_collection_service import fetch_category_by_id
from pecha_api.texts.texts_openpecha_api import fetch_critical_editions, fetch_text_detail, fetch_editions_segmentation, fetch_segmentation_segments, fetch_edition_content
from pecha_api.texts.text_openpecha_response_models import (
@@ -576,3 +576,19 @@ async def _fetch_commentaries_from_related(
]
return commentaries[skip:skip + limit]
+
+
+
+
+CONTENT_SEARCH_URL = "http://13.250.189.160/v2/content-search"
+
+
+async def search_text_content(
+ query: str,
+ search_type: Optional[str] = None,
+ limit: Optional[int] = 10,
+ text_id: Optional[str] = None,
+ edition_id: Optional[str] = None,
+) -> Dict[str, Any]:
+
+ return await search_by_content(query, search_type, limit, text_id, edition_id)
\ No newline at end of file
diff --git a/pecha_api/texts/texts_openpecha_views.py b/pecha_api/texts/texts_openpecha_views.py
index 5b02d0932..e7961f5d0 100644
--- a/pecha_api/texts/texts_openpecha_views.py
+++ b/pecha_api/texts/texts_openpecha_views.py
@@ -89,3 +89,5 @@ async def get_text_commentaries(
skip=skip,
limit=limit
)
+
+
From cc3cf5d5272090153917a6c6c796920d525b0408 Mon Sep 17 00:00:00 2001
From: tenkus47
Date: Tue, 9 Jun 2026 11:41:08 +0530
Subject: [PATCH 28/29] feat(search): add V2ContentSearchType enum and include
new search router
---
pecha_api/app.py | 2 +
pecha_api/config.py | 4 +-
pecha_api/search/search_enums.py | 4 +
pecha_api/search/search_openpecha_service.py | 202 +++++++++++++++++++
pecha_api/search/search_openpecha_views.py | 32 +++
5 files changed, 242 insertions(+), 2 deletions(-)
create mode 100644 pecha_api/search/search_openpecha_service.py
create mode 100644 pecha_api/search/search_openpecha_views.py
diff --git a/pecha_api/app.py b/pecha_api/app.py
index 48b9616e4..956a8b968 100644
--- a/pecha_api/app.py
+++ b/pecha_api/app.py
@@ -12,6 +12,7 @@
from pecha_api.texts.groups import groups_views
from pecha_api.share import share_views
from pecha_api.search import search_views
+from pecha_api.search import search_openpecha_views
from pecha_api.plans.auth import plan_auth_views
from pecha_api.plans.cms import cms_plans_views as cms_plans_views
from pecha_api.plans.series import series_view as cms_series_views
@@ -52,6 +53,7 @@
api.include_router(topics_views.topics_router)
api.include_router(users_views.user_router)
api.include_router(search_views.search_router)
+api.include_router(search_openpecha_views.search_v2_router)
api.include_router(share_views.share_router)
api.include_router(plan_auth_views.plan_auth_router)
api.include_router(cms_plans_views.cms_plans_router)
diff --git a/pecha_api/config.py b/pecha_api/config.py
index 95bc8beaa..19cb2f561 100644
--- a/pecha_api/config.py
+++ b/pecha_api/config.py
@@ -75,8 +75,8 @@
#pecha api configuration
EXTERNAL_PECHA_API_URL="",
- EXTERNAL_DEV_PECHA_API_URL="",
- EXTERNAL_OPENPECHA_API_KEY="",
+ EXTERNAL_DEV_PECHA_API_URL="http://13.250.189.160/",
+ EXTERNAL_OPENPECHA_API_KEY="http://13.250.189.160/",
EXTERNAL_PECHA_APP_NAME="webuddhist",
EXTERNAL_TITLE_SEARCH_API_URL="",
diff --git a/pecha_api/search/search_enums.py b/pecha_api/search/search_enums.py
index 0d2ba5fbe..d22f22686 100644
--- a/pecha_api/search/search_enums.py
+++ b/pecha_api/search/search_enums.py
@@ -16,3 +16,7 @@ class MultilingualSearchType(Enum):
BM25 = "bm25" # Keyword-based ranking algorithm
SEMANTIC = "semantic" # Vector similarity search
EXACT = "exact" # Exact text matching
+
+class V2ContentSearchType(Enum):
+ EXACT = "exact"
+ SIMILAR = "similar"
diff --git a/pecha_api/search/search_openpecha_service.py b/pecha_api/search/search_openpecha_service.py
new file mode 100644
index 000000000..9f66f8d00
--- /dev/null
+++ b/pecha_api/search/search_openpecha_service.py
@@ -0,0 +1,202 @@
+import logging
+from typing import Any, Dict, List, Optional
+
+from openpecha_api.text.openpecha_text_service import fetch_text_by_id, search_by_content
+
+from pecha_api.texts.segments.segments_models import Segment
+from .search_response_models import (
+ MultilingualSearchResponse,
+ MultilingualSegmentMatch,
+ MultilingualSourceResult,
+ TextIndex,
+)
+from .search_service import (
+ apply_pagination_to_sources,
+ create_empty_search_response,
+ fetch_segments_by_ids,
+ fetch_text_info,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _extract_title(title_payload: Any, language: Optional[str] = None) -> str:
+ if isinstance(title_payload, dict):
+ if language and language in title_payload:
+ return title_payload[language]
+ for value in title_payload.values():
+ if isinstance(value, str) and value.strip():
+ return value
+ return ""
+ if isinstance(title_payload, str):
+ return title_payload.strip()
+ return ""
+
+
+def _flatten_content_search_matches(
+ content_search_results: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ matches: List[Dict[str, Any]] = []
+ best_scores: Dict[str, float] = {}
+
+ for result in content_search_results:
+ relevance_score = -result.get("score", 0.0)
+ context = result.get("context", "")
+ text_id = result.get("text_id", "")
+
+ for pecha_segment_id in result.get("segment_ids", []):
+ if not pecha_segment_id:
+ continue
+
+ existing_score = best_scores.get(pecha_segment_id)
+ if existing_score is not None and relevance_score >= existing_score:
+ continue
+
+ best_scores[pecha_segment_id] = relevance_score
+ matches.append(
+ {
+ "text_id": text_id,
+ "pecha_segment_id": pecha_segment_id,
+ "content": context,
+ "relevance_score": relevance_score,
+ }
+ )
+
+ matches.sort(key=lambda match: match["relevance_score"])
+ return matches
+
+
+async def _fetch_text_info_map(text_ids: List[str]) -> Dict[str, TextIndex]:
+ text_info_map = await fetch_text_info(text_ids)
+
+ for text_id in text_ids:
+ if text_id in text_info_map:
+ continue
+
+ try:
+ data = await fetch_text_by_id(text_id)
+ except Exception:
+ logger.warning("Failed to fetch text %s from OpenPecha", text_id, exc_info=True)
+ continue
+
+ if not data:
+ continue
+
+ language = data.get("language") or ""
+ text_info_map[text_id] = TextIndex(
+ text_id=text_id,
+ language=language,
+ title=_extract_title(data.get("title", {}), language),
+ published_date=str(data.get("date") or ""),
+ )
+
+ return text_info_map
+
+
+async def _build_sources_from_content_search_matches(
+ matches: List[Dict[str, Any]],
+ local_segments_by_pecha: Dict[str, Segment],
+) -> List[MultilingualSourceResult]:
+ text_to_matches: Dict[str, List[MultilingualSegmentMatch]] = {}
+ text_ids: set[str] = set()
+
+ for match in matches:
+ pecha_segment_id = match["pecha_segment_id"]
+ local_segment = local_segments_by_pecha.get(pecha_segment_id)
+ text_id = local_segment.text_id if local_segment else match["text_id"]
+
+ if not text_id:
+ continue
+
+ text_ids.add(text_id)
+ text_to_matches.setdefault(text_id, []).append(
+ MultilingualSegmentMatch(
+ segment_id=str(local_segment.id) if local_segment else pecha_segment_id,
+ content=local_segment.content if local_segment else match["content"],
+ relevance_score=match["relevance_score"],
+ pecha_segment_id=pecha_segment_id,
+ )
+ )
+
+ if not text_ids:
+ return []
+
+ text_info_map = await _fetch_text_info_map(list(text_ids))
+ sources: List[MultilingualSourceResult] = []
+
+ for text_id, segment_matches in text_to_matches.items():
+ if text_id not in text_info_map:
+ continue
+
+ segment_matches.sort(key=lambda item: item.relevance_score)
+ sources.append(
+ MultilingualSourceResult(
+ text=text_info_map[text_id],
+ segment_matches=segment_matches,
+ )
+ )
+
+ return sources
+
+
+async def get_v2_multilingual_search_results(
+ query: str,
+ search_type: str = "similar",
+ text_id: Optional[str] = None,
+ edition_id: Optional[str] = None,
+ skip: int = 0,
+ limit: int = 10,
+) -> MultilingualSearchResponse:
+ try:
+ external_limit = min(limit * 5, 100)
+
+ external_data = await search_by_content(
+ query=query,
+ search_type=search_type,
+ limit=external_limit,
+ text_id=text_id,
+ edition_id=edition_id,
+ )
+
+ if not isinstance(external_data, list):
+ logger.warning(
+ "Unexpected OpenPecha content search response type: %s",
+ type(external_data).__name__,
+ )
+ return create_empty_search_response(query, search_type, skip, limit)
+
+ matches = _flatten_content_search_matches(external_data)
+ if not matches:
+ logger.info("No matches returned from OpenPecha content search")
+ return create_empty_search_response(query, search_type, skip, limit)
+
+ pecha_segment_ids = [match["pecha_segment_id"] for match in matches]
+ local_segments = await fetch_segments_by_ids(pecha_segment_ids, text_id)
+ local_segments_by_pecha = {
+ segment.pecha_segment_id: segment
+ for segment in local_segments
+ if segment.pecha_segment_id
+ }
+
+ sources = await _build_sources_from_content_search_matches(
+ matches,
+ local_segments_by_pecha,
+ )
+
+ if not sources:
+ return create_empty_search_response(query, search_type, skip, limit)
+
+ paginated_sources = apply_pagination_to_sources(sources, skip, limit)
+
+ return MultilingualSearchResponse(
+ query=query,
+ search_type=search_type,
+ sources=paginated_sources,
+ skip=skip,
+ limit=limit,
+ total=limit,
+ )
+
+ except Exception:
+ logger.exception("Error in v2 multilingual search")
+ raise
diff --git a/pecha_api/search/search_openpecha_views.py b/pecha_api/search/search_openpecha_views.py
new file mode 100644
index 000000000..f2a19d3fd
--- /dev/null
+++ b/pecha_api/search/search_openpecha_views.py
@@ -0,0 +1,32 @@
+from typing import Optional
+
+from fastapi import APIRouter, Query
+from starlette import status
+
+from .search_enums import V2ContentSearchType
+from .search_openpecha_service import get_v2_multilingual_search_results
+from .search_response_models import MultilingualSearchResponse
+
+search_v2_router = APIRouter(
+ prefix="/v2/search",
+ tags=["search-v2"],
+)
+
+
+@search_v2_router.get("/multilingual", status_code=status.HTTP_200_OK)
+async def multilingual_search_v2(
+ query: str = Query(...),
+ search_type: V2ContentSearchType = Query(default=V2ContentSearchType.SIMILAR),
+ text_id: Optional[str] = Query(default=None),
+ edition_id: Optional[str] = Query(default=None),
+ skip: int = Query(default=0, ge=0),
+ limit: int = Query(default=10, ge=1, le=100),
+) -> MultilingualSearchResponse:
+ return await get_v2_multilingual_search_results(
+ query=query,
+ search_type=search_type.value,
+ text_id=text_id,
+ edition_id=edition_id,
+ skip=skip,
+ limit=limit,
+ )
From e158d3aa8ab4c86755273e971a63d35a63ab8611 Mon Sep 17 00:00:00 2001
From: tenkus47
Date: Tue, 9 Jun 2026 12:49:08 +0530
Subject: [PATCH 29/29] refactor(search): update search router prefix and
change search type enum
---
pecha_api/app.py | 2 -
pecha_api/search/search_enums.py | 6 -
pecha_api/search/search_openpecha_service.py | 4 +-
pecha_api/search/search_openpecha_views.py | 32 -
pecha_api/search/search_service.py | 184 -----
pecha_api/search/search_views.py | 31 +-
tests/search/test_search_service.py | 668 +------------------
tests/search/test_search_views.py | 69 +-
8 files changed, 34 insertions(+), 962 deletions(-)
delete mode 100644 pecha_api/search/search_openpecha_views.py
diff --git a/pecha_api/app.py b/pecha_api/app.py
index 956a8b968..48b9616e4 100644
--- a/pecha_api/app.py
+++ b/pecha_api/app.py
@@ -12,7 +12,6 @@
from pecha_api.texts.groups import groups_views
from pecha_api.share import share_views
from pecha_api.search import search_views
-from pecha_api.search import search_openpecha_views
from pecha_api.plans.auth import plan_auth_views
from pecha_api.plans.cms import cms_plans_views as cms_plans_views
from pecha_api.plans.series import series_view as cms_series_views
@@ -53,7 +52,6 @@
api.include_router(topics_views.topics_router)
api.include_router(users_views.user_router)
api.include_router(search_views.search_router)
-api.include_router(search_openpecha_views.search_v2_router)
api.include_router(share_views.share_router)
api.include_router(plan_auth_views.plan_auth_router)
api.include_router(cms_plans_views.cms_plans_router)
diff --git a/pecha_api/search/search_enums.py b/pecha_api/search/search_enums.py
index d22f22686..7e59ab93f 100644
--- a/pecha_api/search/search_enums.py
+++ b/pecha_api/search/search_enums.py
@@ -12,11 +12,5 @@ class QueryType(Enum):
BOOL = "bool" # Boolean query with exact matching
class MultilingualSearchType(Enum):
- HYBRID = "hybrid" # Combined semantic + keyword search
- BM25 = "bm25" # Keyword-based ranking algorithm
- SEMANTIC = "semantic" # Vector similarity search
- EXACT = "exact" # Exact text matching
-
-class V2ContentSearchType(Enum):
EXACT = "exact"
SIMILAR = "similar"
diff --git a/pecha_api/search/search_openpecha_service.py b/pecha_api/search/search_openpecha_service.py
index 9f66f8d00..b2cd88cb0 100644
--- a/pecha_api/search/search_openpecha_service.py
+++ b/pecha_api/search/search_openpecha_service.py
@@ -139,7 +139,7 @@ async def _build_sources_from_content_search_matches(
return sources
-async def get_v2_multilingual_search_results(
+async def get_multilingual_search_results(
query: str,
search_type: str = "similar",
text_id: Optional[str] = None,
@@ -198,5 +198,5 @@ async def get_v2_multilingual_search_results(
)
except Exception:
- logger.exception("Error in v2 multilingual search")
+ logger.exception("Error in multilingual search")
raise
diff --git a/pecha_api/search/search_openpecha_views.py b/pecha_api/search/search_openpecha_views.py
deleted file mode 100644
index f2a19d3fd..000000000
--- a/pecha_api/search/search_openpecha_views.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from typing import Optional
-
-from fastapi import APIRouter, Query
-from starlette import status
-
-from .search_enums import V2ContentSearchType
-from .search_openpecha_service import get_v2_multilingual_search_results
-from .search_response_models import MultilingualSearchResponse
-
-search_v2_router = APIRouter(
- prefix="/v2/search",
- tags=["search-v2"],
-)
-
-
-@search_v2_router.get("/multilingual", status_code=status.HTTP_200_OK)
-async def multilingual_search_v2(
- query: str = Query(...),
- search_type: V2ContentSearchType = Query(default=V2ContentSearchType.SIMILAR),
- text_id: Optional[str] = Query(default=None),
- edition_id: Optional[str] = Query(default=None),
- skip: int = Query(default=0, ge=0),
- limit: int = Query(default=10, ge=1, le=100),
-) -> MultilingualSearchResponse:
- return await get_v2_multilingual_search_results(
- query=query,
- search_type=search_type.value,
- text_id=text_id,
- edition_id=edition_id,
- skip=skip,
- limit=limit,
- )
diff --git a/pecha_api/search/search_service.py b/pecha_api/search/search_service.py
index 87c4285ce..b380125ed 100644
--- a/pecha_api/search/search_service.py
+++ b/pecha_api/search/search_service.py
@@ -2,7 +2,6 @@
from fastapi import HTTPException
from starlette import status
-from pecha_api.plans.response_message import NO_SEGMENTATION_IDS_RETURNED
from .search_enums import SearchType
from .search_client import search_client
from pecha_api.config import get
@@ -10,8 +9,6 @@
from pecha_api.texts.segments.segments_models import Segment
from pecha_api.texts.texts_models import Text
-from pecha_api.http_message_utils import handle_http_status_error, handle_request_error
-import httpx
import logging
from .search_response_models import (
SearchResponse,
@@ -20,8 +17,6 @@
SourceResultItem,
Search,
SheetResultItem,
- ExternalSearchResponse,
- MultilingualSegmentMatch,
MultilingualSourceResult,
MultilingualSearchResponse,
SegmentLinkResponse,
@@ -182,43 +177,6 @@ def _sheet_search(query: str, skip: int, limit: int) -> SearchResponse:
-def build_search_filter(title: Optional[str] = None) -> Dict[str, List[str]]:
- filter_obj = {}
-
- if title:
- filter_obj["title"] = [title]
-
- return filter_obj
-
-
-def build_search_payload(
- query: str,
- search_type: str,
- limit: int,
- title: Optional[str] = None
-) -> Dict:
- payload = {
- "query": query,
- "search_type": search_type,
- "limit": limit,
- "return_text": False,
- }
-
- if title:
- payload["filter"] = build_search_filter(title)
-
- return payload
-
-
-def group_segments_by_text(segments: List[Segment]) -> Dict[str, List[Segment]]:
- text_segments_map: Dict[str, List[Segment]] = {}
- for segment in segments:
- if segment.text_id not in text_segments_map:
- text_segments_map[segment.text_id] = []
- text_segments_map[segment.text_id].append(segment)
- return text_segments_map
-
-
async def fetch_text_info(text_ids: List[str]) -> Dict[str, TextIndex]:
text_info_map: Dict[str, TextIndex] = {}
for text_id in text_ids:
@@ -233,49 +191,6 @@ async def fetch_text_info(text_ids: List[str]) -> Dict[str, TextIndex]:
return text_info_map
-def build_segment_matches(segments: List[Segment], results_map: Dict[str, Dict]) -> List[MultilingualSegmentMatch]:
- segment_matches = []
- for segment in segments:
- pecha_id = segment.pecha_segment_id
- if pecha_id and pecha_id in results_map:
- segment_matches.append(
- MultilingualSegmentMatch(
- segment_id=str(segment.id),
- content=segment.content,
- relevance_score=results_map[pecha_id]["distance"],
- pecha_segment_id=pecha_id
- )
- )
- segment_matches.sort(key=lambda x: x.relevance_score)
- return segment_matches
-
-
-async def get_text_title_by_id(text_id: Optional[str]) -> Optional[str]:
- if not text_id:
- return None
-
- text = await Text.get_text(text_id)
- if text:
- return text.title
-
- return None
-
-
-def build_results_map(external_results: ExternalSearchResponse) -> tuple[List[str], Dict[str, Dict]]:
- segmentation_ids = []
- results_map = {}
-
- for result in external_results.results:
- if result.id:
- segmentation_ids.append(result.id)
- results_map[result.id] = {
- "distance": result.distance,
- "content": result.entity.text if result.entity.text else ""
- }
-
- return segmentation_ids, results_map
-
-
def create_empty_search_response(
query: str,
search_type: str,
@@ -337,105 +252,6 @@ async def fetch_segments_by_ids(
return segments
-async def get_multilingual_search_results(
- query: str,
- search_type: str = "hybrid",
- text_id: Optional[str] = None,
- skip: int = 0,
- limit: int = 10
-) -> MultilingualSearchResponse:
- try:
- title = await get_text_title_by_id(text_id)
-
- external_limit = min(limit * 5, 100)
-
- external_results = await call_external_search_api(
- query=query,
- search_type=search_type,
- limit=external_limit,
- title=title
- )
-
- segmentation_ids, results_map = build_results_map(external_results)
-
- if not segmentation_ids:
- logger.info(NO_SEGMENTATION_IDS_RETURNED)
- return create_empty_search_response(query, search_type, skip, limit)
-
- segments = await fetch_segments_by_ids(segmentation_ids, text_id)
-
- if not segments:
- return create_empty_search_response(query, search_type, skip, limit)
-
- final_display_sources = await build_multilingual_sources(segments, results_map)
-
- paginated_sources = apply_pagination_to_sources(final_display_sources, skip, limit)
-
- return MultilingualSearchResponse(
- query=query,
- search_type=search_type,
- sources=paginated_sources,
- skip=skip,
- limit=limit,
- total=limit
- )
-
- except Exception as e:
- logger.error(f"Error in multilingual search: {str(e)}", exc_info=True)
- raise
-
-
-async def call_external_search_api(
- query: str,
- search_type: str = "hybrid",
- limit: int = 10,
- title: Optional[str] = None
-) -> ExternalSearchResponse:
-
- external_api_url = "https://search.buddhistai.tools"
- endpoint = f"{external_api_url}/search"
-
- payload = build_search_payload(query, search_type, limit, title)
- print(payload)
- try:
- async with httpx.AsyncClient(timeout=30.0) as client:
- response = await client.post(
- endpoint,
- json=payload,
- headers={"Content-Type": "application/json"}
- )
- response.raise_for_status()
-
- data = response.json()
- return ExternalSearchResponse(**data)
-
- except httpx.HTTPStatusError as e:
- handle_http_status_error(e)
- except httpx.RequestError as e:
- handle_request_error(e)
-
-
-
-async def build_multilingual_sources(segments: List[Segment], results_map: Dict[str, Dict]) -> List[MultilingualSourceResult]:
- text_segments_map = group_segments_by_text(segments)
- text_info_map = await fetch_text_info(list(text_segments_map.keys()))
-
- sources = []
- for text_id, text_segments in text_segments_map.items():
- if text_id not in text_info_map:
- continue
-
- segment_matches = build_segment_matches(text_segments, results_map)
-
- sources.append(
- MultilingualSourceResult(
- text=text_info_map[text_id],
- segment_matches=segment_matches
- )
- )
-
- return sources
-
async def get_url_link(pecha_segment_id: str) -> SegmentLinkResponse:
try:
segment = await Segment.get_segment_by_pecha_segment_id(pecha_segment_id=pecha_segment_id)
diff --git a/pecha_api/search/search_views.py b/pecha_api/search/search_views.py
index 67dabbe5a..c98e77568 100644
--- a/pecha_api/search/search_views.py
+++ b/pecha_api/search/search_views.py
@@ -1,15 +1,14 @@
+from typing import Optional
+
from fastapi import APIRouter, Query
-from .search_enums import SearchType, MultilingualSearchType
from starlette import status
-from typing import Optional
-
+from .search_enums import SearchType, MultilingualSearchType
+from .search_openpecha_service import get_multilingual_search_results
from .search_service import (
get_search_results,
- get_multilingual_search_results,
- get_url_link as get_url_link_service
+ get_url_link as get_url_link_service,
)
-
from .search_response_models import (
SearchResponse,
MultilingualSearchResponse,
@@ -18,42 +17,46 @@
search_router = APIRouter(
prefix="/search",
- tags=["Search"]
+ tags=["Search"],
)
+
@search_router.get("", status_code=status.HTTP_200_OK)
async def search(
query: str = Query(default=None, description="Search query"),
search_type: SearchType = Query(default=None, description="Search type (SOURCE / SHEET)"),
text_id: Optional[str] = Query(default=None, description="Text ID where the search is to be performed"),
skip: int = Query(default=0),
- limit: int = Query(default=10)
+ limit: int = Query(default=10),
) -> SearchResponse:
return await get_search_results(
query=query,
search_type=search_type,
text_id=text_id,
skip=skip,
- limit=limit
+ limit=limit,
)
+
@search_router.get("/multilingual", status_code=status.HTTP_200_OK)
async def multilingual_search(
query: str = Query(...),
- search_type: MultilingualSearchType = Query(default=MultilingualSearchType.HYBRID),
+ search_type: MultilingualSearchType = Query(default=MultilingualSearchType.SIMILAR),
text_id: Optional[str] = Query(default=None),
+ edition_id: Optional[str] = Query(default=None),
skip: int = Query(default=0, ge=0),
- limit: int = Query(default=10, ge=1, le=100)
+ limit: int = Query(default=10, ge=1, le=100),
) -> MultilingualSearchResponse:
-
return await get_multilingual_search_results(
query=query,
search_type=search_type.value,
text_id=text_id,
+ edition_id=edition_id,
skip=skip,
- limit=limit
+ limit=limit,
)
+
@search_router.get("/chat/{pecha_segment_id}", status_code=status.HTTP_200_OK)
async def get_url_link(pecha_segment_id: str) -> SegmentLinkResponse:
- return await get_url_link_service(pecha_segment_id)
\ No newline at end of file
+ return await get_url_link_service(pecha_segment_id)
diff --git a/tests/search/test_search_service.py b/tests/search/test_search_service.py
index 7353d70bb..02b971f28 100644
--- a/tests/search/test_search_service.py
+++ b/tests/search/test_search_service.py
@@ -2,7 +2,6 @@
from unittest.mock import patch, AsyncMock, Mock, MagicMock
from uuid import uuid4
from fastapi import HTTPException
-import httpx
from pecha_api.search.search_response_models import (
SearchResponse,
@@ -11,19 +10,12 @@
TextIndex,
SegmentMatch,
SearchType,
- MultilingualSearchResponse,
- MultilingualSourceResult,
MultilingualSegmentMatch,
- ExternalSearchResponse,
- ExternalSearchResult,
- ExternalSegmentEntity
+ MultilingualSourceResult,
)
from pecha_api.search.search_service import (
get_search_results,
- get_multilingual_search_results,
- call_external_search_api,
- build_multilingual_sources,
- apply_pagination_to_sources
+ apply_pagination_to_sources,
)
@pytest.mark.asyncio
@@ -191,569 +183,6 @@ def _get_mock_elastic_source_within_text_response_():
}
}
-@pytest.mark.asyncio
-async def test_get_multilingual_search_results_success():
- """Test successful multilingual search with all components"""
-
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="hybrid",
- results=[
- ExternalSearchResult(
- id="pecha_seg_1",
- distance=0.9,
- entity=ExternalSegmentEntity(text="Content 1")
- ),
- ExternalSearchResult(
- id="pecha_seg_2",
- distance=0.8,
- entity=ExternalSegmentEntity(text="Content 2")
- )
- ],
- count=2
- )
-
- mock_segment_1 = Mock()
- mock_segment_1.id = uuid4()
- mock_segment_1.content = "Content 1"
- mock_segment_1.text_id = "text_123"
- mock_segment_1.pecha_segment_id = "pecha_seg_1"
-
- mock_segment_2 = Mock()
- mock_segment_2.id = uuid4()
- mock_segment_2.content = "Content 2"
- mock_segment_2.text_id = "text_123"
- mock_segment_2.pecha_segment_id = "pecha_seg_2"
-
- mock_segments = [mock_segment_1, mock_segment_2]
-
- mock_text = Mock()
- mock_text.title = "Test Text"
- mock_text.language = "bo"
- mock_text.created_at = "2024-01-01"
-
- with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \
- patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=mock_segments), \
- patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text):
-
- response = await get_multilingual_search_results(
- query="test query",
- search_type="hybrid",
- skip=0,
- limit=10
- )
-
- assert response is not None
- assert isinstance(response, MultilingualSearchResponse)
- assert response.query == "test query"
- assert response.search_type == "hybrid"
- assert len(response.sources) == 1
- assert response.sources[0].text.text_id == "text_123"
- assert len(response.sources[0].segment_matches) == 2
- assert response.total == 10
-
-
-@pytest.mark.asyncio
-async def test_get_multilingual_search_results_with_text_id():
- """Test multilingual search with specific text_id"""
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="hybrid",
- results=[
- ExternalSearchResult(
- id="pecha_seg_1",
- distance=0.9,
- entity=ExternalSegmentEntity(text="Content 1")
- )
- ],
- count=1
- )
-
- mock_segment = Mock()
- mock_segment.id = uuid4()
- mock_segment.content = "Content 1"
- mock_segment.text_id = "specific_text_123"
- mock_segment.pecha_segment_id = "pecha_seg_1"
-
- mock_text = Mock()
- mock_text.title = "Specific Text"
- mock_text.language = "en"
- mock_text.created_at = "2024-01-01"
-
- with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text), \
- patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \
- patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=[mock_segment]):
-
- response = await get_multilingual_search_results(
- query="test query",
- text_id="specific_text_123",
- skip=0,
- limit=10
- )
-
- assert response is not None
- assert len(response.sources) == 1
- assert response.sources[0].text.text_id == "specific_text_123"
-
-
-@pytest.mark.asyncio
-async def test_get_multilingual_search_results_no_external_results():
- """Test multilingual search when external API returns no results"""
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="hybrid",
- results=[],
- count=0
- )
-
- with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response):
-
- response = await get_multilingual_search_results(
- query="test query",
- search_type="hybrid",
- skip=0,
- limit=10
- )
-
- assert response is not None
- assert response.sources == []
- assert response.total == 0
-
-
-@pytest.mark.asyncio
-async def test_get_multilingual_search_results_no_segments_found():
- """Test multilingual search when no internal segments match external IDs"""
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="hybrid",
- results=[
- ExternalSearchResult(
- id="pecha_seg_1",
- distance=0.9,
- entity=ExternalSegmentEntity(text="Content 1")
- )
- ],
- count=1
- )
-
- with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \
- patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=[]):
-
- response = await get_multilingual_search_results(
- query="test query",
- search_type="hybrid",
- skip=0,
- limit=10
- )
-
- assert response is not None
- assert response.sources == []
- assert response.total == 0
-
-
-@pytest.mark.asyncio
-async def test_get_multilingual_search_results_text_not_found():
- """Test multilingual search when text_id doesn't exist"""
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="hybrid",
- results=[
- ExternalSearchResult(
- id="pecha_seg_1",
- distance=0.9,
- entity=ExternalSegmentEntity(text="Content 1")
- )
- ],
- count=1
- )
-
- mock_segment = Mock()
- mock_segment.id = uuid4()
- mock_segment.content = "Content 1"
- mock_segment.text_id = "text_123"
- mock_segment.pecha_segment_id = "pecha_seg_1"
-
- mock_text = Mock()
- mock_text.title = "Test Text"
- mock_text.language = "bo"
- mock_text.created_at = "2024-01-01"
-
- with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, side_effect=[None, mock_text]), \
- patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \
- patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=[mock_segment]):
-
- response = await get_multilingual_search_results(
- query="test query",
- text_id="nonexistent_text",
- skip=0,
- limit=10
- )
-
- assert response is not None
- assert len(response.sources) == 1
-
-
-@pytest.mark.asyncio
-async def test_get_multilingual_search_results_with_text_id():
- """Test multilingual search with text_id parameter"""
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="hybrid",
- results=[],
- count=0
- )
-
- with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response) as mock_call, \
- patch("pecha_api.search.search_service.get_text_title_by_id", new_callable=AsyncMock, return_value="Test Title"):
-
- await get_multilingual_search_results(
- query="test query",
- text_id="text_123",
- skip=0,
- limit=10
- )
-
- mock_call.assert_called_once()
- call_args = mock_call.call_args
- assert call_args.kwargs["title"] == "Test Title"
-
-
-@pytest.mark.asyncio
-async def test_get_multilingual_search_results_different_search_types():
- """Test multilingual search with different search types"""
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="semantic",
- results=[],
- count=0
- )
-
- with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response):
-
- response = await get_multilingual_search_results(
- query="test query",
- search_type="semantic",
- skip=0,
- limit=10
- )
-
- assert response.search_type == "semantic"
-
-
-@pytest.mark.asyncio
-async def test_call_external_search_api_success():
- """Test successful call to external search API"""
- mock_response_data = {
- "query": "test query",
- "search_type": "hybrid",
- "results": [
- {
- "id": "pecha_seg_1",
- "distance": 0.9,
- "entity": {"text": "Content 1"}
- }
- ],
- "count": 1
- }
-
- mock_http_response = Mock()
- mock_http_response.json.return_value = mock_response_data
- mock_http_response.raise_for_status = Mock()
-
- mock_client = AsyncMock()
- mock_client.post = AsyncMock(return_value=mock_http_response)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=None)
-
- with patch("httpx.AsyncClient", return_value=mock_client):
-
- response = await call_external_search_api(
- query="test query",
- search_type="hybrid",
- limit=10
- )
-
- assert response is not None
- assert isinstance(response, ExternalSearchResponse)
- assert response.query == "test query"
- assert len(response.results) == 1
-
-
-@pytest.mark.asyncio
-async def test_call_external_search_api_without_filters():
- """Test external API call without any filters"""
- mock_response_data = {
- "query": "test query",
- "search_type": "hybrid",
- "results": [],
- "count": 0
- }
-
- mock_http_response = Mock()
- mock_http_response.json.return_value = mock_response_data
- mock_http_response.raise_for_status = Mock()
-
- mock_client = AsyncMock()
- mock_client.post = AsyncMock(return_value=mock_http_response)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=None)
-
- with patch("httpx.AsyncClient", return_value=mock_client):
-
- await call_external_search_api(
- query="test query",
- limit=10
- )
-
- call_args = mock_client.post.call_args
- payload = call_args.kwargs["json"]
- assert "filter" not in payload
-
-
-@pytest.mark.asyncio
-async def test_call_external_search_api_with_title():
- """Test external API call with title filter"""
- mock_response_data = {
- "query": "test query",
- "search_type": "hybrid",
- "results": [],
- "count": 0
- }
-
- mock_http_response = Mock()
- mock_http_response.json.return_value = mock_response_data
- mock_http_response.raise_for_status = Mock()
-
- mock_client = AsyncMock()
- mock_client.post = AsyncMock(return_value=mock_http_response)
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=None)
-
- with patch("httpx.AsyncClient", return_value=mock_client):
-
- await call_external_search_api(
- query="test query",
- title="Specific Title",
- limit=10
- )
-
- call_args = mock_client.post.call_args
- payload = call_args.kwargs["json"]
- assert "filter" in payload
- assert "title" in payload["filter"]
- assert payload["filter"]["title"] == ["Specific Title"]
-
-
-@pytest.mark.asyncio
-async def test_call_external_search_api_http_error():
- """Test external API call when HTTP error occurs"""
- mock_http_response = Mock()
- mock_http_response.status_code = 500
- mock_http_response.text = "Internal Server Error"
-
- mock_client = AsyncMock()
- mock_client.post = AsyncMock(side_effect=httpx.HTTPStatusError(
- "Server error",
- request=Mock(),
- response=mock_http_response
- ))
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=None)
-
- with patch("httpx.AsyncClient", return_value=mock_client):
-
- with pytest.raises(HTTPException) as exc_info:
- await call_external_search_api(
- query="test query",
- limit=10
- )
-
- assert exc_info.value.status_code == 500
-
-
-@pytest.mark.asyncio
-async def test_call_external_search_api_request_error():
- """Test external API call when request fails"""
- mock_client = AsyncMock()
- mock_client.post = AsyncMock(side_effect=httpx.RequestError("Connection failed"))
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=None)
-
- with patch("httpx.AsyncClient", return_value=mock_client):
-
- with pytest.raises(HTTPException) as exc_info:
- await call_external_search_api(
- query="test query",
- limit=10
- )
-
- assert exc_info.value.status_code == 500
- assert "Failed to connect" in exc_info.value.detail
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_sources_success():
- """Test building multilingual sources from segments"""
-
- mock_segment_1 = Mock()
- mock_segment_1.id = uuid4()
- mock_segment_1.content = "Content 1"
- mock_segment_1.text_id = "text_123"
- mock_segment_1.pecha_segment_id = "pecha_seg_1"
-
- mock_segment_2 = Mock()
- mock_segment_2.id = uuid4()
- mock_segment_2.content = "Content 2"
- mock_segment_2.text_id = "text_123"
- mock_segment_2.pecha_segment_id = "pecha_seg_2"
-
- segments = [mock_segment_1, mock_segment_2]
-
- results_map = {
- "pecha_seg_1": {"distance": 0.9, "content": "Content 1"},
- "pecha_seg_2": {"distance": 0.8, "content": "Content 2"}
- }
-
- mock_text = Mock()
- mock_text.title = "Test Text"
- mock_text.language = "bo"
- mock_text.created_at = "2024-01-01"
-
- with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text):
-
- final_display_sources = await build_multilingual_sources(segments, results_map)
-
- assert len(final_display_sources) == 1
- assert isinstance(final_display_sources[0], MultilingualSourceResult)
- assert final_display_sources[0].text.text_id == "text_123"
- assert len(final_display_sources[0].segment_matches) == 2
- assert final_display_sources[0].segment_matches[0].relevance_score <= final_display_sources[0].segment_matches[1].relevance_score
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_sources_multiple_texts():
- """Test building sources from segments across multiple texts"""
- mock_segment_1 = Mock()
- mock_segment_1.id = uuid4()
- mock_segment_1.content = "Content 1"
- mock_segment_1.text_id = "text_123"
- mock_segment_1.pecha_segment_id = "pecha_seg_1"
-
- mock_segment_2 = Mock()
- mock_segment_2.id = uuid4()
- mock_segment_2.content = "Content 2"
- mock_segment_2.text_id = "text_456"
- mock_segment_2.pecha_segment_id = "pecha_seg_2"
-
- segments = [mock_segment_1, mock_segment_2]
-
- results_map = {
- "pecha_seg_1": {"distance": 0.9, "content": "Content 1"},
- "pecha_seg_2": {"distance": 0.8, "content": "Content 2"}
- }
-
- mock_text_1 = Mock()
- mock_text_1.title = "Test Text 1"
- mock_text_1.language = "bo"
- mock_text_1.created_at = "2024-01-01"
-
- mock_text_2 = Mock()
- mock_text_2.title = "Test Text 2"
- mock_text_2.language = "en"
- mock_text_2.created_at = "2024-01-02"
-
- with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, side_effect=[mock_text_1, mock_text_2]):
-
- final_display_sources = await build_multilingual_sources(segments, results_map)
-
- assert len(final_display_sources) == 2
- assert final_display_sources[0].text.text_id == "text_123"
- assert final_display_sources[1].text.text_id == "text_456"
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_sources_missing_text():
- """Test building sources when text is not found"""
- mock_segment = Mock()
- mock_segment.id = uuid4()
- mock_segment.content = "Content 1"
- mock_segment.text_id = "text_123"
- mock_segment.pecha_segment_id = "pecha_seg_1"
-
- segments = [mock_segment]
- results_map = {
- "pecha_seg_1": {"distance": 0.9, "content": "Content 1"}
- }
-
- with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=None):
-
- final_display_sources = await build_multilingual_sources(segments, results_map)
-
- assert len(final_display_sources) == 0
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_sources_segment_not_in_results_map():
- """Test building sources when segment pecha_id not in results map"""
- mock_segment = Mock()
- mock_segment.id = uuid4()
- mock_segment.content = "Content 1"
- mock_segment.text_id = "text_123"
- mock_segment.pecha_segment_id = "pecha_seg_unknown"
-
- segments = [mock_segment]
- results_map = {
- "pecha_seg_1": {"distance": 0.9, "content": "Content 1"}
- }
-
- mock_text = Mock()
- mock_text.title = "Test Text"
- mock_text.language = "bo"
- mock_text.created_at = "2024-01-01"
-
- with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text):
-
- final_display_sources = await build_multilingual_sources(segments, results_map)
-
- assert len(final_display_sources) == 1
- assert len(final_display_sources[0].segment_matches) == 0
-
-
-@pytest.mark.asyncio
-async def test_build_multilingual_sources_sorting():
- """Test that segment matches are sorted by relevance score"""
- segments = []
- results_map = {}
-
- for i in range(5):
- mock_segment = Mock()
- mock_segment.id = uuid4()
- mock_segment.content = f"Content {i}"
- mock_segment.text_id = "text_123"
- mock_segment.pecha_segment_id = f"pecha_seg_{i}"
- segments.append(mock_segment)
-
- results_map[f"pecha_seg_{i}"] = {
- "distance": 0.5 + (i * 0.1),
- "content": f"Content {i}"
- }
-
- mock_text = Mock()
- mock_text.title = "Test Text"
- mock_text.language = "bo"
- mock_text.created_at = "2024-01-01"
-
- with patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text):
-
- final_display_sources = await build_multilingual_sources(segments, results_map)
-
- assert len(final_display_sources) == 1
- assert len(final_display_sources[0].segment_matches) == 5
-
- scores = [match.relevance_score for match in final_display_sources[0].segment_matches]
- assert scores == sorted(scores)
-
-
@pytest.mark.asyncio
async def test_get_url_link_success():
"""Test get_url_link service with valid pecha_segment_id"""
@@ -927,99 +356,6 @@ async def test_get_url_link_none_segment_id():
assert result.text_id == "text123"
assert result.segment_id == "None"
-@pytest.mark.asyncio
-async def test_get_multilingual_search_external_limit_calculation():
- """Test that external API is called with limit * 5, max 100"""
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="hybrid",
- results=[],
- count=0
- )
-
- with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response) as mock_call:
-
- await get_multilingual_search_results(
- query="test query",
- search_type="hybrid",
- skip=0,
- limit=10
- )
-
- call_args = mock_call.call_args
- assert call_args.kwargs["limit"] == 50 # 10 * 5 = 50
-
-
-@pytest.mark.asyncio
-async def test_get_multilingual_search_external_limit_max_cap():
- """Test that external API limit is capped at 100"""
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="hybrid",
- results=[],
- count=0
- )
-
- with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response) as mock_call:
-
- await get_multilingual_search_results(
- query="test query",
- search_type="hybrid",
- skip=0,
- limit=30
- )
-
- call_args = mock_call.call_args
- assert call_args.kwargs["limit"] == 100 # min(30 * 5, 100) = 100
-
-
-@pytest.mark.asyncio
-async def test_get_multilingual_search_total_returns_limit():
- """Test that total returns the set limit value"""
- mock_external_response = ExternalSearchResponse(
- query="test query",
- search_type="hybrid",
- results=[
- ExternalSearchResult(id="pecha_seg_1", distance=0.9, entity=ExternalSegmentEntity(text="Content 1")),
- ExternalSearchResult(id="pecha_seg_2", distance=0.8, entity=ExternalSegmentEntity(text="Content 2")),
- ExternalSearchResult(id="pecha_seg_3", distance=0.7, entity=ExternalSegmentEntity(text="Content 3")),
- ],
- count=100
- )
-
- mock_segment_1 = Mock()
- mock_segment_1.id = uuid4()
- mock_segment_1.content = "Content 1"
- mock_segment_1.text_id = "text_123"
- mock_segment_1.pecha_segment_id = "pecha_seg_1"
-
- mock_segment_2 = Mock()
- mock_segment_2.id = uuid4()
- mock_segment_2.content = "Content 2"
- mock_segment_2.text_id = "text_123"
- mock_segment_2.pecha_segment_id = "pecha_seg_2"
-
- mock_segments = [mock_segment_1, mock_segment_2]
-
- mock_text = Mock()
- mock_text.title = "Test Text"
- mock_text.language = "bo"
- mock_text.created_at = "2024-01-01"
-
- with patch("pecha_api.search.search_service.call_external_search_api", new_callable=AsyncMock, return_value=mock_external_response), \
- patch("pecha_api.search.search_service.Segment.get_segments_by_pecha_ids", new_callable=AsyncMock, return_value=mock_segments), \
- patch("pecha_api.search.search_service.Text.get_text", new_callable=AsyncMock, return_value=mock_text):
-
- response = await get_multilingual_search_results(
- query="test query",
- search_type="hybrid",
- skip=0,
- limit=10
- )
-
- assert response.total == 10
-
-
def test_apply_pagination_to_sources_basic():
"""Test basic pagination with skip and limit"""
text_info = TextIndex(text_id="text_123", language="bo", title="Test", published_date="")
diff --git a/tests/search/test_search_views.py b/tests/search/test_search_views.py
index f4c10ccfe..a96034191 100644
--- a/tests/search/test_search_views.py
+++ b/tests/search/test_search_views.py
@@ -308,8 +308,8 @@ def test_search_service_error():
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
-def test_multilingual_search_success_hybrid():
- """Test multilingual search with HYBRID search type (default)"""
+def test_multilingual_search_success_similar():
+ """Test multilingual search with SIMILAR search type (default)"""
mock_segment_matches = [
MultilingualSegmentMatch(
segment_id=f"seg_{i}",
@@ -334,7 +334,7 @@ def test_multilingual_search_success_hybrid():
mock_response = MultilingualSearchResponse(
query="test query",
- search_type="hybrid",
+ search_type="similar",
sources=mock_source_results,
skip=0,
limit=10,
@@ -350,7 +350,7 @@ def test_multilingual_search_success_hybrid():
data = response.json()
assert data["query"] == "test query"
- assert data["search_type"] == "hybrid"
+ assert data["search_type"] == "similar"
assert len(data["sources"]) == 1
assert data["sources"][0]["text"]["text_id"] == "text_123"
assert data["sources"][0]["text"]["language"] == "bo"
@@ -360,49 +360,6 @@ def test_multilingual_search_success_hybrid():
assert data["total"] == 1
-def test_multilingual_search_with_semantic_type():
- """Test multilingual search with SEMANTIC search type"""
- mock_response = MultilingualSearchResponse(
- query="semantic query",
- search_type="semantic",
- sources=[],
- skip=0,
- limit=10,
- total=0
- )
-
- with patch("pecha_api.search.search_views.get_multilingual_search_results",
- new_callable=AsyncMock, return_value=mock_response):
-
- response = client.get("/search/multilingual?query=semantic query&search_type=semantic")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["search_type"] == "semantic"
- assert data["sources"] == []
-
-
-def test_multilingual_search_with_bm25_type():
- """Test multilingual search with BM25 search type"""
- mock_response = MultilingualSearchResponse(
- query="bm25 query",
- search_type="bm25",
- sources=[],
- skip=0,
- limit=10,
- total=0
- )
-
- with patch("pecha_api.search.search_views.get_multilingual_search_results",
- new_callable=AsyncMock, return_value=mock_response):
-
- response = client.get("/search/multilingual?query=bm25 query&search_type=bm25")
-
- assert response.status_code == status.HTTP_200_OK
- data = response.json()
- assert data["search_type"] == "bm25"
-
-
def test_multilingual_search_with_exact_type():
"""Test multilingual search with EXACT search type"""
mock_response = MultilingualSearchResponse(
@@ -428,7 +385,7 @@ def test_multilingual_search_with_text_id():
"""Test multilingual search with specific text_id parameter"""
mock_response = MultilingualSearchResponse(
query="query in specific text",
- search_type="hybrid",
+ search_type="similar",
sources=[],
skip=0,
limit=10,
@@ -447,7 +404,7 @@ def test_multilingual_search_with_search_type():
"""Test multilingual search with search_type parameter"""
mock_response = MultilingualSearchResponse(
query="query with search type",
- search_type="semantic",
+ search_type="similar",
sources=[],
skip=0,
limit=10,
@@ -457,7 +414,7 @@ def test_multilingual_search_with_search_type():
with patch("pecha_api.search.search_views.get_multilingual_search_results",
new_callable=AsyncMock, return_value=mock_response):
- response = client.get("/search/multilingual?query=query with search type&search_type=semantic")
+ response = client.get("/search/multilingual?query=query with search type&search_type=similar")
assert response.status_code == status.HTTP_200_OK
@@ -466,7 +423,7 @@ def test_multilingual_search_with_pagination():
"""Test multilingual search with custom pagination"""
mock_response = MultilingualSearchResponse(
query="paginated query",
- search_type="hybrid",
+ search_type="similar",
sources=[],
skip=20,
limit=50,
@@ -489,7 +446,7 @@ def test_multilingual_search_with_all_parameters():
"""Test multilingual search with all optional parameters"""
mock_response = MultilingualSearchResponse(
query="full query",
- search_type="semantic",
+ search_type="exact",
sources=[],
skip=10,
limit=25,
@@ -500,12 +457,12 @@ def test_multilingual_search_with_all_parameters():
new_callable=AsyncMock, return_value=mock_response):
response = client.get(
- "/search/multilingual?query=full query&search_type=semantic&text_id=text_456&skip=10&limit=25"
+ "/search/multilingual?query=full query&search_type=exact&text_id=text_456&edition_id=edition_789&skip=10&limit=25"
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
- assert data["search_type"] == "semantic"
+ assert data["search_type"] == "exact"
assert data["skip"] == 10
assert data["limit"] == 25
@@ -514,7 +471,7 @@ def test_multilingual_search_empty_results():
"""Test multilingual search with no results found"""
mock_response = MultilingualSearchResponse(
query="no results query",
- search_type="hybrid",
+ search_type="similar",
sources=[],
skip=0,
limit=10,
@@ -556,7 +513,7 @@ def test_multilingual_search_multiple_sources():
mock_response = MultilingualSearchResponse(
query="multi source query",
- search_type="hybrid",
+ search_type="similar",
sources=mock_sources,
skip=0,
limit=10,