Skip to content

Commit 8dd5061

Browse files
joaocarvoliclaude
andauthored
Feat/annotation studio (#86)
* Add annotation-studio enums and SQLAlchemy models Nine as_* tables, every language_id FK retargeted to tripod languages.id (CASCADE). As* enums kept isolated from app.core.enums so the studio's pending/stored upload lifecycle never clashes with the oral-collector UploadStatus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add annotation-studio Pydantic schemas Ported from the studio interface schemas; reuses tripod LanguageResponse for the picker and drops is_seed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add annotation-studio service layer Collection engine ported into tripod's async-SQLAlchemy style: speakers, Tier A/B/C, export bundler, readiness, results, dashboard. naming.py and export_plan.py are pure and verbatim to preserve the CSV/zip contract. storage.py reuses the oral-collector GCS presign pattern, keys prefixed annotation-studio/ in the shared bucket. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add annotation-studio API routers Eight routers (speakers, Tier A/B/C, export, results, languages, audio) + _deps.py guards (require_app_access / require_role). Mounted at /api/annotation-studio (40 routes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add annotation-studio migration and app/role seed Migration creates only the as_* tables and seeds the App row + admin/facilitator roles idempotently. Default access-request role is facilitator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add annotation-studio CORS origin Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Use a dedicated GCS bucket for annotation-studio audio Isolate experiment audio in its own bucket (annotation-studio-audio) instead of sharing oral-collector's tripod-image-uploads. Its CORS is managed independently. Drops the now-redundant annotation-studio/ key prefix so object names are the logical naming.py keys directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix ruff and mypy on the annotation-studio module Wrap long lines + ruff-format; contextlib.suppress for the GCS delete; type annotations on the export helpers (_config_hint/_assemble_zip/_gather); type:ignore for the GCS-stub Any returns and the generic get_or_404 model.id; Row→tuple comprehension in tier_c; avoid rebinding the CurrentUser _ in set_reference. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add tier_b.pairs_ready to readiness (balanced, not just created) readiness.tier_b reported only `pairs` (total created) + `recordings`, so the studio UI marked Tier B complete/export-ready even on empty pairs. Add `pairs_ready` = pairs where both sides have >= REPS_PER_SIDE (5) stored takes, mirroring Tier A's `words_ready`. The frontend gates its ready/percent on this field instead of `pairs`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Scope annotation-studio access per language (+ persistence, readiness & storage hardening) (#85) * feat(db): add per-language membership table and indexes Introduce as_language_members to scope annotation-studio facilitators to specific languages (admins and platform admins bypass it). The Alembic migration creates the table, seeds memberships for every current AS user across every currently-active language so no one is locked out on deploy, and adds composite (FK, upload_status) indexes on the tier recording/clip tables used by readiness and export filters. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(access): enforce per-language access across all routers Add access helpers (assert_language_access, accessible_language_ids and the language_id resolvers) and call them on every annotation-studio data route: path routes check the URL language, by-id routes resolve the owning language from the resource. The dashboard now lists only languages the user may see. Closes the gap where any approved AS user could read or modify any language's data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(members): admin endpoints to manage language members Add admin-only endpoints to list, add (by email) and remove per-language facilitators, backed by member_service and the LanguageMember schemas, and mount the members router. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(readiness): compute readiness with SQL aggregation Replace the in-Python Counter/row-loading readiness computation with SQL COUNT/GROUP BY/HAVING aggregates. This avoids loading every recording, clip and sort row into memory and removes the per-language cost the dashboard multiplied across languages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(storage): commit before deleting objects and cap upload size Delete storage objects only after the database commit succeeds, so a failed commit can no longer leave a row pointing at a deleted file (delete word, recording, clip and export, plus the reference set/clear flows). Also enforce a maximum audio size at the upload "complete" step, deleting oversized objects instead of marking them stored. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(annotation-studio): cover access control, readiness and members Add service-level tests for assert_language_access, the audio storage-key resolver, readiness aggregation and member management, plus an end-to-end HTTP suite that drives the real ASGI stack to prove cross-language requests are rejected with 403 (and guards against a route forgetting the check). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(annotation-studio): apply ruff format Run `ruff format` on the new annotation-studio modules and tests to satisfy the CI format check (`ruff format --check`). Formatting only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f2012f3 commit 8dd5061

31 files changed

Lines changed: 1251 additions & 126 deletions
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""annotation-studio: per-language membership + upload_status indexes
2+
3+
Adds ``as_language_members`` (scopes facilitators to specific languages) and
4+
composite indexes on the recording/clip ``upload_status`` filters used by
5+
readiness and export. Seeds memberships so every existing annotation-studio user
6+
keeps access to every currently-active language (no lockout on deploy).
7+
8+
Revision ID: 20260608_0001
9+
Revises: 20260603_0001
10+
Create Date: 2026-06-08 12:00:00.000000
11+
"""
12+
from __future__ import annotations
13+
14+
import uuid
15+
16+
import sqlalchemy as sa
17+
18+
from alembic import op
19+
20+
revision: str = "20260608_0001"
21+
down_revision: str | None = "20260603_0001"
22+
branch_labels = None
23+
depends_on = None
24+
25+
AS_APP_KEY = "annotation-studio"
26+
_ACTIVE_DATA_TABLES = (
27+
"as_speakers",
28+
"as_tier_a_words",
29+
"as_tier_b_pairs",
30+
"as_tier_c_clips",
31+
"as_exports",
32+
"as_analysis_results",
33+
)
34+
35+
36+
def upgrade() -> None:
37+
op.create_table(
38+
"as_language_members",
39+
sa.Column("id", sa.String(length=36), nullable=False),
40+
sa.Column("language_id", sa.String(length=36), nullable=False),
41+
sa.Column("user_id", sa.String(length=36), nullable=False),
42+
sa.Column("granted_by", sa.String(length=36), nullable=True),
43+
sa.Column(
44+
"created_at",
45+
sa.DateTime(timezone=True),
46+
server_default=sa.text("now()"),
47+
nullable=False,
48+
),
49+
sa.ForeignKeyConstraint(["language_id"], ["languages.id"], ondelete="CASCADE"),
50+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
51+
sa.ForeignKeyConstraint(["granted_by"], ["users.id"], ondelete="SET NULL"),
52+
sa.PrimaryKeyConstraint("id"),
53+
sa.UniqueConstraint("language_id", "user_id", name="uq_as_language_member"),
54+
)
55+
op.create_index(
56+
op.f("ix_as_language_members_language_id"),
57+
"as_language_members",
58+
["language_id"],
59+
unique=False,
60+
)
61+
op.create_index(
62+
op.f("ix_as_language_members_user_id"),
63+
"as_language_members",
64+
["user_id"],
65+
unique=False,
66+
)
67+
68+
# Composite indexes aligned with readiness/export filters (FK + upload_status).
69+
op.create_index(
70+
"ix_as_tier_a_recordings_word_status",
71+
"as_tier_a_recordings",
72+
["word_id", "upload_status"],
73+
unique=False,
74+
)
75+
op.create_index(
76+
"ix_as_tier_b_recordings_pair_status",
77+
"as_tier_b_recordings",
78+
["pair_id", "upload_status"],
79+
unique=False,
80+
)
81+
op.create_index(
82+
"ix_as_tier_c_clips_language_status",
83+
"as_tier_c_clips",
84+
["language_id", "upload_status"],
85+
unique=False,
86+
)
87+
88+
_seed_existing_members()
89+
90+
91+
def _seed_existing_members() -> None:
92+
"""Grant every current annotation-studio user access to every active language.
93+
94+
Prevents a deploy from locking facilitators out of languages they were already
95+
working on. Admins bypass membership, so seeding them is harmless.
96+
"""
97+
bind = op.get_bind()
98+
app_id = bind.execute(
99+
sa.text("SELECT id FROM apps WHERE app_key = :app_key"),
100+
{"app_key": AS_APP_KEY},
101+
).scalar()
102+
if app_id is None:
103+
return
104+
105+
user_ids = [
106+
row[0]
107+
for row in bind.execute(
108+
sa.text(
109+
"SELECT DISTINCT user_id FROM user_app_roles "
110+
"WHERE app_id = :app_id AND revoked_at IS NULL"
111+
),
112+
{"app_id": app_id},
113+
).all()
114+
]
115+
if not user_ids:
116+
return
117+
118+
language_ids: set[str] = set()
119+
for table in _ACTIVE_DATA_TABLES:
120+
for row in bind.execute(sa.text(f"SELECT DISTINCT language_id FROM {table}")).all():
121+
if row[0] is not None:
122+
language_ids.add(row[0])
123+
if not language_ids:
124+
return
125+
126+
insert = sa.text(
127+
"INSERT INTO as_language_members (id, language_id, user_id) "
128+
"VALUES (:id, :language_id, :user_id)"
129+
)
130+
for user_id in user_ids:
131+
for language_id in language_ids:
132+
bind.execute(
133+
insert,
134+
{"id": str(uuid.uuid4()), "language_id": language_id, "user_id": user_id},
135+
)
136+
137+
138+
def downgrade() -> None:
139+
op.drop_index("ix_as_tier_c_clips_language_status", table_name="as_tier_c_clips")
140+
op.drop_index("ix_as_tier_b_recordings_pair_status", table_name="as_tier_b_recordings")
141+
op.drop_index("ix_as_tier_a_recordings_word_status", table_name="as_tier_a_recordings")
142+
op.drop_index(op.f("ix_as_language_members_user_id"), table_name="as_language_members")
143+
op.drop_index(op.f("ix_as_language_members_language_id"), table_name="as_language_members")
144+
op.drop_table("as_language_members")

app/api/annotation_studio/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
audio,
55
export,
66
languages,
7+
members,
78
results,
89
speakers,
910
tier_a,
@@ -13,6 +14,6 @@
1314

1415
router = APIRouter()
1516

16-
for _sub in (languages, speakers, tier_a, tier_b, tier_c, export, results, audio):
17+
for _sub in (languages, members, speakers, tier_a, tier_b, tier_c, export, results, audio):
1718
for route in _sub.router.routes:
1819
router.routes.append(route)

app/api/annotation_studio/audio.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
from fastapi import APIRouter
22

3-
from app.api.annotation_studio._deps import CurrentUser
4-
from app.services.annotation_studio import storage
3+
from app.api.annotation_studio._deps import CurrentUser, Db
4+
from app.services.annotation_studio import access, storage
55

66
router = APIRouter()
77

88

99
@router.get("/audio/url")
10-
async def audio_url(key: str, _: CurrentUser) -> dict[str, str]:
11-
"""A short-lived signed GET URL for client-side playback of a stored object."""
10+
async def audio_url(key: str, db: Db, user: CurrentUser) -> dict[str, str]:
11+
"""A short-lived signed GET URL for client-side playback of a stored object.
12+
13+
The key must belong to an annotation-studio resource in a language the user
14+
can access — this blocks presigning arbitrary keys in the shared bucket.
15+
"""
16+
language_id = await access.language_id_for_storage_key(db, key)
17+
await access.assert_language_access(db, user, language_id)
1218
return {"url": storage.presign_get(key)}

app/api/annotation_studio/export.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from app.api.annotation_studio._deps import CurrentUser, Db
77
from app.core.exceptions import NotFoundError
88
from app.models.annotation_studio import ExportDetail, ExportResponse
9-
from app.services.annotation_studio import export_service, readiness_service
9+
from app.services.annotation_studio import access, export_service, readiness_service
1010

1111
router = APIRouter()
1212

@@ -17,18 +17,22 @@
1717
status_code=status.HTTP_201_CREATED,
1818
)
1919
async def build_export(language_id: str, db: Db, user: CurrentUser) -> ExportResponse:
20+
await access.assert_language_access(db, user, language_id)
2021
export = await export_service.build_export(db, language_id, user.id)
2122
return ExportResponse.model_validate(export)
2223

2324

2425
@router.get("/languages/{language_id}/exports", response_model=list[ExportResponse])
25-
async def list_exports(language_id: str, db: Db, _: CurrentUser) -> list[ExportResponse]:
26+
async def list_exports(language_id: str, db: Db, user: CurrentUser) -> list[ExportResponse]:
27+
await access.assert_language_access(db, user, language_id)
2628
exports = await export_service.list_exports(db, language_id)
2729
return [ExportResponse.model_validate(e) for e in exports]
2830

2931

3032
@router.get("/exports/{export_id}", response_model=ExportDetail)
31-
async def export_detail(export_id: str, db: Db, _: CurrentUser) -> ExportDetail:
33+
async def export_detail(export_id: str, db: Db, user: CurrentUser) -> ExportDetail:
34+
language_id = await access.language_id_for_export(db, export_id)
35+
await access.assert_language_access(db, user, language_id)
3236
export = await export_service.get_export(db, export_id)
3337
detail = ExportDetail.model_validate(export)
3438
detail.manifest = json.loads(export.manifest_json) if export.manifest_json else None
@@ -39,18 +43,23 @@ async def export_detail(export_id: str, db: Db, _: CurrentUser) -> ExportDetail:
3943

4044

4145
@router.delete("/exports/{export_id}", status_code=status.HTTP_204_NO_CONTENT)
42-
async def delete_export(export_id: str, db: Db, _: CurrentUser) -> None:
46+
async def delete_export(export_id: str, db: Db, user: CurrentUser) -> None:
47+
language_id = await access.language_id_for_export(db, export_id)
48+
await access.assert_language_access(db, user, language_id)
4349
await export_service.delete_export(db, export_id)
4450

4551

4652
@router.get("/exports/{export_id}/download")
47-
async def download_export(export_id: str, db: Db, _: CurrentUser) -> RedirectResponse:
53+
async def download_export(export_id: str, db: Db, user: CurrentUser) -> RedirectResponse:
54+
language_id = await access.language_id_for_export(db, export_id)
55+
await access.assert_language_access(db, user, language_id)
4856
url = await export_service.download_url(db, export_id)
4957
if not url:
5058
raise NotFoundError("Export bundle is not ready")
5159
return RedirectResponse(url=url)
5260

5361

5462
@router.get("/languages/{language_id}/readiness")
55-
async def readiness(language_id: str, db: Db, _: CurrentUser) -> dict:
63+
async def readiness(language_id: str, db: Db, user: CurrentUser) -> dict:
64+
await access.assert_language_access(db, user, language_id)
5665
return await readiness_service.compute_readiness(db, language_id)

app/api/annotation_studio/languages.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@
88

99

1010
@router.get("/languages", response_model=list[AsLanguageSummary])
11-
async def list_active_languages(db: Db, _: CurrentUser) -> list[AsLanguageSummary]:
12-
"""Tripod languages that have studio data, each with collection readiness."""
13-
return await dashboard_service.list_active_languages(db)
11+
async def list_active_languages(db: Db, user: CurrentUser) -> list[AsLanguageSummary]:
12+
"""Tripod languages the user may access that have studio data, with readiness."""
13+
return await dashboard_service.list_active_languages(db, user)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from fastapi import APIRouter, status
2+
3+
from app.api.annotation_studio._deps import AdminUser, Db
4+
from app.models.annotation_studio import LanguageMemberCreate, LanguageMemberResponse
5+
from app.services.annotation_studio import member_service
6+
7+
router = APIRouter()
8+
9+
10+
@router.get(
11+
"/languages/{language_id}/members",
12+
response_model=list[LanguageMemberResponse],
13+
)
14+
async def list_members(language_id: str, db: Db, _: AdminUser) -> list[LanguageMemberResponse]:
15+
rows = await member_service.list_members(db, language_id)
16+
return [
17+
LanguageMemberResponse(
18+
user_id=user.id,
19+
email=user.email,
20+
display_name=user.display_name,
21+
created_at=member.created_at,
22+
)
23+
for member, user in rows
24+
]
25+
26+
27+
@router.post(
28+
"/languages/{language_id}/members",
29+
response_model=LanguageMemberResponse,
30+
status_code=status.HTTP_201_CREATED,
31+
)
32+
async def add_member(
33+
language_id: str, payload: LanguageMemberCreate, db: Db, admin: AdminUser
34+
) -> LanguageMemberResponse:
35+
member, user = await member_service.add_member(db, language_id, payload.email, admin.id)
36+
return LanguageMemberResponse(
37+
user_id=user.id,
38+
email=user.email,
39+
display_name=user.display_name,
40+
created_at=member.created_at,
41+
)
42+
43+
44+
@router.delete(
45+
"/languages/{language_id}/members/{user_id}",
46+
status_code=status.HTTP_204_NO_CONTENT,
47+
)
48+
async def remove_member(language_id: str, user_id: str, db: Db, _: AdminUser) -> None:
49+
await member_service.remove_member(db, language_id, user_id)

app/api/annotation_studio/results.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
ResultCreate,
99
ResultResponse,
1010
)
11-
from app.services.annotation_studio import results_service
11+
from app.services.annotation_studio import access, results_service
1212

1313
router = APIRouter()
1414

@@ -31,24 +31,28 @@ def _to_response(result: AsAnalysisResult, plots: dict[str, str]) -> ResultRespo
3131
status_code=status.HTTP_201_CREATED,
3232
)
3333
async def create_result(
34-
language_id: str, payload: ResultCreate, db: Db, _: CurrentUser
34+
language_id: str, payload: ResultCreate, db: Db, user: CurrentUser
3535
) -> ResultResponse:
36+
await access.assert_language_access(db, user, language_id)
3637
result = await results_service.create_result(
3738
db, language_id, payload.results_json, payload.export_id
3839
)
3940
return _to_response(result, {})
4041

4142

4243
@router.get("/languages/{language_id}/results", response_model=list[ResultResponse])
43-
async def list_results(language_id: str, db: Db, _: CurrentUser) -> list[ResultResponse]:
44+
async def list_results(language_id: str, db: Db, user: CurrentUser) -> list[ResultResponse]:
45+
await access.assert_language_access(db, user, language_id)
4446
results = await results_service.list_results(db, language_id)
4547
return [_to_response(r, results_service.plot_urls(r)) for r in results]
4648

4749

4850
@router.post("/results/{result_id}/plots", response_model=PlotPresignResponse)
4951
async def presign_plot(
50-
result_id: str, payload: PlotPresignRequest, db: Db, _: CurrentUser
52+
result_id: str, payload: PlotPresignRequest, db: Db, user: CurrentUser
5153
) -> PlotPresignResponse:
54+
language_id = await access.language_id_for_result(db, result_id)
55+
await access.assert_language_access(db, user, language_id)
5256
presigned = await results_service.presign_plot(
5357
db, result_id, payload.name, payload.content_type
5458
)

app/api/annotation_studio/speakers.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
from app.api.annotation_studio._deps import CurrentUser, Db
44
from app.models.annotation_studio import SpeakerCreate, SpeakerResponse, SpeakerUpdate
5-
from app.services.annotation_studio import speaker_service
5+
from app.services.annotation_studio import access, speaker_service
66

77
router = APIRouter()
88

99

1010
@router.get("/languages/{language_id}/speakers", response_model=list[SpeakerResponse])
11-
async def list_speakers(language_id: str, db: Db, _: CurrentUser) -> list[SpeakerResponse]:
11+
async def list_speakers(language_id: str, db: Db, user: CurrentUser) -> list[SpeakerResponse]:
12+
await access.assert_language_access(db, user, language_id)
1213
speakers = await speaker_service.list_speakers(db, language_id)
1314
return [SpeakerResponse.model_validate(s) for s in speakers]
1415

@@ -19,8 +20,9 @@ async def list_speakers(language_id: str, db: Db, _: CurrentUser) -> list[Speake
1920
status_code=status.HTTP_201_CREATED,
2021
)
2122
async def create_speaker(
22-
language_id: str, payload: SpeakerCreate, db: Db, _: CurrentUser
23+
language_id: str, payload: SpeakerCreate, db: Db, user: CurrentUser
2324
) -> SpeakerResponse:
25+
await access.assert_language_access(db, user, language_id)
2426
speaker = await speaker_service.create_speaker(
2527
db, language_id, payload.label, payload.display_name
2628
)
@@ -29,12 +31,18 @@ async def create_speaker(
2931

3032
@router.patch("/speakers/{speaker_id}", response_model=SpeakerResponse)
3133
async def update_speaker(
32-
speaker_id: str, payload: SpeakerUpdate, db: Db, _: CurrentUser
34+
speaker_id: str, payload: SpeakerUpdate, db: Db, user: CurrentUser
3335
) -> SpeakerResponse:
36+
await access.assert_language_access(
37+
db, user, await access.language_id_for_speaker(db, speaker_id)
38+
)
3439
speaker = await speaker_service.update_speaker(db, speaker_id, payload.display_name)
3540
return SpeakerResponse.model_validate(speaker)
3641

3742

3843
@router.delete("/speakers/{speaker_id}", status_code=status.HTTP_204_NO_CONTENT)
39-
async def delete_speaker(speaker_id: str, db: Db, _: CurrentUser) -> None:
44+
async def delete_speaker(speaker_id: str, db: Db, user: CurrentUser) -> None:
45+
await access.assert_language_access(
46+
db, user, await access.language_id_for_speaker(db, speaker_id)
47+
)
4048
await speaker_service.delete_speaker(db, speaker_id)

0 commit comments

Comments
 (0)