Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
73 changes: 73 additions & 0 deletions radis/reports/api/operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Async write operations for Report. Callers own atomicity."""
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:
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.

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:
await report.adelete()
87 changes: 28 additions & 59 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 All @@ -20,8 +23,7 @@ class Meta:
fields = ("code",)

def run_validation(self, data: dict[str, Any]) -> Any:
# We don't want to check if this modality already exists in the database
# as we later use get_or_create.
# Strip the UniqueValidator; `acreate`/`aupdate` use `get_or_create`.
for validator in self.fields["code"].validators:
if isinstance(validator, validators.UniqueValidator):
self.fields["code"].validators.remove(validator)
Expand All @@ -34,15 +36,17 @@ class Meta:
fields = ("code",)

def run_validation(self, data: dict[str, Any]) -> Any:
# We don't want to check if this modality already exists in the database
# as we later use get_or_create.
# Strip the UniqueValidator; `acreate`/`aupdate` use `get_or_create`.
for validator in self.fields["code"].validators:
if isinstance(validator, validators.UniqueValidator):
self.fields["code"].validators.remove(validator)
return super().run_validation(data)


class ReportSerializer(serializers.ModelSerializer):
class ReportSerializer(AsyncModelSerializer):
"""`acreate`/`aupdate` own the atomic block that bounds the multi-step
write of Language → Report → groups → Metadata → Modalities."""

language = LanguageSerializer()
metadata = MetadataSerializer(many=True)
modalities = ModalitySerializer(many=True)
Expand Down Expand Up @@ -75,60 +79,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