diff --git a/fec/home/migrations/0149_courtcaseindexpage_cross_references.py b/fec/home/migrations/0149_courtcaseindexpage_cross_references.py new file mode 100644 index 000000000..217d64836 --- /dev/null +++ b/fec/home/migrations/0149_courtcaseindexpage_cross_references.py @@ -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), + ), + ] diff --git a/fec/home/models.py b/fec/home/models.py index 411891521..f43c1b6d0 100644 --- a/fec/home/models.py +++ b/fec/home/models.py @@ -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): @@ -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. @@ -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 """ @@ -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'), @@ -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) @@ -1251,6 +1288,8 @@ def content_section(self): class CourtCasePage(Page): + is_cross_reference = False + index_title = models.CharField( max_length=255, blank=True, @@ -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 diff --git a/fec/home/templates/home/court_case_index_page.html b/fec/home/templates/home/court_case_index_page.html index b42929237..a294b4c90 100644 --- a/fec/home/templates/home/court_case_index_page.html +++ b/fec/home/templates/home/court_case_index_page.html @@ -95,6 +95,14 @@
Some opinion
", + ) + 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('Miller: FEC v.', content) + + # "See:" line with the target case as a link + self.assertIn('See:', content) + self.assertIn( + 'Wagner v. FEC'.format(target_case.url), + content, + ) + + # The target case itself should still render as a normal link + self.assertIn( + 'Wagner: FEC v.'.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 @@ -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"""