Skip to content

feat(recap): Enhances appellate docket purchase to support ACMS cases #5960

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
14 changes: 14 additions & 0 deletions cl/corpus_importer/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ async def ais_appellate_court(court_id: str) -> bool:
return await appellate_court_ids.filter(pk=court_id).aexists()


def should_check_acms_court(court_id: str) -> bool:
"""
Checks whether the given court_id should be checked using ACMS-specific logic.

This helper is used to identify courts that require special handling,
currently limited to a known set of appellate courts.

:param court_id: The unique identifier of the court.

:return: True if the court_id is one that uses ACMS; False otherwise.
"""
return court_id in ["ca2", "ca9"]


def get_start_of_quarter(d: date | None = None) -> date:
"""Get the start date of the calendar quarter requested

Expand Down
17 changes: 15 additions & 2 deletions cl/recap/mergers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Code for merging PACER content into the DB
import json
import logging
import re
from copy import deepcopy
Expand Down Expand Up @@ -1584,9 +1585,21 @@ def merge_pacer_docket_into_cl_docket(
UPLOAD_TYPE.APPELLATE_DOCKET if appellate else UPLOAD_TYPE.DOCKET
)
pacer_file = PacerHtmlFiles(content_object=d, upload_type=upload_type)

# Determine how to store the report data.
# Most PACER reports include a raw HTML response and set the `response`
# attribute. However, ACMS reports typically construct the data from a
# series of API calls, and do not include a single HTML response. In those
# cases, we store the data as JSON instead.
pacer_file_name = "docket.html" if report.response else "docket.json"
pacer_file_content = (
report.response.text.encode()
if report.response
else json.dumps(report.data, default=str).encode()
)
pacer_file.filepath.save(
"docket.html", # We only care about the ext w/S3PrivateUUIDStorageTest
ContentFile(report.response.text.encode()),
pacer_file_name, # We only care about the ext w/S3PrivateUUIDStorageTest
ContentFile(pacer_file_content),
)

# Merge parties before adding docket entries, so they can access parties'
Expand Down
35 changes: 32 additions & 3 deletions cl/recap/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from juriscraper.lib.string_utils import CaseNameTweaker, harmonize
from juriscraper.pacer import (
ACMSAttachmentPage,
AcmsCaseSearch,
ACMSDocketReport,
AppellateDocketReport,
CaseQuery,
Expand Down Expand Up @@ -65,6 +66,7 @@
is_bankruptcy_court,
is_long_appellate_document_number,
mark_ia_upload_needed,
should_check_acms_court,
)
from cl.custom_filters.templatetags.text_filters import oxford_join
from cl.lib.filesizes import convert_size_to_bytes
Expand Down Expand Up @@ -2348,7 +2350,7 @@ def create_or_update_docket_data_from_fetch(
fq: PacerFetchQueue,
court_id: str,
pacer_case_id: str | None,
report: DocketReport | AppellateDocketReport,
report: DocketReport | AppellateDocketReport | ACMSDocketReport,
docket_data: dict[str, Any],
) -> dict[str, str | bool]:
"""Creates or updates docket data in the database from fetched data.
Expand Down Expand Up @@ -2420,12 +2422,39 @@ def purchase_appellate_docket_by_docket_number(
:param fq: The PacerFetchQueue object
:return: a dict with information about the docket and the new data
"""
report = AppellateDocketReport(map_cl_to_pacer_id(court_id), session)
report.query(docket_number, **kwargs)
acms_case_id = None

if should_check_acms_court(court_id):
acms_search = AcmsCaseSearch(court_id="ca9", pacer_session=session)
acms_search.query(docket_number)
acms_case_id = (
acms_search.data["pcx_caseid"] if acms_search.data else None
)

pacer_court_id = map_cl_to_pacer_id(court_id)
report_class = ACMSDocketReport if acms_case_id else AppellateDocketReport
report = report_class(pacer_court_id, session)

if acms_case_id:
# ACMSDocketReport only accepts the case ID; filters are not currently
# supported for ACMS docket reports.
report.query(acms_case_id)
else:
report.query(docket_number, **kwargs)

docket_data = report.data
if not docket_data:
raise ParsingException("No data found in docket report.")

if acms_case_id:
docket_data["docket_entries"] = sorted(
docket_data["docket_entries"],
key=lambda d: (
d["date_filed"],
d["document_number"] is None,
d["document_number"],
),
)
Copy link
Contributor

Choose a reason for hiding this comment

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

I remember we have this sorting logic in a different method. Can we create a method with this logic that can be reused?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're correct. I'll add the new helper method and refactor this code

Copy link
Contributor

Choose a reason for hiding this comment

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

btw when testing the fetch attachment page PR I found this docket:
https://www.courtlistener.com/docket/68490335/united-states-of-america-v-raji/

Which I retrieved using the fetch API in my local env.

As you can see the entry 17 is in the wrong place due to date Field is being prioritized over the document number.

Screenshot 2025-07-16 at 2 45 32 p m

I checked in PACER and the date filed is correct.

Would it be possible to prioritize the document number, and use the date filed if it's None?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since we just introduced a method to sort ACM entries, I think we could check whether all docket entries in the input have a document_number. If they do, we can sort purely by that number. Otherwise, we fall back to the existing sort function. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, I think this makes sense. We can leverage the fact that all entries are available when fetching the docket.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

cool! i'll refine the new helper method

return create_or_update_docket_data_from_fetch(
fq, court_id, None, report, docket_data
)
Expand Down
211 changes: 203 additions & 8 deletions cl/recap/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@
get_next_webhook_retry_date,
get_webhook_deprecation_date,
)
from cl.corpus_importer.utils import is_appellate_court
from cl.corpus_importer.utils import (
is_appellate_court,
should_check_acms_court,
)
from cl.lib.pacer import is_pacer_court_accessible, lookup_and_save
from cl.lib.recap_utils import needs_ocr
from cl.lib.redis_utils import get_redis_interface
Expand Down Expand Up @@ -2481,6 +2484,18 @@ def setUpTestData(cls) -> None:
docket_number=fakes.DOCKET_NUMBER,
case_name=fakes.CASE_NAME,
)
cls.ca9_acms_court = CourtFactory(
id="ca9", jurisdiction=Court.FEDERAL_APPELLATE
)
cls.ca2_acms_court = CourtFactory(
id="ca2", jurisdiction=Court.FEDERAL_APPELLATE
)
cls.acms_docket = DocketFactory(
source=Docket.RECAP,
court=cls.ca2_acms_court,
docket_number="25-1671",
case_name="G.F.F. v. Trump",
)

def setUp(self) -> None:
self.user = User.objects.get(username="recap")
Expand Down Expand Up @@ -2541,12 +2556,18 @@ def test_fetch_docket_by_docket_id(
"cl.recap.tasks.AppellateDocketReport",
new=fakes.FakeAppellateDocketReport,
)
@mock.patch("cl.recap.tasks.AcmsCaseSearch")
@mock.patch(
"cl.recap.tasks.should_check_acms_court", wraps=should_check_acms_court
)
@mock.patch(
"cl.recap.tasks.fetch_appellate_docket", wraps=fetch_appellate_docket
)
def test_fetch_appellate_docket_by_docket_id(
self,
mock_fetch_appellate_docket,
mock_should_check_acms_court,
mock_acms_case_search,
mock_court_accessible,
mock_cookies,
):
Expand All @@ -2569,41 +2590,215 @@ def test_fetch_appellate_docket_by_docket_id(
# correct fetch queue ID.
mock_fetch_appellate_docket.si.assert_called_once_with(fq.pk)

# Verify that court_id validation was performed before attempting to
# retrieve the docket report.
mock_should_check_acms_court.assert_called_once_with(
self.appellate_docket.court_id
)

# Verify that the ACMS case search was not triggered since the court
# is ca1.
mock_acms_case_search.assert_not_called()

# Assert that a RECAPDocument was created.
rds = RECAPDocument.objects.all()
rds = RECAPDocument.objects.filter(
docket_entry__docket=self.appellate_docket
)
self.assertEqual(rds.count(), 1)

@mock.patch(
"cl.recap.tasks.AppellateDocketReport",
new=fakes.FakeAppellateDocketReport,
new=fakes.FakeNewAppellateCaseDocketReport,
)
@mock.patch("cl.recap.tasks.AcmsCaseSearch")
@mock.patch(
"cl.recap.tasks.should_check_acms_court", wraps=should_check_acms_court
)
@mock.patch(
"cl.recap.tasks.fetch_appellate_docket", wraps=fetch_appellate_docket
)
def test_fetch_appellate_docket_by_docket_number(
self, mock_fetch_appellate_docket, mock_court_accessible, mock_cookies
self,
mock_fetch_appellate_docket,
mock_should_check_acms_court,
mock_acms_case_search,
mock_court_accessible,
mock_cookies,
) -> None:
fq = PacerFetchQueue.objects.create(
user=self.user,
request_type=REQUEST_TYPE.DOCKET,
court_id=self.appellate_court.pk,
docket_number=self.appellate_docket.docket_number,
docket_number="10-1081",
)
result = do_pacer_fetch(fq)
result.get()

# Refresh the fetch queue entry from the database to get the updated
# status.
fq.refresh_from_db()
self.assertEqual(fq.docket, self.appellate_docket)
self.assertIsNotNone(fq.docket)
self.assertEqual(fq.status, PROCESSING_STATUS.SUCCESSFUL)

# Assert that the fetch_appellate_docket task was called once with the
# correct fetch queue ID.
mock_fetch_appellate_docket.si.assert_called_once_with(fq.pk)

# Assert that a RECAPDocument was created.
rds = RECAPDocument.objects.all()
# Verify that court_id validation was performed before attempting to
# retrieve the docket report.
mock_should_check_acms_court.assert_called_once_with(
self.appellate_docket.court_id
)

# Verify that the ACMS case search was not triggered since the court
# is ca1.
mock_acms_case_search.assert_not_called()

# Verify that the docket was created.
appellate_docket = Docket.objects.filter(pacer_case_id="49959").first()
self.assertIsNotNone(appellate_docket)

# Check that the docket fields match the expected fake data.
self.assertEqual(appellate_docket.court_id, "ca1")
self.assertEqual(appellate_docket.docket_number, "10-1081")
self.assertEqual(appellate_docket.case_name, "United States v. Brown")

# Verify that a RECAPDocument was created and linked to the docket.
rds = RECAPDocument.objects.filter(
docket_entry__docket=appellate_docket
)
self.assertEqual(rds.count(), 1)

@mock.patch(
"cl.recap.tasks.ACMSDocketReport", new=fakes.FakeAcmsDocketReport
)
@mock.patch("cl.recap.tasks.AcmsCaseSearch", new=fakes.FakeAcmsCaseSearch)
@mock.patch("cl.recap.tasks.AppellateDocketReport")
@mock.patch(
"cl.recap.tasks.should_check_acms_court", wraps=should_check_acms_court
)
@mock.patch(
"cl.recap.tasks.fetch_appellate_docket", wraps=fetch_appellate_docket
)
def test_can_fetch_acms_docket_by_docket_number(
self,
mock_fetch_appellate_docket,
mock_should_check_acms_court,
mock_appellate_docket_report,
mock_court_accessible,
mock_cookies,
):
# Ensure the docket does not exist before the fetch.
self.assertFalse(
Docket.objects.filter(docket_number="25-4097").exists()
)

fq = PacerFetchQueue.objects.create(
user=self.user,
request_type=REQUEST_TYPE.DOCKET,
court_id=self.ca9_acms_court.pk,
docket_number="25-4097",
)
result = do_pacer_fetch(fq)
result.get()

# Refresh the fetch queue entry from the database to get the updated
# status.
fq.refresh_from_db()
self.assertIsNotNone(fq.docket)
self.assertEqual(fq.status, PROCESSING_STATUS.SUCCESSFUL)

# Assert that the fetch_appellate_docket task was called once with the
# correct fetch queue ID.
mock_fetch_appellate_docket.si.assert_called_once_with(fq.pk)

# Verify that court_id validation was performed before attempting to
# retrieve the docket report.
mock_should_check_acms_court.assert_called_once_with(
self.ca9_acms_court.pk
)

# Confirm that AppellateDocketReport was not used for this ACMS case.
mock_appellate_docket_report.assert_not_called()

# Verify that the docket was created.
acms_docket = Docket.objects.filter(docket_number="25-4097").first()
self.assertIsNotNone(acms_docket)

# Check that the docket fields match the expected fake data.
self.assertEqual(acms_docket.court_id, "ca9")
self.assertEqual(acms_docket.docket_number, "25-4097")
self.assertEqual(
acms_docket.case_name, "Wortman, et al. v. All Nippon Airways"
)

# Verify that a RECAPDocument was created and linked to the docket.
rds = RECAPDocument.objects.filter(docket_entry__docket=acms_docket)
self.assertEqual(rds.count(), 1)

@mock.patch(
"cl.recap.tasks.ACMSDocketReport",
new=fakes.FakeAcmsDocketReportToUpdate,
)
@mock.patch("cl.recap.tasks.AcmsCaseSearch", new=fakes.FakeAcmsCaseSearch)
@mock.patch("cl.recap.tasks.AppellateDocketReport")
@mock.patch(
"cl.recap.tasks.should_check_acms_court", wraps=should_check_acms_court
)
@mock.patch(
"cl.recap.tasks.fetch_appellate_docket", wraps=fetch_appellate_docket
)
def test_can_fetch_acms_docket_by_docket_id(
self,
mock_fetch_appellate_docket,
mock_should_check_acms_court,
mock_appellate_docket_report,
mock_court_accessible,
mock_cookies,
):
# Verify that the docket initially has no associated RECAP documents.
rds = RECAPDocument.objects.filter(
docket_entry__docket=self.acms_docket
)
self.assertEqual(rds.count(), 0)

fq = PacerFetchQueue.objects.create(
user=self.user,
request_type=REQUEST_TYPE.DOCKET,
court_id=self.ca2_acms_court.pk,
docket_id=self.acms_docket.pk,
)
result = do_pacer_fetch(fq)
result.get()

# Refresh the fetch queue entry from the database to get the updated
# status.
fq.refresh_from_db()
self.assertIsNotNone(fq.docket)
self.assertEqual(fq.status, PROCESSING_STATUS.SUCCESSFUL)

# Assert that the fetch_appellate_docket task was called once with the
# correct fetch queue ID.
mock_fetch_appellate_docket.si.assert_called_once_with(fq.pk)

# Verify that court_id validation was performed before attempting to
# retrieve the docket report.
mock_should_check_acms_court.assert_called_once_with(
self.ca2_acms_court.pk
)

# Ensure the standard AppellateDocketReport was not used, since this is
# an ACMS case.
mock_appellate_docket_report.assert_not_called()

# Verify that no duplicate was created.
acms_dockets = Docket.objects.filter(court_id="ca2")
self.assertIsNotNone(acms_dockets.count(), 1)

# Verify that a RECAPDocument was created and linked to the docket.
rds = RECAPDocument.objects.filter(
docket_entry__docket=self.acms_docket
)
self.assertEqual(rds.count(), 1)

def test_fetch_docket_send_alert(
Expand Down
Loading