diff --git a/.gitignore b/.gitignore index 6756d54e..aa829e66 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,7 @@ celerybeat.pid # Environments .env .venv +pro-vnev/ env/ venv/ ENV/ @@ -170,8 +171,15 @@ cython_debug/ # PyPI configuration file .pypirc +#Firebase logs +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + storage.rules .secret.local .ruff_cache/ -docker-compose.yaml \ No newline at end of file +docker-compose.yaml diff --git a/database/category_database.py b/database/category_database.py index c0f0b0a5..070d18ac 100644 --- a/database/category_database.py +++ b/database/category_database.py @@ -48,6 +48,19 @@ class CategoryDatabase: RETURN c.id AS category_id """ + GET_BY_ID_QUERY = """ + MATCH (c:Category {id: $category_id}) + RETURN { + id: c.id, + title: [(c)-[:HAS_TITLE]->(n:Nomen)-[:HAS_LOCALIZATION]->(lt:LocalizedText) + -[:HAS_LANGUAGE]->(l:Language) | {language: l.code, text: lt.text}], + description: [(c)-[:HAS_DESCRIPTION]->(dn:Nomen)-[:HAS_LOCALIZATION]->(dlt:LocalizedText) + -[:HAS_LANGUAGE]->(dl:Language) | {language: dl.code, text: dlt.text}], + parent_id: [(c)-[:HAS_PARENT]->(parent:Category) | parent.id][0], + children: [(child:Category)-[:HAS_PARENT]->(c) | child.id] + } AS category + """ + FIND_EXISTING_QUERY = """ MATCH (c:Category)-[:BELONGS_TO]->(app:Application {id: $application}) WHERE ($parent_id IS NULL AND NOT EXISTS { (c)-[:HAS_PARENT]->(:Category) }) @@ -76,6 +89,14 @@ async def get_all(self, application: str, parent_id: str | None = None) -> list[ records = await result.data() return [DataAdapter.category(record["category"]) for record in records] + async def get_by_id(self, category_id: str) -> CategoryOutput | None: + async with self.session as session: + result = await session.run(CategoryDatabase.GET_BY_ID_QUERY, category_id=category_id) + record = await result.single() + if record is None: + return None + return DataAdapter.category(record["category"]) + async def create(self, category: CategoryInput, application: str) -> str: async def create_transaction(tx: AsyncManagedTransaction) -> str: await self._validate_not_exists_tx(tx, application, category.title.root, category.parent_id) diff --git a/database/data_adapter.py b/database/data_adapter.py index 10476ece..612643a3 100644 --- a/database/data_adapter.py +++ b/database/data_adapter.py @@ -1,5 +1,5 @@ from models.base import LocalizedString -from models.category import CategoryOutput +from models.category import CategoryDetailOutput, CategoryOutput from models.contribution import AIContribution, ContributionOutput from models.edition import EditionOutput from models.enums import EditionType, LicenseType @@ -102,6 +102,17 @@ def category(data: dict) -> CategoryOutput: children=data.get("children") or [], ) + @staticmethod + def category_detail(data: dict) -> CategoryDetailOutput: + desc = DataAdapter.localized_text(data.get("description")) + return CategoryDetailOutput( + id=data["id"], + title=LocalizedString(DataAdapter.localized_text(data["title"]) or {}), + description=LocalizedString(desc) if desc else None, + parent_id=data.get("parent_id"), + children=[DataAdapter.category(child) for child in data.get("children") or []], + ) + @staticmethod def tag(data: dict) -> TagOutput: desc = DataAdapter.localized_text(data.get("description")) diff --git a/models/category.py b/models/category.py index e8b84f96..6402986a 100644 --- a/models/category.py +++ b/models/category.py @@ -16,3 +16,8 @@ class CategoryInput(CategoryBase): class CategoryOutput(CategoryBase): id: NonEmptyStr children: list[str] = Field(default_factory=list) + + +class CategoryDetailOutput(CategoryBase): + id: NonEmptyStr + children: list[CategoryOutput] = Field(default_factory=list) diff --git a/routers/categories.py b/routers/categories.py index 273b790a..6ac9cdc8 100644 --- a/routers/categories.py +++ b/routers/categories.py @@ -31,6 +31,22 @@ async def get_categories( return await db.category.get_all(application=x_application, parent_id=parent_id) +@router.get( + "/{category_id}", + summary="Get category", + description="Retrieve a category and its direct child categories by ID.", +) +async def get_category( + category_id: str, + _api_key: Annotated[str, Depends(get_api_key)], + db: Annotated[Database, Depends(get_db)], +) -> CategoryOutput: + """Get a category with its children by ID.""" + category = await db.category.get_by_id(category_id) + if category is None: + raise DataNotFoundError(f"Category '{category_id}' not found") + return category + @router.post( "", diff --git a/tests/test_categories.py b/tests/test_categories.py index 00d9d79a..6ade7f1c 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -486,6 +486,115 @@ async def test_get_categories_seeded_category_no_description(self, client, test_ assert seeded_cat.get("description") is None +@pytest.mark.asyncio(loop_scope="session") +class TestGetCategoryById: + """Tests for GET /v2/categories/{category_id} endpoint""" + + async def test_get_category_returns_seeded_category(self, client, test_database): + """Test fetching the seeded category by ID returns it with correct fields""" + response = await client.get("/v2/categories/category") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == "category" + assert "title" in data + assert data["title"]["en"] == "Test Category" + assert data["title"]["bo"] == "ཚིག་སྒྲུབ་གསར་པ།" + assert data.get("parent_id") is None + assert isinstance(data["children"], list) + + async def test_get_category_not_found(self, client, test_database): + """Test fetching a non-existent category returns 404""" + response = await client.get("/v2/categories/nonexistent_id") + + assert response.status_code == 404 + data = response.json() + assert "error" in data + + async def test_get_category_includes_children_ids(self, client, test_database): + """Test that children are returned as a list of IDs""" + parent = CategoryInput.model_validate({"title": {"en": "Parent For Detail Test"}}) + parent_id = await test_database.category.create(parent, application="test_application") + + child1 = CategoryInput.model_validate({"title": {"en": "Child One"}, "parent_id": parent_id}) + child1_id = await test_database.category.create(child1, application="test_application") + + child2 = CategoryInput.model_validate( + {"title": {"en": "Child Two"}, "description": {"en": "A child with description"}, "parent_id": parent_id} + ) + child2_id = await test_database.category.create(child2, application="test_application") + + response = await client.get(f"/v2/categories/{parent_id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == parent_id + assert len(data["children"]) == 2 + assert child1_id in data["children"] + assert child2_id in data["children"] + + async def test_get_category_no_children(self, client, test_database): + """Test fetching a leaf category returns an empty children list""" + leaf = CategoryInput.model_validate({"title": {"en": "Leaf For Detail Test"}}) + leaf_id = await test_database.category.create(leaf, application="test_application") + + response = await client.get(f"/v2/categories/{leaf_id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == leaf_id + assert data["children"] == [] + + async def test_get_category_children_are_direct_child_ids(self, client, test_database): + """Test that children field contains only direct child IDs""" + grandparent = CategoryInput.model_validate({"title": {"en": "Grandparent Detail"}}) + grandparent_id = await test_database.category.create(grandparent, application="test_application") + + parent = CategoryInput.model_validate({"title": {"en": "Parent Detail"}, "parent_id": grandparent_id}) + parent_id = await test_database.category.create(parent, application="test_application") + + grandchild = CategoryInput.model_validate({"title": {"en": "Grandchild Detail"}, "parent_id": parent_id}) + await test_database.category.create(grandchild, application="test_application") + + response = await client.get(f"/v2/categories/{grandparent_id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == grandparent_id + assert isinstance(data["children"], list) + assert len(data["children"]) == 1 + assert data["children"][0] == parent_id + + async def test_get_category_with_description(self, client, test_database): + """Test fetching a category that has a description returns it""" + cat = CategoryInput.model_validate( + {"title": {"en": "Category With Desc"}, "description": {"en": "Some description", "bo": "འགྲེལ་བཤད།"}} + ) + cat_id = await test_database.category.create(cat, application="test_application") + + response = await client.get(f"/v2/categories/{cat_id}") + + assert response.status_code == 200 + data = response.json() + assert data["description"]["en"] == "Some description" + assert data["description"]["bo"] == "འགྲེལ་བཤད།" + + async def test_get_category_with_parent_id(self, client, test_database): + """Test that parent_id is correctly set on a child category""" + parent = CategoryInput.model_validate({"title": {"en": "Parent For Parent ID Test"}}) + parent_id = await test_database.category.create(parent, application="test_application") + + child = CategoryInput.model_validate({"title": {"en": "Child For Parent ID Test"}, "parent_id": parent_id}) + child_id = await test_database.category.create(child, application="test_application") + + response = await client.get(f"/v2/categories/{child_id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == child_id + assert data["parent_id"] == parent_id + + async def _seed_app_b(test_database): """Seed a second application for isolation tests.""" async with test_database.get_session() as session: