Skip to content
23 changes: 21 additions & 2 deletions app/api/languages.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.auth_middleware import get_current_user
from app.core.auth_middleware import get_current_user, require_platform_admin
from app.core.database import get_db
from app.core.exceptions import NotFoundError
from app.db.models.auth import User
from app.models.language import LanguageCreate, LanguageResponse
from app.models.language import (
LanguageCreate,
LanguageProjectRef,
LanguageResponse,
LanguageStatsResponse,
)
from app.services import language_service

router = APIRouter()
Expand Down Expand Up @@ -50,3 +55,17 @@ async def get_language_by_id(
) -> LanguageResponse:
language = await language_service.get_language_or_404(db, language_id)
return LanguageResponse.model_validate(language)


@router.get("/{language_id}/stats", response_model=LanguageStatsResponse)
async def get_language_stats(
language_id: str,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_platform_admin),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that correct? Relative to main this /stats route is new — it was added on the first commit of this same PR (58d0d63) — so I think nothing was ever exposed, but the body reads like a leak that shipped. And the require_admin_or_manager you mention is not on main, it comes on #93. The admin guard itself is a good call, just please fix the body and the "403 instead of 200" breaking change checkbox — they do not apply for an endpoint that never existed outside this PR.

) -> LanguageStatsResponse:
projects = await language_service.get_language_stats(db, language_id)
return LanguageStatsResponse(
language_id=language_id,
project_count=len(projects),
projects=[LanguageProjectRef(id=project_id, name=name) for project_id, name in projects],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ps: typed outputs issue again — building the LanguageProjectRef list and the count here is model creation on the router, CLAUDE.md asks to keep this on the service.

)
11 changes: 11 additions & 0 deletions app/models/language.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,14 @@ class LanguageResponse(BaseModel):
created_at: datetime

model_config = {"from_attributes": True}


class LanguageProjectRef(BaseModel):
id: str
name: str


class LanguageStatsResponse(BaseModel):
language_id: str
project_count: int
projects: list[LanguageProjectRef]
2 changes: 2 additions & 0 deletions app/services/language/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
from app.services.language.get_language_by_code import get_language_by_code
from app.services.language.get_language_by_id import get_language_by_id
from app.services.language.get_language_or_404 import get_language_or_404
from app.services.language.get_language_stats import get_language_stats
from app.services.language.list_languages import list_languages

__all__ = [
"create_language",
"get_language_by_code",
"get_language_by_id",
"get_language_or_404",
"get_language_stats",
"list_languages",
]
16 changes: 16 additions & 0 deletions app/services/language/get_language_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.models.project import Project
from app.services.language.get_language_or_404 import get_language_or_404


async def get_language_stats(db: AsyncSession, language_id: str) -> list[tuple[str, str]]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me it is relevant type the outputs whenever possible. Project.id and Project.name are both str here, so if the select() column order changes one day the router unpacking for project_id, name in projects swaps them silently and mypy will not catch it. app/services/oral_collector/stats_service.py already builds the Pydantic items inside the service and returns GenreStatsResponse directly to the router — please verify that and if it is the majority for stats on the codebase, adapt this one to return the typed model.

await get_language_or_404(db, language_id)
stmt = (
select(Project.id, Project.name)
.where(Project.language_id == language_id)
.order_by(Project.name)
)
result = await db.execute(stmt)
return [(row.id, row.name) for row in result.all()]
32 changes: 32 additions & 0 deletions tests/test_language_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import pytest

from app.core.exceptions import NotFoundError
from app.services import language_service
from tests.baker import make_language, make_project


@pytest.mark.asyncio
async def test_language_stats_lists_projects(db_session) -> None:
lang = await make_language(db_session, code="ksa")
other = await make_language(db_session, code="oth")
p1 = await make_project(db_session, language_id=lang.id, name="P1")
p2 = await make_project(db_session, language_id=lang.id, name="P2")
await make_project(db_session, language_id=other.id, name="P3")

projects = await language_service.get_language_stats(db_session, lang.id)
assert projects == [(p1.id, p1.name), (p2.id, p2.name)]


@pytest.mark.asyncio
async def test_language_stats_empty_when_unused(db_session) -> None:
lang = await make_language(db_session, code="ksb")
projects = await language_service.get_language_stats(db_session, lang.id)
assert projects == []


@pytest.mark.asyncio
async def test_language_stats_missing_raises_not_found(db_session) -> None:
with pytest.raises(NotFoundError, match=r"Language .* not found"):
await language_service.get_language_stats(
db_session, "00000000-0000-0000-0000-000000000000"
)
Loading