Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions fec/home/migrations/0149_courtcaseindexpage_cross_references.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 5.1.15 on 2026-02-18 19:13

import wagtail.fields
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('home', '0148_alter_courtcasepage_sections_and_more'),
]

operations = [
migrations.AddField(
model_name='courtcaseindexpage',
name='cross_references',
field=wagtail.fields.StreamField([('cross_reference', 3)], blank=True, block_lookup={0: ('wagtail.blocks.CharBlock', (), {'help_text': 'Plain-text title for the index (e.g., "Americans for Change: FEC v.")', 'label': 'Title'}), 1: ('wagtail.blocks.PageChooserBlock', (), {'page_type': ['home.CourtCasePage']}), 2: ('wagtail.blocks.ListBlock', (1,), {'help_text': 'Court case pages to link in the "See:" line', 'label': 'See also cases'}), 3: ('wagtail.blocks.StructBlock', [[('title', 0), ('see_also_cases', 2)]], {})}, help_text='Cross-reference entries that appear as plain text with "See:" links', null=True),
),
]
75 changes: 60 additions & 15 deletions fec/home/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,17 @@
logger = logging.getLogger(__name__)


def extract_case_numbers(title):
def extract_first_case_number(title):
"""
Extract case numbers from a court case title for sorting.
Extract the first case number from a court case title for sorting.
Case numbers appear in formats like (19-1021), (20-0588 / 21-5081), etc.
Returns a list of (year, number) tuples, sorted descending by year then number.
Returns the first (year, number) tuple found, or None if no case number exists.
"""
import re
pattern = r'(\d{2})-(\d+)'
matches = re.findall(pattern, title)
if not matches:
return []
numbers = [(int(year), int(num)) for year, num in matches]
numbers.sort(reverse=True)
return numbers
match = re.search(r'(\d{2})-(\d+)', title)
if not match:
return None
return (int(match.group(1)), int(match.group(2)))


def get_sort_key_for_title(title):
Expand Down Expand Up @@ -106,6 +103,18 @@ def num_to_words(n):
return title


class CrossReferenceEntry:
"""Lightweight stand-in for a CourtCasePage used by cross-reference entries."""
is_cross_reference = True

def __init__(self, title, referenced_cases):
self.title = title
self.index_title = title
self.referenced_cases = referenced_cases # list of CourtCasePage instances
self.status = 'closed'
self.opinions = ''


def court_case_sort_key(case, get_sort_key_func=None):
"""
Generate a sort key for a court case.
Expand All @@ -128,12 +137,12 @@ def court_case_sort_key(case, get_sort_key_func=None):
alpha_title = re.sub(r'\s*\([^)]*\)\s*$', '', title).strip()
alpha_key = sort_key_func(alpha_title).lower()

# Get case numbers, negated for descending sort
case_nums = extract_case_numbers(title)
# Negate so higher numbers sort first; use (0, 0) if no case numbers
negated = tuple((-year, -num) for year, num in case_nums) if case_nums else ((0, 0),)
# Sort by the first case number in the title, with higher numbers first (reverse chronological)
first_case_num = extract_first_case_number(title)
# Negate so higher numbers sort first; use (0, 0) if no case number
num_key = (-first_case_num[0], -first_case_num[1]) if first_case_num else (0, 0)

return (alpha_key, negated)
return (alpha_key, num_key)


"""options for wagtail default table_block """
Expand Down Expand Up @@ -1168,11 +1177,25 @@ class CourtCaseIndexPage(ContentPage):
default='',
blank=True
)
cross_references = StreamField([
('cross_reference', blocks.StructBlock([
('title', blocks.CharBlock(
label='Title',
help_text='Plain-text title for the index (e.g., "Americans for Change: FEC v.")'
)),
('see_also_cases', blocks.ListBlock(
blocks.PageChooserBlock(page_type='home.CourtCasePage'),
label='See also cases',
help_text='Court case pages to link in the "See:" line'
)),
]))
], null=True, blank=True, help_text='Cross-reference entries that appear as plain text with "See:" links')

subpage_types = ['CourtCasePage']

content_panels = Page.content_panels + [
FieldPanel('intro'),
FieldPanel('cross_references'),
FieldPanel('body'),
FieldPanel('sidebar'),
FieldPanel('record_articles'),
Expand Down Expand Up @@ -1209,6 +1232,20 @@ def get_context(self, request):
# Convert to list and sort using custom sort key
# Sorts alphabetically (by index_title or title), then by case numbers (higher first) for same titles
cases_list = list(all_cases)

# Merge cross-reference entries from the StreamField
if self.cross_references:
for block in self.cross_references:
if block.block_type == 'cross_reference':
referenced_cases = [
page.specific for page in block.value['see_also_cases']
]
entry = CrossReferenceEntry(
title=block.value['title'],
referenced_cases=referenced_cases,
)
cases_list.append(entry)

cases_list.sort(key=lambda c: court_case_sort_key(c))

total_cases_count = len(cases_list)
Expand Down Expand Up @@ -1251,6 +1288,8 @@ def content_section(self):


class CourtCasePage(Page):
is_cross_reference = False

index_title = models.CharField(
max_length=255,
blank=True,
Expand Down Expand Up @@ -1318,6 +1357,12 @@ class CourtCasePage(Page):
FieldPanel('selected_court_case'),
]

search_fields = Page.search_fields + [
index.SearchField('index_title'),
index.AutocompleteField('index_title'),
index.FilterField('status'),
]

parent_page_types = ['CourtCaseIndexPage', 'ResourcePage']

@property
Expand Down
9 changes: 9 additions & 0 deletions fec/home/templates/home/court_case_index_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ <h3 class="t-ruled--bold">{{ letter }}</h3>
{% for case in cases %}
<tr class="simple-table__row">
<td class="simple-table__cell align-top">
{% if case.is_cross_reference %}
<span>{{ case.title }}</span>
<div class="t-sans u-padding--top--small">
See: {% for ref_case in case.referenced_cases %}
<a href="{{ ref_case.url }}">{{ ref_case.title }}</a>{% if not forloop.last %}; {% endif %}
{% endfor %}
</div>
{% else %}
<a href="{{ case.url }}">{% if case.index_title %}{{ case.index_title }}{% else %}{{ case.title }}{% endif %}</a>
{% if case.status == 'active' %}
<div class="t-data t-bold">
Expand All @@ -110,6 +118,7 @@ <h3 class="t-ruled--bold">{{ letter }}</h3>
{% endfor %}
</div>
{% endif %}
{% endif %}
</td>
<td class="simple-table__cell align-top">
{% if case.opinions %}
Expand Down
131 changes: 130 additions & 1 deletion fec/home/tests/test_court_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
CourtCasePage,
CourtCaseIndexPage,
ResourcePage,
HomePage
HomePage,
court_case_sort_key,
)
from ..templatetags.active_court_cases import active_court_cases
from ..templatetags.selected_court_cases import selected_court_cases
Expand Down Expand Up @@ -102,6 +103,28 @@ def test_index_title_fallback(self):
# The template uses index_title if present
self.assertEqual(court_case.index_title, "Short Name: FEC v.")

def test_search_fields_defined(self):
"""Test that CourtCasePage has search_fields for the page chooser search.

The page chooser uses autocomplete(), so AutocompleteField entries
are required for the chooser's search bar to work.
"""
from wagtail.search import index

field_names = [f.field_name for f in CourtCasePage.search_fields]
# title comes from Page.search_fields
self.assertIn('title', field_names)
# index_title is added by CourtCasePage
self.assertIn('index_title', field_names)

# Verify AutocompleteField entries exist (needed by page chooser)
autocomplete_fields = [
f.field_name for f in CourtCasePage.search_fields
if isinstance(f, index.AutocompleteField)
]
self.assertIn('title', autocomplete_fields)
self.assertIn('index_title', autocomplete_fields)


class CourtCaseIndexPageTests(WagtailPageTests):
"""Tests for CourtCaseIndexPage model"""
Expand Down Expand Up @@ -288,6 +311,50 @@ def test_index_page_handles_numeric_titles(self):
self.assertContains(response, '21st Century Fund v. FEC')
self.assertContains(response, '501(c)(4) Organization v. FEC')

def test_cross_reference_cases_render_as_plain_text(self):
"""Test that cross-reference entries from the StreamField render as
plain text with 'See:' links, while normal cases render as links."""
# Create a target case (the case being referenced)
target_case = CourtCasePage(
title="Wagner v. FEC",
slug="wagner-v-fec",
index_title="Wagner: FEC v.",
opinions="<p>Some opinion</p>",
)
self.index_page.add_child(instance=target_case)
target_case.save()

# Add a cross-reference entry on the index page's StreamField
self.index_page.cross_references = [
('cross_reference', {
'title': 'Miller: FEC v.',
'see_also_cases': [target_case],
})
]
self.index_page.save()

client = Client()
response = client.get(self.index_page.url)
content = response.content.decode()

self.assertEqual(response.status_code, 200)

# Cross-reference entry should be rendered as plain text (span), not a link
self.assertIn('<span>Miller: FEC v.</span>', content)

# "See:" line with the target case as a link
self.assertIn('See:', content)
self.assertIn(
'<a href="{}">Wagner v. FEC</a>'.format(target_case.url),
content,
)

# The target case itself should still render as a normal link
self.assertIn(
'<a href="{}">Wagner: FEC v.</a>'.format(target_case.url),
content,
)

def test_get_sort_key_converts_numbers_to_words(self):
"""Test that get_sort_key properly converts leading numbers"""
# Test the helper method directly
Expand All @@ -304,6 +371,68 @@ def test_get_sort_key_converts_numbers_to_words(self):
self.assertEqual(result, "one hundred Citizens Group")


class CourtCaseSortKeyTests(TestCase):
"""Tests for court_case_sort_key function"""

def setUp(self):
self.home_page = HomePage.objects.first()
if not self.home_page:
root_page = Page.objects.get(depth=1)
self.home_page = HomePage(title="Home", slug="home")
root_page.add_child(instance=self.home_page)

def test_same_name_cases_sort_reverse_chronologically(self):
"""Test that cases with the same name sort by first case number, newest first"""
case_oldest = CourtCasePage(
title="Campaign Legal Center v. FEC (21-1376)",
slug="clc-v-fec-21-1376",
)
self.home_page.add_child(instance=case_oldest)

case_mid = CourtCasePage(
title="Campaign Legal Center v. FEC (22-838)",
slug="clc-v-fec-22-838",
)
self.home_page.add_child(instance=case_mid)

case_newest = CourtCasePage(
title="Campaign Legal Center v. FEC (22-1976 / 22-5339)",
slug="clc-v-fec-22-1976",
)
self.home_page.add_child(instance=case_newest)

cases = [case_oldest, case_mid, case_newest]
sorted_cases = sorted(cases, key=lambda c: court_case_sort_key(c))

# Reverse chronological: newest (highest first case number) first
self.assertEqual(sorted_cases[0].slug, "clc-v-fec-22-1976")
self.assertEqual(sorted_cases[1].slug, "clc-v-fec-22-838")
self.assertEqual(sorted_cases[2].slug, "clc-v-fec-21-1376")

def test_sort_uses_first_case_number_not_highest(self):
"""Test that sorting uses the first case number in the title, not the highest"""
# Case A has a lower first number (20-100) but a higher second number (23-9999)
case_a = CourtCasePage(
title="Test v. FEC (20-100 / 23-9999)",
slug="test-v-fec-a",
)
self.home_page.add_child(instance=case_a)

# Case B has a higher first number (22-500)
case_b = CourtCasePage(
title="Test v. FEC (22-500)",
slug="test-v-fec-b",
)
self.home_page.add_child(instance=case_b)

sorted_cases = sorted([case_a, case_b], key=lambda c: court_case_sort_key(c))

# Case B's first number (22-500) is higher than Case A's first number (20-100)
# so Case B should sort first (reverse chronological)
self.assertEqual(sorted_cases[0].slug, "test-v-fec-b")
self.assertEqual(sorted_cases[1].slug, "test-v-fec-a")


class CourtCaseTemplateTagTests(TestCase):
"""Tests for court case template tags"""

Expand Down