Skip to content

[US-2.2 / OBT-201] Language usage stats endpoint - #98

Open
levigtri wants to merge 7 commits into
mainfrom
levigft/obt-201-us-22-see-how-many-projects-use-a-given-language-before
Open

[US-2.2 / OBT-201] Language usage stats endpoint#98
levigtri wants to merge 7 commits into
mainfrom
levigft/obt-201-us-22-see-how-many-projects-use-a-given-language-before

Conversation

@levigtri

@levigtri levigtri commented Jul 5, 2026

Copy link
Copy Markdown
Member

Summary

Extends the language usage-stats endpoint so it returns which projects use a language, not just how many. GET /api/languages/{id}/stats now includes a projects array of { id, name } alongside project_count. The console deactivate confirmation uses this to list the affected projects before an admin confirms.

Also restricts the endpoint to platform admins, closing a cross-organization exposure — see below.

Changes

  1. Richer stats responseapp/models/language.py: new LanguageProjectRef { id, name }; LanguageStatsResponse gains projects: list[LanguageProjectRef] (keeps project_count).
  2. Service returns the project listapp/services/language/get_language_stats.py: selects (Project.id, Project.name) for the language, ordered by name.
  3. Router maps the listapp/api/languages.py: builds project_count = len(projects) and the projects array.
  4. Platform-admin onlyapp/api/languages.py: the route is guarded by require_platform_admin instead of get_current_user.

Security fix — cross-organization project exposure

As written, the endpoint was guarded only by get_current_user and the router-level require_admin_or_manager, both of which admit a manager. get_language_stats runs select(Project.id, Project.name).where(Project.language_id == language_id) with no scoping against the caller's own projects. Any manager could therefore walk language ids — which they already hold, since language_id is a required field on every project response — and inventory the id + name of projects belonging to organizations they have no access to. That directly contradicts the managed-project scoping US-11.6 establishes for the console lists.

Guarded with require_platform_admin rather than narrowed by scoping, because the endpoint exists to inform the deactivation decision and deactivation is now platform-admin only (US-2.1 / OBT-200, rule revised 2026-07-16). A manager has no remaining use for it. Verified in the console: the only caller is openDeleteDialog in LanguagesPage, reachable exclusively from the admin-gated deactivate action — so this is not a frontend regression.

The alternative was to keep it open to managers and filter the projects array through get_managed_project_ids. That preserves an audience which the rule change has removed, and leaves the count itself as a cross-org signal. If managers should regain usage stats for their own projects later, that is the shape to revisit.

Type of Change

  • Feature / additive response change (new projects field)
  • Security fix (cross-organization project names exposed to any manager)
  • Database migration
  • Breaking change for non-admin callers: 403 instead of 200 (no such caller exists in the console)

Testing

  • tests/test_language_stats.py: returned ids/names, the empty case, and unknown language → 404. The service tests are unaffected by the guard change (the guard lives on the route).
  • ruff check . + ruff format --check . clean.

levigtri added 2 commits July 4, 2026 22:53
Add GET /api/languages/{id}/stats returning project_count so admins can
gauge the impact before deactivating a language. Returns 404 for an
unknown language and requires authentication.
The usage-stats endpoint now returns which projects use a language, not
just the count. GET /api/languages/{id}/stats exposes a `projects` array
of {id, name} alongside `project_count`, so the console can list the
affected projects before deactivating and explain an in-use block.

- LanguageStatsResponse.projects (LanguageProjectRef {id, name})
- get_language_stats returns (id, name) rows ordered by name
- tests assert the returned project names and the empty case
@levigtri levigtri self-assigned this Jul 15, 2026
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds language statistics response models, a service query for language-associated projects, an authenticated GET /{language_id}/stats endpoint, and tests for filtering, empty results, and missing languages.

Changes

Language statistics

Layer / File(s) Summary
Statistics contract and service
app/models/language.py, app/services/language/get_language_stats.py, app/services/language/__init__.py
Defines project-reference and statistics response models, validates the language, queries ordered projects, and exports the new service function.
Statistics API response
app/api/languages.py
Adds the authenticated statistics route and maps service results into LanguageStatsResponse.
Statistics service validation
tests/test_language_stats.py
Tests language-specific project filtering, empty project results, and not-found errors.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant LanguageStatsEndpoint
  participant LanguageStatsService
  participant Database
  Client->>LanguageStatsEndpoint: GET /{language_id}/stats
  LanguageStatsEndpoint->>LanguageStatsService: Request language statistics
  LanguageStatsService->>Database: Validate language and query ordered projects
  Database-->>LanguageStatsService: Project id and name tuples
  LanguageStatsService-->>LanguageStatsEndpoint: Project tuples
  LanguageStatsEndpoint-->>Client: LanguageStatsResponse
Loading

Suggested reviewers: joaocarvoli

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the new language usage stats endpoint introduced by this PR.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch levigft/obt-201-us-22-see-how-many-projects-use-a-given-language-before

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
tests/test_language_stats.py-9-18 (1)

9-18: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert project IDs and ordering explicitly.

The current set-based assertion would pass with incorrect IDs, mismatched ID/name pairs, or without the service’s order_by(Project.name). Capture the created projects and assert the exact ordered tuples.

Suggested test adjustment
-    await make_project(db_session, language_id=lang.id, name="P1")
-    await make_project(db_session, language_id=lang.id, name="P2")
+    first = await make_project(db_session, language_id=lang.id, name="Zeta")
+    second = await make_project(db_session, language_id=lang.id, name="Alpha")
...
-    assert len(projects) == 2
-    assert {name for _, name in projects} == {"P1", "P2"}
+    assert projects == [(second.id, "Alpha"), (first.id, "Zeta")]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_language_stats.py` around lines 9 - 18, Update
test_language_stats_lists_projects to retain the project objects returned by
make_project, then assert the exact ordered (project ID, project name) tuples
from get_language_stats. Replace the set-based name assertion while preserving
the expected P1, P2 ordering established by the service.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Other comments:
In `@tests/test_language_stats.py`:
- Around line 9-18: Update test_language_stats_lists_projects to retain the
project objects returned by make_project, then assert the exact ordered (project
ID, project name) tuples from get_language_stats. Replace the set-based name
assertion while preserving the expected P1, P2 ordering established by the
service.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Pro Plus

Run ID: f68b2016-2044-46f7-aeba-6c63d455b2cd

📥 Commits

Reviewing files that changed from the base of the PR and between d669a56 and bea98e5.

📒 Files selected for processing (5)
  • app/api/languages.py
  • app/models/language.py
  • app/services/language/__init__.py
  • app/services/language/get_language_stats.py
  • tests/test_language_stats.py

levigtri and others added 2 commits July 15, 2026 21:49
…tats test

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ce conventions

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@levigtri

Copy link
Copy Markdown
Member Author

@coderabbitai — decision on this review:

tests/test_language_stats.py (assert the exact ordered (id, name) tuples instead of a set of names):
Done. get_language_stats orders by Project.name, so the ordering is deterministic — the test now asserts the exact ordered tuples, which also covers the project id, not just the name.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@levigtri Thanks for confirming — asserting the exact ordered (id, name) tuples is a stronger check since it validates both ordering and the id field, not just names. Sounds good to me.

(¬‸¬)b

@levigtri
levigtri requested a review from joaocarvoli July 16, 2026 02:04
GET /languages/{id}/stats was guarded by get_current_user and returned the id
and name of every project using the language, with no scoping against the
caller's own projects. Any manager could walk language ids and inventory the
projects of organizations they have no access to, which contradicts the
managed-project scoping US-11.6 establishes for the console lists.

The endpoint exists to inform the deactivation decision, and deactivation is now
platform-admin only (US-2.1), so a manager has no remaining use for it. Guarding
with require_platform_admin closes the exposure outright instead of narrowing it.
The console only ever calls this from the admin-gated deactivation dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

@joaocarvoli joaocarvoli left a comment

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.

some comments

Comment thread app/api/languages.py
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.

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.

Comment thread app/api/languages.py
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants