Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,7 @@ docker exec -it cl-django python manage.py shell

- `rg` may be installed. Use it instead of `grep` if so.
- `gh` → GitHub CLI for PRs, issues, actions
- `pre-commit` → code quality checks (ruff, mypy, etc.)
- `pre-commit` → code quality checks (ruff, mypy, etc.). Its `check python ast`
hook already validates Python syntax on every edited file — MUST rely on
that instead of ad hoc `python -c "import ast; ast.parse(...)"` snippets.
- `uv` → Python dependency management (the only tool to use for deps)
112 changes: 15 additions & 97 deletions cl/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db import IntegrityError, connection
from django.http import HttpRequest, JsonResponse
from django.test import RequestFactory, SimpleTestCase, override_settings
from django.test.client import AsyncClient, AsyncRequestFactory
from django.test.utils import CaptureQueriesContext
Expand Down Expand Up @@ -68,11 +67,7 @@
promo_doubling_applies,
promo_switch_is_active,
)
from cl.api.views import (
build_chart_data,
coverage_data,
make_court_variable,
)
from cl.api.views import make_court_variable
from cl.api.webhooks import send_webhook_event
from cl.audio.api_views import AudioViewSet
from cl.audio.audio_sources import AudioSources
Expand Down Expand Up @@ -184,7 +179,6 @@ class BasicAPIPageTest(ESIndexTestCase, TestCase):

@classmethod
def setUpTestData(cls):
CourtFactory(id="ca1", jurisdiction=Court.FEDERAL_APPELLATE)
cls.rebuild_index("search.OpinionCluster")

def setUp(self) -> None:
Expand All @@ -205,16 +199,6 @@ async def test_court_index(self) -> None:
r = await self.async_client.get(reverse("court_index"))
self.assertEqual(r.status_code, 200)

async def test_coverage_api(self) -> None:
r = await self.async_client.get(
reverse("coverage_data", kwargs={"version": 4, "court": "ca1"})
)
self.assertEqual(r.status_code, 200)

async def test_coverage_api_via_url(self) -> None:
r = await self.async_client.get("/api/rest/v4/coverage/ca1/")
self.assertEqual(r.status_code, 200)

async def test_wiki_data_endpoint(self) -> None:
"""Does the wiki data endpoint return the expected JSON structure?"""
await caches["default"].adelete("wiki-data")
Expand Down Expand Up @@ -288,6 +272,20 @@ async def test_wiki_coverage_data_endpoint(self) -> None:
):
self.assertIsInstance(financial_disclosures[key], int)

async def test_wiki_coverage_data_rounds_oa_duration(self) -> None:
"""Is the total oral argument duration rounded to the nearest minute?

The wiki renders this value as-is, so CourtListener has to do the
rounding itself rather than serving a raw float.
"""
await caches["default"].adelete("wiki-coverage-data")
await sync_to_async(AudioFactory)(duration=250)
r = await self.async_client.get(reverse("wiki_coverage_data"))
data = json.loads(r.content)
duration_minutes = data["oral_arguments"]["duration_minutes"]
self.assertIsInstance(duration_minutes, int)
self.assertEqual(duration_minutes, 4)


@override_settings(
CACHES={
Expand Down Expand Up @@ -528,45 +526,6 @@ def setUpTestData(cls):
testing_mode=True,
)

async def test_coverage_data_view_provides_court_data(self) -> None:
response = await coverage_data(HttpRequest(), "v4", "ca1")
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response, JsonResponse)
self.assertContains(response, "annual_counts")
self.assertContains(response, "total")

async def test_coverage_data_all_courts(self) -> None:
r = await self.async_client.get(
reverse("coverage_data", kwargs={"version": "4", "court": "all"})
)
j = json.loads(r.content)
self.assertTrue(len(j["annual_counts"].keys()) > 0)
self.assertIn("total", j)

async def test_coverage_data_specific_court(self) -> None:
r = await self.async_client.get(
reverse(
"coverage_data", kwargs={"version": "4", "court": "scotus"}
)
)
j = json.loads(r.content)
self.assertEqual(len(j["annual_counts"].keys()), 25)
self.assertEqual(j["annual_counts"]["2000"], 1)
self.assertEqual(j["annual_counts"]["2024"], 1)
self.assertEqual(j["total"], 2)

# Ensure that coverage can be filtered using a query string.
r = await self.async_client.get(
reverse(
"coverage_data", kwargs={"version": "3", "court": "scotus"}
),
{"q": "America"},
)
j = json.loads(r.content)
self.assertEqual(len(j["annual_counts"].keys()), 1)
self.assertEqual(j["annual_counts"]["2024"], 1)
self.assertEqual(j["total"], 1)

async def test_make_court_variable(self) -> None:
"""Confirm opinions counts per court are properly returned."""

Expand All @@ -581,47 +540,6 @@ async def test_make_court_variable(self) -> None:
if court.pk == self.court_cand.pk:
self.assertEqual(1, court.count)

async def test_build_chart_data(self) -> None:
"""Confirm build_chart_data method returns the right data."""

chart_data = await sync_to_async(build_chart_data)(["scotus", "cand"])
for court_data in chart_data:
if (
court_data["group"]
== self.court_scotus.get_jurisdiction_display()
):
data = court_data["data"][0]
self.assertEqual(data["id"], self.court_scotus.pk)
self.assertEqual(data["label"], self.court_scotus.full_name)
self.assertEqual(data["data"][0]["val"], 2)

date_1 = datetime.fromisoformat(
data["data"][0]["timeRange"][0].replace("Z", "+00:00")
)
date_2 = datetime.fromisoformat(
data["data"][0]["timeRange"][1].replace("Z", "+00:00")
)
self.assertEqual(date_1.date(), self.c_scotus_1.date_filed)
self.assertEqual(date_2.date(), self.c_scotus_2.date_filed)

if (
court_data["group"]
== self.court_cand.get_jurisdiction_display()
):
data = court_data["data"][0]
self.assertEqual(data["id"], self.court_cand.pk)
self.assertEqual(data["label"], self.court_cand.full_name)
self.assertEqual(data["data"][0]["val"], 1)

date_1 = datetime.fromisoformat(
data["data"][0]["timeRange"][0].replace("Z", "+00:00")
)
date_2 = datetime.fromisoformat(
data["data"][0]["timeRange"][1].replace("Z", "+00:00")
)
self.assertEqual(date_1.date(), self.c_cand_1.date_filed)
self.assertEqual(date_2.date(), self.c_cand_1.date_filed)


@mock.patch(
"cl.api.utils.get_logging_prefix",
Expand Down
10 changes: 0 additions & 10 deletions cl/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,6 @@
views.wiki_coverage_data,
name="wiki_coverage_data",
),
re_path(
r"^api/rest/v4/coverage/opinions/",
views.coverage_data_opinions,
name="coverage_data_opinions",
),
re_path(
r"^api/rest/v(?P<version>[1234])/coverage/(?P<court>.+)/$",
views.coverage_data,
name="coverage_data",
),
re_path(
r"^api/rest/v(?P<version>[1234])/alert-frequency/(?P<day_count>\d+)/$",
views.get_result_count,
Expand Down
78 changes: 5 additions & 73 deletions cl/api/views.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import logging
import re
from collections.abc import Awaitable, Callable
from datetime import date, datetime, timedelta
from http import HTTPStatus
from typing import TypedDict, cast

from asgiref.sync import async_to_sync, sync_to_async
from asgiref.sync import sync_to_async
from django.conf import settings
from django.contrib.auth.models import User
from django.core.cache import cache
from django.db.models import QuerySet, Sum
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import aget_object_or_404 # type: ignore[attr-defined]
from django.template.response import TemplateResponse
from django.urls import reverse
from django.views.decorators.cache import cache_page
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -31,10 +28,7 @@
from cl.donate.models import NeonMembership, NeonMembershipLevel
from cl.favorites.models import Prayer
from cl.favorites.utils import get_lifetime_prayer_stats
from cl.lib.elasticsearch_utils import (
get_court_opinions_counts,
get_opinions_coverage_over_time,
)
from cl.lib.elasticsearch_utils import get_court_opinions_counts
from cl.lib.url_utils import BASE_URL
from cl.people_db.models import Person
from cl.search.documents import (
Expand All @@ -43,15 +37,11 @@
from cl.search.exception import ElasticBadRequestError, ElasticServerError
from cl.search.models import Citation, Court, OpinionCluster
from cl.search.utils import get_redis_stat_sum
from cl.simple_pages.coverage_utils import build_chart_data
from cl.simple_pages.views import get_coverage_data_fds
from cl.stats.constants import StatMetric

logger = logging.getLogger(__name__)

max_court_id_length = Court._meta.get_field("id").max_length
VALID_COURT_ID_REGEX = re.compile(rf"^\w{{1,{max_court_id_length}}}$")


async def get_cached_court_counts(courts_queryset: QuerySet) -> dict[str, int]:
"""Fetch court counts from cache or ES if not available.
Expand Down Expand Up @@ -101,34 +91,6 @@ async def court_index(request: HttpRequest) -> HttpResponse:
)


async def coverage_data(request, version, court):
"""Provides coverage data for a court.

Responds to either AJAX or regular requests.
"""

if court != "all":
court_str = (await aget_object_or_404(Court, pk=court)).pk
else:
court_str = "all"
q = request.GET.get("q")
opinions_coverage = await sync_to_async(get_opinions_coverage_over_time)(
OpinionClusterDocument.search(), court_str, q, "dateFiled"
)
# Calculate the totals
annual_counts = {}
total_docs = 0
for year_coverage in opinions_coverage:
annual_counts[year_coverage["key_as_string"]] = year_coverage[
"doc_count"
]
total_docs += year_coverage["doc_count"]

return JsonResponse(
{"annual_counts": annual_counts, "total": total_docs}, safe=True
)


async def fetch_first_last_date_filed(
court_id: str,
) -> tuple[date | None, date | None]:
Expand All @@ -146,38 +108,6 @@ async def fetch_first_last_date_filed(
return None, None


@sync_to_async
@cache_page(7 * 60 * 60 * 24, key_prefix="coverage")
@async_to_sync
async def coverage_data_opinions(request: HttpRequest):
"""Generate Coverage Chart Data

Accept GET to query court data for timelines-chart on coverage page

:param request: The HTTP request
:return: Timeline data for court(s)
"""

if request.method != "GET":
return JsonResponse([], safe=False)

court_ids = request.GET.get("court_ids", "").strip() # type: ignore
if not court_ids:
return JsonResponse([], safe=False)

# Clean and validate court_ids
valid_court_ids = [
court_id.strip()
for court_id in court_ids.split(",")
if court_id.strip() and VALID_COURT_ID_REGEX.match(court_id.strip())
]
if not valid_court_ids:
return JsonResponse([], safe=False)

chart_data = await sync_to_async(build_chart_data)(valid_court_ids)
return JsonResponse(chart_data, safe=False)


async def get_result_count(request, version, day_count):
"""Get the count of results for the past `day_count` number of days

Expand Down Expand Up @@ -507,7 +437,9 @@ async def build_wiki_coverage_data(bust_cache: bool = False) -> dict:
oa_aggregate = await Audio.objects.aaggregate(Sum("duration"))
oa_duration = oa_aggregate["duration__sum"]
if oa_duration:
oa_duration /= 60 # Avoids a "unsupported operand type" error
# Round to the nearest minute — the wiki renders this value as-is,
# so it can't do any rounding of its own.
oa_duration = round(oa_duration / 60)

return {
"judges": {
Expand Down
Loading
Loading