Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
693e642
docs(reports): spec for ADRF report views refactor
samuelvkwong Jun 8, 2026
5e4a1be
docs(reports): implementation plan for ADRF report views
samuelvkwong Jun 8, 2026
31111bd
refactor(reports): extract bulk_upsert_reports into radis/reports/api…
samuelvkwong Jun 8, 2026
59d0f28
test(reports): add end-to-end report API tests + async-shape guards
samuelvkwong Jun 8, 2026
5b30886
feat(reports): add ADRF report views (not yet wired into urls)
samuelvkwong Jun 8, 2026
00232c9
feat(reports): swap report API URLs to ADRF views; remove ReportViewSet
samuelvkwong Jun 8, 2026
d6d5e26
fix(reports): address Gemini async-safety findings on PR #230
samuelvkwong Jun 8, 2026
86ac291
test(reports): use transaction=True on HTTP tests against ADRF views
samuelvkwong Jun 8, 2026
07b8751
test(reports): migrate HTTP tests to AsyncClient
samuelvkwong Jun 8, 2026
06cbfd3
test(reports): wrap sync ORM helpers with sync_to_async in async tests
samuelvkwong Jun 8, 2026
8329d45
docs(reports): correct ADRF spec motivation — inline embedding, not e…
samuelvkwong Jun 8, 2026
7215e2f
refactor(reports): collapse three ADRF views into one ReportViewSet
samuelvkwong Jun 8, 2026
6961339
refactor(reports): keep viewsets.py naming, fold bulk helper back in
samuelvkwong Jun 8, 2026
590cfab
fix(reports): use adrf.routers.DefaultRouter so dispatch reaches asyn…
samuelvkwong Jun 8, 2026
103f36b
Merge branch 'main' into feat/adrf-views
samuelvkwong Jun 9, 2026
0028280
refactor(reports): split async coordination from sync atomic helpers
samuelvkwong Jun 10, 2026
7b4f549
fix(reports): drop redundant @transaction.atomic on acreate/aupdate h…
samuelvkwong Jun 10, 2026
d47a70a
refactor(reports): extract async write operations into operations.py
samuelvkwong Jun 10, 2026
ed9b0e0
refactor(reports): make ReportSerializer async-native (acreate/aupdate)
samuelvkwong Jun 10, 2026
a54f203
refactor(reports): move atomic transaction ownership into the serializer
samuelvkwong Jun 10, 2026
e4ef7a8
refactor(reports): wrap bulk_upsert_reports CPU phases in sync_to_async
samuelvkwong Jun 10, 2026
c01c550
docs(reports): correct viewsets.py async-roadmap comment + document e…
samuelvkwong Jun 10, 2026
580c4e5
docs(reports): trim async-roadmap detail from viewsets.py docstring
samuelvkwong Jun 10, 2026
6ec0df0
docs(reports): condense async-roadmap implication paragraph
samuelvkwong Jun 10, 2026
dcbe6af
refactor(reports): move BULK_DB_BATCH_SIZE to settings
samuelvkwong Jun 11, 2026
f54f0ef
docs(reports): redistribute comments to reflect current architecture
samuelvkwong Jun 11, 2026
e272a4a
refactor(reports): demote REPORTS_BULK_DB_BATCH_SIZE from env to code…
samuelvkwong Jun 11, 2026
76dc20f
fix(reports): wrap transaction.on_commit in sync_to_async for acreate…
samuelvkwong Jun 15, 2026
7ed69b5
docs(reports): trim migration-flavored comments
samuelvkwong Jun 15, 2026
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
939 changes: 939 additions & 0 deletions docs/superpowers/plans/2026-06-08-adrf-report-views.md

Large diffs are not rendered by default.

223 changes: 223 additions & 0 deletions docs/superpowers/specs/2026-06-08-adrf-report-views-design.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion radis-client/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def test_report_data_valid():
assert report.is_valid()


@pytest.mark.django_db
@pytest.mark.django_db(transaction=True)
def test_report_data_post(live_server: LiveServer, mocker: MockerFixture):
# Make sure it won't try to save created reports to any full text search database
# as those are not available during test
Expand Down
94 changes: 94 additions & 0 deletions radis/reports/api/operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Async domain operations for the report API.

Each function is a pure async write operation using native async ORM
methods (`aget_or_create`, `acreate`, `aset`, `asave`, `adelete`, ...).
None of these functions open their own transactions — atomicity is the
caller's responsibility. The caller is a sync helper decorated with
`@sync_to_async(thread_sensitive=True)` + `@transaction.atomic` that
invokes these operations via `async_to_sync(...)`.

The `thread_sensitive=True` chain ensures the outer sync helper and any
nested `sync_to_async` adapters (which Django's `a*` ORM methods use
internally) all run on the same Django thread, so the transaction
context held by the outer helper applies to every write performed by
these operations.
"""
import logging
from typing import Any

from ..models import Language, Metadata, Modality, Report

logger = logging.getLogger(__name__)


async def create_report_from_validated(
validated_data: dict[str, Any],
) -> Report:
"""Create a Report and its nested associations from validated payload.

Pops `language`, `groups`, `metadata`, `modalities` out of
`validated_data` and uses the remaining keys as direct Report fields.
"""
language = validated_data.pop("language")
groups = validated_data.pop("groups")
metadata = validated_data.pop("metadata")
modalities = validated_data.pop("modalities")

language_instance, _ = await Language.objects.aget_or_create(**language)
report = await Report.objects.acreate(
**validated_data, language=language_instance
)

await report.groups.aset(groups)

for item in metadata:
await Metadata.objects.acreate(report=report, **item)

modality_instances: list[Modality] = []
for modality in modalities:
instance, _ = await Modality.objects.aget_or_create(**modality)
modality_instances.append(instance)
await report.modalities.aset(modality_instances)

return report


async def update_report_from_validated(
report: Report, validated_data: dict[str, Any]
) -> Report:
"""Replace all mutable fields and nested associations on an existing Report.

Matches the legacy `ReportSerializer.update` semantics: metadata is
fully replaced (delete + recreate), modalities and groups are reset
to the provided sets.
"""
language = validated_data.pop("language")
groups = validated_data.pop("groups")
metadata = validated_data.pop("metadata")
modalities = validated_data.pop("modalities")

language_instance = await Language.objects.aget(**language)
report.language = language_instance
for attr, value in validated_data.items():
setattr(report, attr, value)
await report.asave()

await report.groups.aset(groups)

await report.metadata.all().adelete()
for item in metadata:
await Metadata.objects.acreate(report=report, **item)

await report.modalities.aclear()
modality_instances: list[Modality] = []
for modality in modalities:
instance, _ = await Modality.objects.aget_or_create(**modality)
modality_instances.append(instance)
await report.modalities.aset(modality_instances)

return report


async def delete_report(report: Report) -> None:
"""Delete a single Report row."""
await report.adelete()
87 changes: 32 additions & 55 deletions radis/reports/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from typing import Any

from adrf.serializers import ModelSerializer as AsyncModelSerializer
from asgiref.sync import async_to_sync, sync_to_async
from django.db import transaction
from rest_framework import serializers, validators
from rest_framework.exceptions import ValidationError
from rest_framework.relations import PrimaryKeyRelatedField

from ..models import Language, Metadata, Modality, Report
from . import operations


class MetadataSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -42,7 +45,16 @@ def run_validation(self, data: dict[str, Any]) -> Any:
return super().run_validation(data)


class ReportSerializer(serializers.ModelSerializer):
class ReportSerializer(AsyncModelSerializer):
"""Async serializer for Report.

Subclasses `adrf.serializers.ModelSerializer` so callers can do
`await serializer.asave()` directly. `acreate` and `aupdate` below
delegate to the async write operations in `operations.py`. None of
these methods own a transaction — the caller (the view's
`@sync_to_async @transaction.atomic` helper) does.
"""
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

language = LanguageSerializer()
metadata = MetadataSerializer(many=True)
modalities = ModalitySerializer(many=True)
Expand Down Expand Up @@ -75,60 +87,25 @@ def _strip_unique_validator(self, field_name: str) -> None:
if not isinstance(validator, validators.UniqueValidator)
]

def create(self, validated_data: Any) -> Any:
language = validated_data.pop("language")
groups = validated_data.pop("groups")
metadata = validated_data.pop("metadata")
modalities = validated_data.pop("modalities")

with transaction.atomic():
language_instance, _ = Language.objects.get_or_create(**language)

report = Report.objects.create(**validated_data, language=language_instance)

report.groups.set(groups)

for metadata in metadata:
Metadata.objects.create(report=report, **metadata)

modality_instances: list[Modality] = []
for modality in modalities:
modality_instance, _ = Modality.objects.get_or_create(**modality)
modality_instances.append(modality_instance)

report.modalities.set(modality_instances)

return report

def update(self, report: Report, validated_data: Any) -> Any:
language = validated_data.pop("language")
groups = validated_data.pop("groups")
metadata = validated_data.pop("metadata")
modalities = validated_data.pop("modalities")

with transaction.atomic():
language_instance = Language.objects.get(**language)
report.language = language_instance

for attr, value in validated_data.items():
setattr(report, attr, value)

report.save()

report.groups.set(groups)

report.metadata.all().delete()
for metadata in metadata:
Metadata.objects.create(report=report, **metadata)

report.modalities.clear()
modality_instances: list[Modality] = []
for modality in modalities:
modality_instance, _ = Modality.objects.get_or_create(**modality)
modality_instances.append(modality_instance)
report.modalities.set(modality_instances)

return report
async def acreate(self, validated_data: Any) -> Report:
@sync_to_async(thread_sensitive=True)
@transaction.atomic
def _atomic() -> Report:
return async_to_sync(operations.create_report_from_validated)(
validated_data
)

return await _atomic()

async def aupdate(self, report: Report, validated_data: Any) -> Report:
@sync_to_async(thread_sensitive=True)
@transaction.atomic
def _atomic() -> Report:
return async_to_sync(operations.update_report_from_validated)(
report, validated_data
)

return await _atomic()

def to_internal_value(self, data: Any) -> Any:
if "language" in data:
Expand Down
4 changes: 2 additions & 2 deletions radis/reports/api/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from adrf.routers import DefaultRouter
from django.urls import include, path
from rest_framework.routers import DefaultRouter

from .viewsets import ReportViewSet

router = DefaultRouter()
router.register(r"", ReportViewSet)
router.register("", ReportViewSet, basename="report")

urlpatterns = [
path("", include(router.urls)),
Expand Down
Loading