Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ celerybeat.pid
# Environments
.env
.venv
pro-vnev/
env/
venv/
ENV/
Expand Down Expand Up @@ -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
docker-compose.yaml
21 changes: 21 additions & 0 deletions database/category_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion database/data_adapter.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"))
Expand Down
5 changes: 5 additions & 0 deletions models/category.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
16 changes: 16 additions & 0 deletions routers/categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"",
Expand Down
109 changes: 109 additions & 0 deletions tests/test_categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down