From 291e4273e31c0c709a4d6876c18e4a2976ed51fd Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 1 May 2024 14:23:38 -0400 Subject: [PATCH 01/10] feat(templates): Enhance I'm Now Following [#332] --- bc/channel/tests/factories.py | 6 + bc/core/tests/test_make_dev_data.py | 18 +- bc/core/tests/test_templates.py | 752 +++++++++++++++++++++---- bc/core/utils/status/base.py | 102 +++- bc/core/utils/status/selectors.py | 50 +- bc/core/utils/status/templates.py | 131 +++-- bc/subscription/tasks.py | 50 +- bc/subscription/tests/test_tasks.py | 60 +- bc/subscription/utils/courtlistener.py | 68 ++- bc/subscription/views.py | 4 +- 10 files changed, 1013 insertions(+), 228 deletions(-) diff --git a/bc/channel/tests/factories.py b/bc/channel/tests/factories.py index f1d9de7d..cd971ed3 100644 --- a/bc/channel/tests/factories.py +++ b/bc/channel/tests/factories.py @@ -88,6 +88,12 @@ class Meta: group = SubFactory(GroupFactory) class Params: + bluesky = factory.Trait( + service=Channel.BLUESKY, + account="BigCases2-faux.bsky.social", + account_id="Bigsky-big-cases-email-faux", + enabled=True, + ) mastodon = factory.Trait( service=Channel.MASTODON, account="BigCases2-faux", diff --git a/bc/core/tests/test_make_dev_data.py b/bc/core/tests/test_make_dev_data.py index a37490ee..e1890fa1 100644 --- a/bc/core/tests/test_make_dev_data.py +++ b/bc/core/tests/test_make_dev_data.py @@ -5,19 +5,21 @@ # MagicMocks, so those lines are commented to ignore the [attr-defined] # error. import re +from typing import cast from unittest.mock import ANY, MagicMock, call, patch from django.test import SimpleTestCase from bc.core.management.commands.make_dev_data import MakeDevData from bc.subscription.tests.factories import SubscriptionFactory +from bc.subscription.utils.courtlistener import DocketDict -CL_DOCKET_RESULT = { - "docket_id": 42, +CL_DOCKET_RESULT: DocketDict = { + "id": 42, "docket_number": "US 12345", "case_name": "US v Bobolink", - "court_id": 5, - "pacer_case_id": 89, + "court_id": "5", + "pacer_case_id": "89", "slug": "cl_slug_for_docket", } @@ -43,7 +45,7 @@ def test_default_values(self) -> None: return_value=([], "subbed randoms"), ) class TestCreate(SimpleTestCase): - cl_docket_result: dict[str, object] = {} + cl_docket_result = cast(DocketDict, {}) mock_big_cases_group: MagicMock = MagicMock() mocked_make_big_cases_group_and_channels: MagicMock = MagicMock() mocked_make_little_cases_group_and_channels: MagicMock = MagicMock() @@ -404,7 +406,7 @@ def has_group_subscribed_str( # @see https://docs.python.org/3/library/unittest.mock.html#where-to-patch @patch("bc.core.management.commands.make_dev_data.lookup_docket_by_cl_id") class TestMakeSubsFromClDocketId(NumSubdToGroupStrTest, SimpleTestCase): - cl_docket_result: dict[str, object] = {} + cl_docket_result: DocketDict = cast(DocketDict, {}) mocked_channels: MagicMock = MagicMock() mock_big_cases_group: MagicMock = MagicMock() @@ -447,8 +449,8 @@ def test_creates_subs_from_factory( cl_docket_id=67890, docket_number="US 12345", docket_name="US v Bobolink", - cl_court_id=5, - pacer_case_id=89, + cl_court_id="5", + pacer_case_id="89", cl_slug="cl_slug_for_docket", channels=self.mock_big_cases_group.channels.all(), ) diff --git a/bc/core/tests/test_templates.py b/bc/core/tests/test_templates.py index 22f8e54c..d1d9c0b3 100644 --- a/bc/core/tests/test_templates.py +++ b/bc/core/tests/test_templates.py @@ -1,14 +1,9 @@ -from textwrap import wrap - from django.test import SimpleTestCase from bc.core.utils.status.templates import ( BLUESKY_FOLLOW_A_NEW_CASE, - BLUESKY_FOLLOW_A_NEW_CASE_W_ARTICLE, MASTODON_FOLLOW_A_NEW_CASE, - MASTODON_FOLLOW_A_NEW_CASE_W_ARTICLE, TWITTER_FOLLOW_A_NEW_CASE, - TWITTER_FOLLOW_A_NEW_CASE_W_ARTICLE, MastodonTemplate, ) @@ -16,142 +11,703 @@ class NewSubscriptionValidTemplateTest(SimpleTestCase): def setUp(self) -> None: self.docket_url = "https://www.courtlistener.com/docket/68073028/01208579363/united-states-v-donald-trump/?redirect_or_modal=True" + self.initial_complaint_link = "https://www.courtlistener.com/opinion/9472375/united-states-v-donald-trump/" self.article_url = "https://www.theverge.com/2023/9/11/23868870/internet-archive-hachette-open-library-copyright-lawsuit-appeal" self.docket_id = "68073028" + self.date_filed = "2023-12-13" + self.initial_complaint_type = ( + "Bankruptcy" # Use this title since it's the longest possible one + ) return super().setUp() def test_check_output_validity_mastodon_simple_template(self): template = MASTODON_FOLLOW_A_NEW_CASE - valid_multipliers = [5, 10, 20, 40, 48] + valid_multipliers = [5, 10, 20, 40, 47] for multiplier in valid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertTrue(template.is_valid) + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + ) - invalid_multipliers = [50, 100] + self.assertTrue(template.is_valid) + + invalid_multipliers = [48, 50, 100] for multiplier in invalid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertFalse(template.is_valid) + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + ) + + self.assertFalse(template.is_valid) def test_check_output_validity_mastodon_template_w_article(self): - template = MASTODON_FOLLOW_A_NEW_CASE_W_ARTICLE + template = MASTODON_FOLLOW_A_NEW_CASE valid_multipliers = [5, 10, 20, 40] for multiplier in valid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + ) - self.assertTrue(template.is_valid) + self.assertTrue(template.is_valid) invalid_multipliers = [41, 50, 100] for multiplier in invalid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertFalse(template.is_valid) + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_mastodon_template_w_date(self): + template = MASTODON_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 40, 43] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [44, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_mastodon_template_w_initial_complaint(self): + template = MASTODON_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 39] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_mastodon_template_w_article_date(self): + template = MASTODON_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 36] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [37, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + ) + self.assertFalse(template.is_valid) + + def test_check_output_validity_mastodon_template_w_article_initial_complaint( + self, + ): + template = MASTODON_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 32] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [33, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_mastodon_template_w_article_date_initial_complaint( + self, + ): + template = MASTODON_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 24, 26, 28] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [30, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + article_url=self.article_url, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_mastodon_template_w_date_initial_complaint( + self, + ): + template = MASTODON_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 35] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [36, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) def test_check_output_validity_twitter_simple_template(self): template = TWITTER_FOLLOW_A_NEW_CASE - valid_multipliers = [5, 10, 20, 40, 44] + valid_multipliers = [5, 10, 20, 40, 43] for multiplier in valid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertTrue(template.is_valid) + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + ) - invalid_multipliers = [45, 50, 100] + self.assertTrue(template.is_valid) + + invalid_multipliers = [44, 50, 100] for multiplier in invalid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertFalse(template.is_valid) + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + ) + + self.assertFalse(template.is_valid) def test_check_output_validity_twitter_template_w_article(self): - template = TWITTER_FOLLOW_A_NEW_CASE_W_ARTICLE + template = TWITTER_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 36] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [37, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_twitter_template_w_date(self): + template = TWITTER_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 39] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_twitter_template_w_initial_complaint(self): + template = TWITTER_FOLLOW_A_NEW_CASE valid_multipliers = [5, 10, 20, 35] for multiplier in valid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertTrue(template.is_valid) + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) - invalid_multipliers = [37, 50, 100] + invalid_multipliers = [36, 40, 50, 100] for multiplier in invalid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertFalse(template.is_valid) + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_twitter_template_w_article_date(self): + template = TWITTER_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 32] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [33, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_twitter_template_w_article_initial_complaint( + self, + ): + template = TWITTER_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 28] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [29, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_twitter_template_w_article_date_initial_complaint( + self, + ): + template = TWITTER_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 25] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [26, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_twitter_template_w_date_initial_complaint( + self, + ): + template = TWITTER_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 31] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [32, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) def test_check_output_validity_bluesky_simple_template(self): template = BLUESKY_FOLLOW_A_NEW_CASE - valid_multipliers = [5, 10, 20, 40, 50] + valid_multipliers = [5, 10, 20, 40, 50, 50] for multiplier in valid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertTrue(template.is_valid) + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + ) + + self.assertTrue(template.is_valid) invalid_multipliers = [51, 100] for multiplier in invalid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertFalse(template.is_valid) + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + ) + + self.assertFalse(template.is_valid) def test_check_output_validity_bluesky_template_w_article(self): - template = BLUESKY_FOLLOW_A_NEW_CASE_W_ARTICLE + template = BLUESKY_FOLLOW_A_NEW_CASE valid_multipliers = [5, 10, 20, 40, 46] for multiplier in valid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertTrue(template.is_valid) + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + ) + + self.assertTrue(template.is_valid) invalid_multipliers = [47, 50, 100] for multiplier in invalid_multipliers: - template.format( - docket=multiplier * "short", - docket_link=self.docket_url, - docket_id=self.docket_id, - article_url=self.article_url, - ) - self.assertFalse(template.is_valid) + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_bluesky_template_w_date(self): + template = BLUESKY_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 40, 46] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [47, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_bluesky_template_w_initial_complaint(self): + template = BLUESKY_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 40, 46] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [47, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_bluesky_template_w_article_date(self): + template = BLUESKY_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 40, 42] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [43, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_bluesky_template_w_article_initial_complaint( + self, + ): + template = BLUESKY_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 40, 42] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [43, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_bluesky_template_w_date_initial_complaint( + self, + ): + template = BLUESKY_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 40, 42] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [43, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) + + def test_check_output_validity_bluesky_template_w_article_date_initial_complaint( + self, + ): + template = BLUESKY_FOLLOW_A_NEW_CASE + valid_multipliers = [5, 10, 20, 38] + for multiplier in valid_multipliers: + with self.subTest(multiplier=multiplier, valid=True): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertTrue(template.is_valid) + + invalid_multipliers = [39, 40, 50, 100] + for multiplier in invalid_multipliers: + with self.subTest(multiplier=multiplier, valid=False): + template.format( + docket=multiplier * "short", + docket_link=self.docket_url, + docket_id=self.docket_id, + article_url=self.article_url, + date_filed=self.date_filed, + initial_complaint_type=self.initial_complaint_type, + initial_complaint_link=self.initial_complaint_link, + ) + + self.assertFalse(template.is_valid) class MastodonTemplateTest(SimpleTestCase): diff --git a/bc/core/utils/status/base.py b/bc/core/utils/status/base.py index 7ee486ba..815668dd 100644 --- a/bc/core/utils/status/base.py +++ b/bc/core/utils/status/base.py @@ -1,7 +1,11 @@ import re -from dataclasses import dataclass +from dataclasses import dataclass, field from string import Formatter +from django.template import Context, NodeList, Template +from django.template.base import VariableNode +from django.template.defaulttags import IfNode + from bc.core.utils.string_utils import trunc from ..images import TextImage @@ -24,7 +28,9 @@ class BaseTemplate: link_placeholders: list[str] max_characters: int border_color: tuple[int, ...] = (243, 195, 62) + post_replace: list[tuple[str, str]] = field(default_factory=list) is_valid: bool = True + _django_template: Template | None = None def __len__(self) -> int: """Returns the length of the template without the placeholders @@ -41,7 +47,15 @@ def count_fixed_characters(self): using a dictionary that returns a blank string for each key and then computes the len of the new string. """ - clean_template = self.str_template.format_map(AlwaysBlankValueDict()) + if self.django_template: + clean_template = self.django_template.render( + Context(AlwaysBlankValueDict()) + ) + else: + clean_template = self.str_template.format_map( + AlwaysBlankValueDict() + ) + return len(clean_template) def _available_space(self, *args, **kwargs) -> int: @@ -56,12 +70,20 @@ def _available_space(self, *args, **kwargs) -> int: placeholder_characters = sum( [ len(str(kwargs.get(field_name))) - for text, field_name, *_ in Formatter().parse( - self.str_template - ) + for field_name in self.template_fields if field_name and field_name not in excluded ] ) + if self.django_template: + # Remove flow control tags from the template + placeholder_characters += sum( + [ + len(x) + for x in re.findall( + r"{%[^%]*%}", self.str_template, re.MULTILINE + ) + ] + ) return self.max_characters - len(self) - placeholder_characters @@ -81,9 +103,13 @@ def _check_output_validity(self, text: str) -> bool: url_pattern = r"https?://\S+" url_count = len(re.findall(url_pattern, text)) linkless_output = re.sub(url_pattern, "", text) + unfilled_template_items = re.findall(r"({\w+}|{%|%})", linkless_output) # Twitter and Mastodon both count links as 23 chars at present - return len(linkless_output) + (23 * url_count) <= self.max_characters + return ( + len(linkless_output) + (23 * url_count) <= self.max_characters + and len(unfilled_template_items) == 0 + ) def format(self, *args, **kwargs) -> tuple[str, TextImage | None]: image = None @@ -102,13 +128,58 @@ def format(self, *args, **kwargs) -> tuple[str, TextImage | None]: available_space, "…\n\n[full entry below 👇]", ) + if self.django_template: + text = str(self.django_template.render(Context(kwargs))) + else: + text = self.str_template.format(**kwargs) - text = self.str_template.format(**kwargs) + for find_str, sub_str in self.post_replace: + text = text.replace(find_str, sub_str) self.is_valid = self._check_output_validity(text) return text, image + @property + def _is_django_template(self) -> bool: + """Checks if the template is a Django template + + Returns: + bool: True if the template is a Django template, False otherwise. + """ + + return "{%" in self.str_template or "{{" in self.str_template + + @property + def django_template(self) -> Template | None: + """Returns the Django template object + + Returns: + Template: Django template object + """ + + if not self._django_template and self._is_django_template: + self._django_template = Template(self.str_template) + return self._django_template + + @property + def template_fields(self) -> list[str]: + """Returns the template fields + + Returns: + list[str]: list of fields in the template + """ + if self.django_template: + return _get_node_list_fields( + self.django_template.compile_nodelist() + ) + else: + return [ + field_name + for _, field_name, *_ in Formatter().parse(self.str_template) + if field_name + ] + @dataclass class MastodonTemplate(BaseTemplate): @@ -147,4 +218,19 @@ def _check_output_validity(self, text: str) -> bool: markup language. """ cleaned_text = re.sub(r"(?<=])\(\S+\)", "", text) - return len(cleaned_text) <= self.max_characters + unfilled_template_items = re.findall(r"({\w+}|{%|%})", cleaned_text) + + return ( + len(cleaned_text) <= self.max_characters + and len(unfilled_template_items) == 0 + ) + + +def _get_node_list_fields(nodelist: NodeList) -> list[str]: + fields: list[str] = [] + for node in nodelist: + if isinstance(node, VariableNode): + fields.append(str(node.filter_expression)) + if isinstance(node, IfNode): + fields.extend(_get_node_list_fields(node.nodelist)) + return fields diff --git a/bc/core/utils/status/selectors.py b/bc/core/utils/status/selectors.py index 5f241e84..df72e09f 100644 --- a/bc/core/utils/status/selectors.py +++ b/bc/core/utils/status/selectors.py @@ -1,16 +1,14 @@ from bc.channel.models import Channel +from bc.core.utils.status.base import BaseTemplate from .templates import ( BLUESKY_FOLLOW_A_NEW_CASE, - BLUESKY_FOLLOW_A_NEW_CASE_W_ARTICLE, BLUESKY_MINUTE_TEMPLATE, BLUESKY_POST_TEMPLATE, MASTODON_FOLLOW_A_NEW_CASE, - MASTODON_FOLLOW_A_NEW_CASE_W_ARTICLE, MASTODON_MINUTE_TEMPLATE, MASTODON_POST_TEMPLATE, TWITTER_FOLLOW_A_NEW_CASE, - TWITTER_FOLLOW_A_NEW_CASE_W_ARTICLE, TWITTER_MINUTE_TEMPLATE, TWITTER_POST_TEMPLATE, BlueskyTemplate, @@ -60,41 +58,27 @@ def get_template_for_channel( ) -def get_new_case_template( - service: int, article_url: str -) -> TwitterTemplate | MastodonTemplate | BlueskyTemplate: +def get_new_case_template(service: int) -> BaseTemplate: """Returns a template object that uses the data of a subscription to create a status update in the given service. this method checks the article URL to pick one of the templates available. Args: - service (int): the service identifier. - article_url (str): the article url of the new subscription + service (int): the service identifier Returns: - TwitterTemplate | MastodonTemplate | BlueskyTemplate: template object to create - a new post. + BaseTemplate: template object to create a new post """ - match service: - case Channel.TWITTER: - return ( - TWITTER_FOLLOW_A_NEW_CASE_W_ARTICLE - if article_url - else TWITTER_FOLLOW_A_NEW_CASE - ) - case Channel.MASTODON: - return ( - MASTODON_FOLLOW_A_NEW_CASE_W_ARTICLE - if article_url - else MASTODON_FOLLOW_A_NEW_CASE - ) - case Channel.BLUESKY: - return ( - BLUESKY_FOLLOW_A_NEW_CASE_W_ARTICLE - if article_url - else BLUESKY_FOLLOW_A_NEW_CASE - ) - case _: - raise NotImplementedError( - f"No template implemented for service: '{service}'." - ) + + new_case_templates: dict[int, BaseTemplate] = { + Channel.BLUESKY: BLUESKY_FOLLOW_A_NEW_CASE, + Channel.MASTODON: MASTODON_FOLLOW_A_NEW_CASE, + Channel.TWITTER: TWITTER_FOLLOW_A_NEW_CASE, + } + + if service in new_case_templates: + return new_case_templates[service] + + raise NotImplementedError( + f"No template implemented for service: '{service}'." + ) diff --git a/bc/core/utils/status/templates.py b/bc/core/utils/status/templates.py index 62b9ad63..850e770f 100644 --- a/bc/core/utils/status/templates.py +++ b/bc/core/utils/status/templates.py @@ -1,11 +1,6 @@ import re -from .base import ( - BaseTemplate, - BlueskyTemplate, - MastodonTemplate, - TwitterTemplate, -) +from .base import BlueskyTemplate, MastodonTemplate, TwitterTemplate DO_NOT_POST = re.compile( r"""( @@ -50,23 +45,30 @@ ) MASTODON_FOLLOW_A_NEW_CASE = MastodonTemplate( - link_placeholders=[], - str_template="""I'm now following {docket}: - -{docket_link} - -#CL{docket_id}""", -) - -MASTODON_FOLLOW_A_NEW_CASE_W_ARTICLE = MastodonTemplate( - link_placeholders=["article_url"], - str_template="""I'm now following {docket}: - -Docket: {docket_link} - -Context: {article_url} - -#CL{docket_id}""", + link_placeholders=["docket_link", "initial_complaint_link", "article_url"], + # Remove extra newlines caused by empty template blocks + post_replace=[ + ("\n\n\n\n", "\n\n"), + ("\n\n\n\n", "\n\n"), + ("\n\n\n", "\n\n"), + ], + str_template="""I'm now following {{docket}}: + +{% if date_filed %} +Filed: {{date_filed}} +{% endif %} + +Docket: {{docket_link}} + +{% if initial_complaint_type and initial_complaint_link %} +{{initial_complaint_type}}: {{initial_complaint_link}} +{% endif %} + +{% if article_url %} +Context: {{article_url}} +{% endif %} + +#CL{{docket_id}}""", ) @@ -89,44 +91,59 @@ #CL{docket_id}""", ) - TWITTER_FOLLOW_A_NEW_CASE = TwitterTemplate( - link_placeholders=[], - str_template="""I'm now following {docket}: - -{docket_link} - -#CL{docket_id}""", + link_placeholders=["docket_link", "initial_complaint_link", "article_url"], + # Remove extra newlines caused by empty template blocks + post_replace=[ + ("\n\n\n\n", "\n\n"), + ("\n\n\n\n", "\n\n"), + ("\n\n\n", "\n\n"), + ], + str_template="""I'm now following {{docket}}: + +{% if date_filed %} +Filed: {{date_filed}} +{% endif %} + +Docket: {{docket_link}} + +{% if initial_complaint_type and initial_complaint_link %} +{{initial_complaint_type}}: {{initial_complaint_link}} +{% endif %} + +{% if article_url %} +Context: {{article_url}} +{% endif %} + +#CL{{docket_id}}""", ) -TWITTER_FOLLOW_A_NEW_CASE_W_ARTICLE = TwitterTemplate( - link_placeholders=["article_url"], - str_template="""I'm now following {docket}: - -Docket: {docket_link} - -Context: {article_url} - -#CL{docket_id}""", -) - - BLUESKY_FOLLOW_A_NEW_CASE = BlueskyTemplate( - link_placeholders=["docket_link"], - str_template="""I'm now following {docket}: - -[View Full Case]({docket_link}) - -#CL{docket_id}""", -) - -BLUESKY_FOLLOW_A_NEW_CASE_W_ARTICLE = BlueskyTemplate( - link_placeholders=["docket_link", "article_url"], - str_template="""I'm now following {docket}: - -[View Full Case]({docket_link}) | [Background Info]({article_url}) - -#CL{docket_id}""", + link_placeholders=["docket_link", "article_url", "initial_complaint_link"], + # Remove extra newlines caused by empty template blocks + post_replace=[ + ("\n\n\n\n", "\n\n"), + ("\n\n\n\n", "\n\n"), + ("\n\n\n", "\n\n"), + ("\n\n\n", "\n\n"), + ("\n\n |", " |"), + ("\n |", " |"), + ], + str_template="""I'm now following {{docket}}: + +{% if date_filed %} +Filed: {{date_filed}} +{% endif %} + +[View Full Case]({{docket_link}}) +{% if article_url %} + | [Background Info]({{article_url}}) +{% endif %} +{% if initial_complaint_type and initial_complaint_link %} + | [{{initial_complaint_type}}]({{initital_complaint_link}}) +{% endif %} + +#CL{{docket_id}}""", ) BLUESKY_POST_TEMPLATE = BlueskyTemplate( diff --git a/bc/subscription/tasks.py b/bc/subscription/tasks.py index c918b411..a950f352 100644 --- a/bc/subscription/tasks.py +++ b/bc/subscription/tasks.py @@ -1,4 +1,5 @@ -from typing import Literal +from datetime import date, datetime +from typing import Literal, TypeVar from django.conf import settings from django.db import transaction @@ -20,8 +21,11 @@ from bc.sponsorship.selectors import check_active_sponsorships from bc.sponsorship.services import log_purchase from bc.subscription.utils.courtlistener import ( + DocketDict, DocumentDict, download_pdf_from_cl, + is_bankruptcy, + lookup_docket_by_cl_id, lookup_document_by_doc_id, lookup_initial_complaint, purchase_pdf_by_doc_id, @@ -37,6 +41,7 @@ def enqueue_posts_for_new_case( subscription: Subscription, document: bytes | None = None, check_sponsor_message: bool = False, + initial_document: DocumentDict | None = None, ) -> None: """ Enqueue jobs to create a post in the available channels after @@ -53,10 +58,38 @@ def enqueue_posts_for_new_case( if document: files = get_thumbnails_from_range(document, "[1,2,3,4]") - for channel in get_channels_per_subscription(subscription.pk): - template = get_new_case_template( - channel.service, subscription.article_url + if initial_document is None: + initial_document = lookup_initial_complaint(subscription.cl_docket_id) + + docket: DocketDict | None = None + if subscription.cl_docket_id: + docket = lookup_docket_by_cl_id(subscription.cl_docket_id) + + date_filed: date | None = None + date_filed_str = ( + docket["date_filed"] if initial_document and docket else None + ) + try: + date_filed = ( + datetime.strptime(date_filed_str, "%Y-%m-%d").date() + if date_filed_str + else None ) + except ValueError: + # If the date_filed_str is not a valid date, just continue on + pass + initial_complaint_link = ( + initial_document["absolute_url"] if initial_document else None + ) + + initial_complaint_type: Literal["Petition", "Complaint"] | None = None + if initial_complaint_link and docket: + initial_complaint_type = ( + "Petition" if is_bankruptcy(docket) else "Complaint" + ) + + for channel in get_channels_per_subscription(subscription.pk): + template = get_new_case_template(channel.service) if channel.group: template.border_color = channel.group.border_color_rgb @@ -66,6 +99,9 @@ def enqueue_posts_for_new_case( docket_link=subscription.cl_url, docket_id=subscription.cl_docket_id, article_url=subscription.article_url, + date_filed=date_filed, + initial_complaint_type=initial_complaint_type, + initial_complaint_link=initial_complaint_link, ) api = channel.get_api_wrapper() @@ -251,7 +287,9 @@ def check_initial_complaint_before_posting( return subscription # Got the document or no sponsorship. Tweet and toot. - enqueue_posts_for_new_case(subscription, document) + enqueue_posts_for_new_case( + subscription, document, initial_document=cl_document + ) return subscription @@ -320,7 +358,7 @@ def process_fetch_webhook_event( if record_type == "filing_webhook": enqueue_posts_for_docket_alert(filing_webhook_event, pdf_data, True) else: - enqueue_posts_for_new_case(subscription, pdf_data, True) + enqueue_posts_for_new_case(subscription, pdf_data, True, cl_document) return record_pk diff --git a/bc/subscription/tests/test_tasks.py b/bc/subscription/tests/test_tasks.py index f6e6a483..d6d64505 100644 --- a/bc/subscription/tests/test_tasks.py +++ b/bc/subscription/tests/test_tasks.py @@ -5,8 +5,8 @@ from bc.channel.models import Channel, Post from bc.channel.tests.factories import ChannelFactory, GroupFactory from bc.core.utils.status.templates import ( + MASTODON_FOLLOW_A_NEW_CASE, TWITTER_FOLLOW_A_NEW_CASE, - TWITTER_FOLLOW_A_NEW_CASE_W_ARTICLE, ) from bc.core.utils.tests.base import faker from bc.sponsorship.tests.factories import SponsorshipFactory @@ -300,6 +300,7 @@ def test_add_sponsor_text_to_thumbails( @patch("bc.subscription.tasks.lookup_initial_complaint") +@patch("bc.subscription.tasks.lookup_docket_by_cl_id") @patch("bc.subscription.tasks.Retry") @patch("bc.subscription.tasks.queue") @patch.object(Channel, "get_api_wrapper") @@ -335,6 +336,7 @@ def test_can_create_new_post_wo_document( mock_api, mock_queue, mock_retry, + mock_docket_by_cl_id, mock_lookup, ): mock_lookup.return_value = { @@ -347,7 +349,9 @@ def test_can_create_new_post_wo_document( mock_lookup.assert_called_once_with(self.subscription.cl_docket_id) mock_download.assert_not_called() - mock_enqueue.assert_called_once_with(self.subscription, None) + mock_enqueue.assert_called_once_with( + self.subscription, None, initial_document=mock_lookup.return_value + ) @patch("bc.subscription.tasks.enqueue_posts_for_new_case") @patch("bc.subscription.tasks.download_pdf_from_cl") @@ -358,6 +362,7 @@ def test_can_download_initial_complaint( mock_api, mock_queue, mock_retry, + mock_docket_by_cl_id, mock_lookup, ): local_filepath = faker.url() @@ -373,12 +378,20 @@ def test_can_download_initial_complaint( mock_lookup.assert_called_once_with(self.subscription.cl_docket_id) mock_download.assert_called_with(local_filepath) mock_enqueue.assert_called_once_with( - self.subscription, mock_download() + self.subscription, + mock_download(), + initial_document=mock_lookup.return_value, ) @patch("bc.subscription.tasks.purchase_pdf_by_doc_id") def test_can_purchase_initial_complaint( - self, mock_purchase, mock_api, mock_queue, mock_retry, mock_lookup + self, + mock_purchase, + mock_api, + mock_queue, + mock_retry, + mock_docket_by_cl_id, + mock_lookup, ): api_wrapper = self.mock_api_wrapper() mock_api.return_value = api_wrapper @@ -403,7 +416,12 @@ def test_can_purchase_initial_complaint( mock_queue.assert_not_called() def test_can_enqueue_new_case_status( - self, mock_api, mock_queue, mock_retry, mock_lookup + self, + mock_api, + mock_queue, + mock_retry, + mock_docket_by_cl_id, + mock_lookup, ): api_wrapper = self.mock_api_wrapper() mock_api.return_value = api_wrapper @@ -421,12 +439,17 @@ def test_can_enqueue_new_case_status( ) def test_can_post_new_case_w_link( - self, mock_api, mock_queue, mock_retry, mock_lookup + self, + mock_api, + mock_queue, + mock_retry, + mock_docket_by_cl_id, + mock_lookup, ): api_wrapper = self.mock_api_wrapper() mock_api.return_value = api_wrapper mock_lookup.return_value = None - message, _ = TWITTER_FOLLOW_A_NEW_CASE_W_ARTICLE.format( + message, _ = TWITTER_FOLLOW_A_NEW_CASE.format( docket=self.subscription_w_link.name_with_summary, docket_link=self.subscription_w_link.cl_url, docket_id=self.subscription_w_link.cl_docket_id, @@ -445,18 +468,22 @@ def test_can_post_new_case_w_thumbnails( mock_api, mock_queue, mock_retry, + mock_docket_by_cl_id, mock_lookup, ): api_wrapper = self.mock_api_wrapper() mock_api.return_value = api_wrapper + mock_lookup.return_value = None + mock_docket_by_cl_id.return_value = None + document = faker.binary(2) thumb_1 = faker.binary(4) thumb_2 = faker.binary(6) mock_thumbnails.return_value = [thumb_1, thumb_2] - message, _ = TWITTER_FOLLOW_A_NEW_CASE_W_ARTICLE.format( + message, _ = TWITTER_FOLLOW_A_NEW_CASE.format( docket=self.subscription_w_link.name_with_summary, docket_link=self.subscription_w_link.cl_url, docket_id=self.subscription_w_link.cl_docket_id, @@ -483,6 +510,7 @@ def test_can_create_post_w_sponsored_thumbnails( mock_api, mock_queue, mock_retry, + mock_docket_by_cl_id, mock_lookup, ): sponsorship = SponsorshipFactory() @@ -493,6 +521,9 @@ def test_can_create_post_w_sponsored_thumbnails( api_wrapper = self.mock_api_wrapper() mock_api.return_value = api_wrapper + mock_lookup.return_value = None + mock_docket_by_cl_id.return_value = None + document = faker.binary(2) thumb_1 = faker.binary(4) @@ -503,7 +534,14 @@ def test_can_create_post_w_sponsored_thumbnails( thumb_4 = faker.binary(7) mock_sponsored.return_value = [thumb_3, thumb_4] - message, _ = TWITTER_FOLLOW_A_NEW_CASE_W_ARTICLE.format( + twitter_message, _ = TWITTER_FOLLOW_A_NEW_CASE.format( + docket=self.subscription_w_link.name_with_summary, + docket_link=self.subscription_w_link.cl_url, + docket_id=self.subscription_w_link.cl_docket_id, + article_url=self.subscription_w_link.article_url, + ) + + masto_message, _ = MASTODON_FOLLOW_A_NEW_CASE.format( docket=self.subscription_w_link.name_with_summary, docket_link=self.subscription_w_link.cl_url, docket_id=self.subscription_w_link.cl_docket_id, @@ -515,14 +553,14 @@ def test_can_create_post_w_sponsored_thumbnails( expected_enqueue_calls = [ call( api_wrapper.add_status, - message, + masto_message, None, [thumb_1, thumb_2], retry=mock_retry(), ), call( api_wrapper.add_status, - message, + twitter_message, None, [thumb_3, thumb_4], retry=mock_retry(), diff --git a/bc/subscription/utils/courtlistener.py b/bc/subscription/utils/courtlistener.py index d2b11e27..928eefd1 100644 --- a/bc/subscription/utils/courtlistener.py +++ b/bc/subscription/utils/courtlistener.py @@ -1,6 +1,6 @@ import logging import re -from typing import TypedDict +from typing import Any, NotRequired, TypedDict, cast import courts_db import requests @@ -113,7 +113,23 @@ def auth_header() -> dict: return header_dict -def lookup_docket_by_cl_id(cl_id: int): +class DocketIDBDataDict(TypedDict): + nature_of_suit: str | None + + +class DocketDict(TypedDict): + id: int + case_name: str + court_id: str + date_filed: NotRequired[str] + docket_number: str + idb_data: NotRequired[DocketIDBDataDict | None] + nature_of_suit: NotRequired[str] + pacer_case_id: str + slug: str + + +def lookup_docket_by_cl_id(cl_id: int) -> DocketDict: """ Performs a GET query on /api/rest/v3/dockets/ to get a Docket using the CourtListener ID @@ -125,6 +141,7 @@ def lookup_docket_by_cl_id(cl_id: int): class DocumentDict(TypedDict): + absolute_url: str id: int page_count: int filepath_local: str @@ -138,7 +155,9 @@ def lookup_document_by_doc_id(doc_id: int | None) -> DocumentDict: """ response = requests.get( f"{CL_API['recap-documents']}{doc_id}/", - params={"fields": "id,filepath_local,page_count,pacer_doc_id"}, + params={ + "fields": "id,absolute_url,filepath_local,page_count,pacer_doc_id" + }, headers=auth_header(), timeout=5, ) @@ -166,7 +185,7 @@ def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None: "docket_entry__docket__id": docket_id, "docket_entry__entry_number": 1, "order_by": "id", - "fields": "id,filepath_local,page_count,pacer_doc_id", + "fields": "id,absolute_url,filepath_local,page_count,pacer_doc_id", } response = requests.get( @@ -184,6 +203,7 @@ def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None: document = data["results"][0] return { "id": document["id"], + "absolute_url": document["absolute_url"], "filepath_local": document["filepath_local"], "page_count": document["page_count"], "pacer_doc_id": document["pacer_doc_id"], @@ -308,3 +328,43 @@ def handle_multi_defendant_cases(queue): # TODO raise NotImplementedError logger.debug("handle_multi_defendant_cases(): done") + + +def is_bankruptcy(docket: DocketDict) -> bool: + """ + For a given docket document (i.e., one retrieved via + `lookup_docket_by_cl_id`), try to determine if it's + + Args: + docket (dict): The docket document for the case + + Returns: + bool: Whether this is a bankruptcy case + """ + + # There seems to be a few approaches we could take here: + # + # - Check the nature_of_suit field and compare against the bankruptcy code (4900) + # listed at https://github.com/freelawproject/courtlistener/blob/main/cl/recap/constants.py#L583 + # + # - Check the nature_of_suit filed inside the idb_data field and compare + # against the bankruptcy code listed above + # + # - Check the court name/id against a list of bankruptcy courts, but I + # don't think we have that anywhere handy at the moment. + # + # For now, I'm going to go with a combo of the first two approaches, since + # it seems like the most efficient path for now. + + if not docket: + return False + + # idb_data is sometimes present and None, so do this instead of + # providing a default to the get function so we always have a dict even + # if it's an empty one. + idb_data = docket.get("idb_data") or cast(DocketIDBDataDict, {}) + + nature_of_suit = docket.get("nature_of_suit") + idb_nature_of_suit = idb_data.get("nature_of_suit") + + return "4900" in [nature_of_suit, idb_nature_of_suit] diff --git a/bc/subscription/views.py b/bc/subscription/views.py index fad20ab3..d53fb256 100644 --- a/bc/subscription/views.py +++ b/bc/subscription/views.py @@ -95,9 +95,7 @@ def post(self, request, *args, **kwargs): # Verify that all templates produce valid post content for channel_id in channels: channel = Channel.objects.get(pk=channel_id) - template = get_new_case_template( - channel.service, subscription.article_url - ) + template = get_new_case_template(channel.service) template.format( docket=subscription.name_with_summary, From 8744ebaea26d2f216c27108db293bb3af4aa286e Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 1 May 2024 16:16:59 -0400 Subject: [PATCH 02/10] feat(templates): fix some small issues + spelling in template --- bc/core/tests/test_templates.py | 16 ++++++------- bc/core/utils/status/templates.py | 2 +- bc/subscription/services.py | 2 +- bc/subscription/tasks.py | 37 +++++++++++++++++++------------ 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/bc/core/tests/test_templates.py b/bc/core/tests/test_templates.py index d1d9c0b3..f44a94b5 100644 --- a/bc/core/tests/test_templates.py +++ b/bc/core/tests/test_templates.py @@ -557,7 +557,7 @@ def test_check_output_validity_bluesky_template_w_date(self): def test_check_output_validity_bluesky_template_w_initial_complaint(self): template = BLUESKY_FOLLOW_A_NEW_CASE - valid_multipliers = [5, 10, 20, 40, 46] + valid_multipliers = [5, 10, 20, 40, 47] for multiplier in valid_multipliers: with self.subTest(multiplier=multiplier, valid=True): template.format( @@ -570,7 +570,7 @@ def test_check_output_validity_bluesky_template_w_initial_complaint(self): self.assertTrue(template.is_valid) - invalid_multipliers = [47, 50, 100] + invalid_multipliers = [48, 50, 100] for multiplier in invalid_multipliers: with self.subTest(multiplier=multiplier, valid=False): template.format( @@ -615,7 +615,7 @@ def test_check_output_validity_bluesky_template_w_article_initial_complaint( self, ): template = BLUESKY_FOLLOW_A_NEW_CASE - valid_multipliers = [5, 10, 20, 40, 42] + valid_multipliers = [5, 10, 20, 40, 43] for multiplier in valid_multipliers: with self.subTest(multiplier=multiplier, valid=True): template.format( @@ -629,7 +629,7 @@ def test_check_output_validity_bluesky_template_w_article_initial_complaint( self.assertTrue(template.is_valid) - invalid_multipliers = [43, 50, 100] + invalid_multipliers = [44, 50, 100] for multiplier in invalid_multipliers: with self.subTest(multiplier=multiplier, valid=False): template.format( @@ -647,7 +647,7 @@ def test_check_output_validity_bluesky_template_w_date_initial_complaint( self, ): template = BLUESKY_FOLLOW_A_NEW_CASE - valid_multipliers = [5, 10, 20, 40, 42] + valid_multipliers = [5, 10, 20, 40, 43] for multiplier in valid_multipliers: with self.subTest(multiplier=multiplier, valid=True): template.format( @@ -661,7 +661,7 @@ def test_check_output_validity_bluesky_template_w_date_initial_complaint( self.assertTrue(template.is_valid) - invalid_multipliers = [43, 50, 100] + invalid_multipliers = [44, 50, 100] for multiplier in invalid_multipliers: with self.subTest(multiplier=multiplier, valid=False): template.format( @@ -679,7 +679,7 @@ def test_check_output_validity_bluesky_template_w_article_date_initial_complaint self, ): template = BLUESKY_FOLLOW_A_NEW_CASE - valid_multipliers = [5, 10, 20, 38] + valid_multipliers = [5, 10, 20, 39] for multiplier in valid_multipliers: with self.subTest(multiplier=multiplier, valid=True): template.format( @@ -694,7 +694,7 @@ def test_check_output_validity_bluesky_template_w_article_date_initial_complaint self.assertTrue(template.is_valid) - invalid_multipliers = [39, 40, 50, 100] + invalid_multipliers = [40, 50, 100] for multiplier in invalid_multipliers: with self.subTest(multiplier=multiplier, valid=False): template.format( diff --git a/bc/core/utils/status/templates.py b/bc/core/utils/status/templates.py index 850e770f..fae4d408 100644 --- a/bc/core/utils/status/templates.py +++ b/bc/core/utils/status/templates.py @@ -140,7 +140,7 @@ | [Background Info]({{article_url}}) {% endif %} {% if initial_complaint_type and initial_complaint_link %} - | [{{initial_complaint_type}}]({{initital_complaint_link}}) + | [{{initial_complaint_type}}]({{initial_complaint_link}}) {% endif %} #CL{{docket_id}}""", diff --git a/bc/subscription/services.py b/bc/subscription/services.py index 5609240d..1d189985 100644 --- a/bc/subscription/services.py +++ b/bc/subscription/services.py @@ -20,7 +20,7 @@ def create_or_update_subscription_from_docket(docket): court = find_court_by_id(cl_court_id) court_name = court[0]["name"] if len(court) == 1 else "" - article_url = docket["article_url"] + article_url = docket.get("article_url", "") return Subscription.objects.update_or_create( cl_docket_id=cl_docket_id, defaults={ diff --git a/bc/subscription/tasks.py b/bc/subscription/tasks.py index a950f352..0e214f93 100644 --- a/bc/subscription/tasks.py +++ b/bc/subscription/tasks.py @@ -66,20 +66,27 @@ def enqueue_posts_for_new_case( docket = lookup_docket_by_cl_id(subscription.cl_docket_id) date_filed: date | None = None - date_filed_str = ( - docket["date_filed"] if initial_document and docket else None - ) - try: - date_filed = ( - datetime.strptime(date_filed_str, "%Y-%m-%d").date() - if date_filed_str - else None - ) - except ValueError: - # If the date_filed_str is not a valid date, just continue on - pass + days_old = 0 + + date_filed_str = docket["date_filed"] if docket else None + if date_filed_str: + try: + date_filed = ( + datetime.strptime(date_filed_str, "%Y-%m-%d").date() + if date_filed_str + else None + ) + + assert date_filed is not None # For type checking purposes + days_old = (date.today() - date_filed).days + except ValueError: + # If the date_filed_str is not a valid date, just continue on + pass + initial_complaint_link = ( - initial_document["absolute_url"] if initial_document else None + f"https://www.courtlistener.com{initial_document['absolute_url']}" + if initial_document + else None ) initial_complaint_type: Literal["Petition", "Complaint"] | None = None @@ -99,11 +106,13 @@ def enqueue_posts_for_new_case( docket_link=subscription.cl_url, docket_id=subscription.cl_docket_id, article_url=subscription.article_url, - date_filed=date_filed, + date_filed=date_filed if days_old >= 30 else None, initial_complaint_type=initial_complaint_type, initial_complaint_link=initial_complaint_link, ) + print(message) + api = channel.get_api_wrapper() sponsor_message = None From 5ffd0b7d2c99d4b8f8468d5626381740ebcb064a Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 1 May 2024 16:18:37 -0400 Subject: [PATCH 03/10] feat(cli): Update bootstrap data to include Bluesky --- bc/channel/tests/factories.py | 2 +- bc/core/management/commands/make_dev_data.py | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/bc/channel/tests/factories.py b/bc/channel/tests/factories.py index cd971ed3..f707dd55 100644 --- a/bc/channel/tests/factories.py +++ b/bc/channel/tests/factories.py @@ -96,7 +96,7 @@ class Params: ) mastodon = factory.Trait( service=Channel.MASTODON, - account="BigCases2-faux", + account="@BigCases2-faux@mastodon.test", account_id="Mastodon-big-cases-email-faux", enabled=True, ) diff --git a/bc/core/management/commands/make_dev_data.py b/bc/core/management/commands/make_dev_data.py index 3e8d353d..f5526f83 100644 --- a/bc/core/management/commands/make_dev_data.py +++ b/bc/core/management/commands/make_dev_data.py @@ -128,26 +128,30 @@ def make_big_cases_group_and_channels( self, ) -> tuple[Group | GroupFactory, str]: """ - Make 1 big cases Group and 2 channels for it (Mastodon and Twitter) + Make 1 big cases Group and channels for it + (Bluesky, Mastodon, and Twitter) :return: the big cases Group, a string saying that they were made :rtype: tuple[Group | GroupFactory, str] """ - info = "Big Cases Group and the Mastodon and Twitter Channels" - big_cases_group = self._make_group_and_2_channels(True, "Big cases") + info = ( + "Big Cases Group and the Bluesky, Mastodon, and Twitter Channels" + ) + big_cases_group = self._make_group_and_channels(True, "Big cases") return big_cases_group, self._made_str(self.NUM_BIGCASES_GROUPS, info) def make_little_cases_group_and_channels( self, ) -> tuple[Group | GroupFactory, str]: """ - Make 1 little cases Group and 2 channels for it (Mastodon and Twitter) + Make 1 little cases Group and channels for it + (Bluesky, Mastodon and Twitter) :return: the little cases Group, a string saying that they were made :rtype: tuple[Group | GroupFactory, str] """ - info = "Little Cases Group and the Mastodon and Twitter Channels" - little_cases_group = self._make_group_and_2_channels( + info = "Little Cases Group and the Bluesky, Mastodon, and Twitter Channels" + little_cases_group = self._make_group_and_channels( False, "Little cases" ) return little_cases_group, self._made_str( @@ -289,7 +293,7 @@ def subscribe_randoms_to_group( ) @staticmethod - def _make_group_and_2_channels( + def _make_group_and_channels( is_big_cases: bool = False, name: str | None = None ) -> Group | GroupFactory: if name is None: @@ -298,10 +302,12 @@ def _make_group_and_2_channels( new_cases_group = GroupFactory.create( name=name, is_big_cases=is_big_cases ) + bluesky_ch = ChannelFactory.create(bluesky=True, group=new_cases_group) mastodon_ch = ChannelFactory.create( mastodon=True, group=new_cases_group ) twitter_ch = ChannelFactory.create(twitter=True, group=new_cases_group) + new_cases_group.channels.add(bluesky_ch) new_cases_group.channels.add(mastodon_ch) new_cases_group.channels.add(twitter_ch) return new_cases_group From 4da7b5d8bfccfa0e3c9d5ffd05121dea619f2568 Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 1 May 2024 16:19:06 -0400 Subject: [PATCH 04/10] feat(cli): Add article_url to subscribe command --- bc/subscription/management/commands/subscribe.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bc/subscription/management/commands/subscribe.py b/bc/subscription/management/commands/subscribe.py index e3fbbb94..06645159 100644 --- a/bc/subscription/management/commands/subscribe.py +++ b/bc/subscription/management/commands/subscribe.py @@ -95,6 +95,15 @@ def handle(self, *args, **options): if case_summary: result["case_summary"] = case_summary + article_url = result.get("article_url", "") + custom_article_url = input( + "\nEnter a an article url or press enter to leave it empty:\n\n" + + (f"Default: {article_url}\n" if article_url else "") + + "article_url: " + ) + if custom_article_url: + result["article_url"] = custom_article_url + instance = "channel" if options["show_channels"] else "group" self.stdout.write( self.style.SUCCESS( From 5c590e16d80b9e7a032331dc2cc5b5bc99aa10d2 Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 1 May 2024 16:29:01 -0400 Subject: [PATCH 05/10] fix unset mocks + errant print statement --- bc/subscription/tasks.py | 2 -- bc/subscription/tests/test_tasks.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bc/subscription/tasks.py b/bc/subscription/tasks.py index 0e214f93..57ba69ab 100644 --- a/bc/subscription/tasks.py +++ b/bc/subscription/tasks.py @@ -111,8 +111,6 @@ def enqueue_posts_for_new_case( initial_complaint_link=initial_complaint_link, ) - print(message) - api = channel.get_api_wrapper() sponsor_message = None diff --git a/bc/subscription/tests/test_tasks.py b/bc/subscription/tests/test_tasks.py index d6d64505..3b37acb1 100644 --- a/bc/subscription/tests/test_tasks.py +++ b/bc/subscription/tests/test_tasks.py @@ -426,6 +426,7 @@ def test_can_enqueue_new_case_status( api_wrapper = self.mock_api_wrapper() mock_api.return_value = api_wrapper mock_lookup.return_value = None + mock_docket_by_cl_id.return_value = None message, _ = TWITTER_FOLLOW_A_NEW_CASE.format( docket=self.subscription.name_with_summary, docket_link=self.subscription.cl_url, @@ -449,6 +450,7 @@ def test_can_post_new_case_w_link( api_wrapper = self.mock_api_wrapper() mock_api.return_value = api_wrapper mock_lookup.return_value = None + mock_docket_by_cl_id.return_value = None message, _ = TWITTER_FOLLOW_A_NEW_CASE.format( docket=self.subscription_w_link.name_with_summary, docket_link=self.subscription_w_link.cl_url, From 201c8f02c4ce2204d950911f039b15742658a30f Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Thu, 2 May 2024 13:12:21 -0400 Subject: [PATCH 06/10] feat(templates): Remove post_replace logic --- bc/core/utils/status/base.py | 4 -- bc/core/utils/status/templates.py | 70 +++++++------------------------ 2 files changed, 15 insertions(+), 59 deletions(-) diff --git a/bc/core/utils/status/base.py b/bc/core/utils/status/base.py index 815668dd..44fbd8df 100644 --- a/bc/core/utils/status/base.py +++ b/bc/core/utils/status/base.py @@ -28,7 +28,6 @@ class BaseTemplate: link_placeholders: list[str] max_characters: int border_color: tuple[int, ...] = (243, 195, 62) - post_replace: list[tuple[str, str]] = field(default_factory=list) is_valid: bool = True _django_template: Template | None = None @@ -133,9 +132,6 @@ def format(self, *args, **kwargs) -> tuple[str, TextImage | None]: else: text = self.str_template.format(**kwargs) - for find_str, sub_str in self.post_replace: - text = text.replace(find_str, sub_str) - self.is_valid = self._check_output_validity(text) return text, image diff --git a/bc/core/utils/status/templates.py b/bc/core/utils/status/templates.py index fae4d408..63d170d7 100644 --- a/bc/core/utils/status/templates.py +++ b/bc/core/utils/status/templates.py @@ -46,27 +46,15 @@ MASTODON_FOLLOW_A_NEW_CASE = MastodonTemplate( link_placeholders=["docket_link", "initial_complaint_link", "article_url"], - # Remove extra newlines caused by empty template blocks - post_replace=[ - ("\n\n\n\n", "\n\n"), - ("\n\n\n\n", "\n\n"), - ("\n\n\n", "\n\n"), - ], - str_template="""I'm now following {{docket}}: + str_template="""I'm now following {{docket}}:{% if date_filed %} -{% if date_filed %} -Filed: {{date_filed}} -{% endif %} +Filed: {{date_filed}}{% endif %} -Docket: {{docket_link}} +Docket: {{docket_link}}{% if initial_complaint_type and initial_complaint_link %} -{% if initial_complaint_type and initial_complaint_link %} -{{initial_complaint_type}}: {{initial_complaint_link}} -{% endif %} +{{initial_complaint_type}}: {{initial_complaint_link}}{% endif %}{% if article_url %} -{% if article_url %} -Context: {{article_url}} -{% endif %} +Context: {{article_url}}{% endif %} #CL{{docket_id}}""", ) @@ -93,27 +81,15 @@ TWITTER_FOLLOW_A_NEW_CASE = TwitterTemplate( link_placeholders=["docket_link", "initial_complaint_link", "article_url"], - # Remove extra newlines caused by empty template blocks - post_replace=[ - ("\n\n\n\n", "\n\n"), - ("\n\n\n\n", "\n\n"), - ("\n\n\n", "\n\n"), - ], - str_template="""I'm now following {{docket}}: + str_template="""I'm now following {{docket}}:{% if date_filed %} -{% if date_filed %} -Filed: {{date_filed}} -{% endif %} +Filed: {{date_filed}}{% endif %} -Docket: {{docket_link}} +Docket: {{docket_link}}{% if initial_complaint_type and initial_complaint_link %} -{% if initial_complaint_type and initial_complaint_link %} -{{initial_complaint_type}}: {{initial_complaint_link}} -{% endif %} +{{initial_complaint_type}}: {{initial_complaint_link}}{% endif %}{% if article_url %} -{% if article_url %} -Context: {{article_url}} -{% endif %} +Context: {{article_url}}{% endif %} #CL{{docket_id}}""", ) @@ -121,27 +97,11 @@ BLUESKY_FOLLOW_A_NEW_CASE = BlueskyTemplate( link_placeholders=["docket_link", "article_url", "initial_complaint_link"], # Remove extra newlines caused by empty template blocks - post_replace=[ - ("\n\n\n\n", "\n\n"), - ("\n\n\n\n", "\n\n"), - ("\n\n\n", "\n\n"), - ("\n\n\n", "\n\n"), - ("\n\n |", " |"), - ("\n |", " |"), - ], - str_template="""I'm now following {{docket}}: - -{% if date_filed %} -Filed: {{date_filed}} -{% endif %} - -[View Full Case]({{docket_link}}) -{% if article_url %} - | [Background Info]({{article_url}}) -{% endif %} -{% if initial_complaint_type and initial_complaint_link %} - | [{{initial_complaint_type}}]({{initial_complaint_link}}) -{% endif %} + str_template="""I'm now following {{docket}}:{% if date_filed %} + +Filed: {{date_filed}}{% endif %} + +[View Full Case]({{docket_link}}){% if article_url %} | [Background Info]({{article_url}}){% endif %}{% if initial_complaint_type and initial_complaint_link %} | [{{initial_complaint_type}}]({{initial_complaint_link}}){% endif %} #CL{{docket_id}}""", ) From 8e5370d3599d0cbae2938689358ffd433e74ced9 Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 8 May 2024 13:50:55 -0400 Subject: [PATCH 07/10] Remove some extraneous imports --- bc/core/utils/status/base.py | 2 +- bc/subscription/tasks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bc/core/utils/status/base.py b/bc/core/utils/status/base.py index 44fbd8df..bacce0e9 100644 --- a/bc/core/utils/status/base.py +++ b/bc/core/utils/status/base.py @@ -1,5 +1,5 @@ import re -from dataclasses import dataclass, field +from dataclasses import dataclass from string import Formatter from django.template import Context, NodeList, Template diff --git a/bc/subscription/tasks.py b/bc/subscription/tasks.py index 57ba69ab..3527dbc6 100644 --- a/bc/subscription/tasks.py +++ b/bc/subscription/tasks.py @@ -1,5 +1,5 @@ from datetime import date, datetime -from typing import Literal, TypeVar +from typing import Literal from django.conf import settings from django.db import transaction From 97fccbd3af38947f93b03ffa74e49e2e1b162271 Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 8 May 2024 13:53:02 -0400 Subject: [PATCH 08/10] Simplify is_bankruptcy (and add tests for good measure) --- bc/subscription/tasks.py | 6 +++-- bc/subscription/tests/test_utils.py | 17 +++++++++++- bc/subscription/utils/courtlistener.py | 36 ++++---------------------- 3 files changed, 25 insertions(+), 34 deletions(-) diff --git a/bc/subscription/tasks.py b/bc/subscription/tasks.py index 3527dbc6..0757e353 100644 --- a/bc/subscription/tasks.py +++ b/bc/subscription/tasks.py @@ -90,9 +90,11 @@ def enqueue_posts_for_new_case( ) initial_complaint_type: Literal["Petition", "Complaint"] | None = None - if initial_complaint_link and docket: + if initial_complaint_link: initial_complaint_type = ( - "Petition" if is_bankruptcy(docket) else "Complaint" + "Petition" + if is_bankruptcy(subscription.cl_court_id) + else "Complaint" ) for channel in get_channels_per_subscription(subscription.pk): diff --git a/bc/subscription/tests/test_utils.py b/bc/subscription/tests/test_utils.py index 262a7217..3a9e92e5 100644 --- a/bc/subscription/tests/test_utils.py +++ b/bc/subscription/tests/test_utils.py @@ -3,7 +3,10 @@ from django.core.exceptions import ValidationError from django.test import SimpleTestCase -from bc.subscription.utils.courtlistener import get_docket_id_from_query +from bc.subscription.utils.courtlistener import ( + get_docket_id_from_query, + is_bankruptcy, +) class SearchBarTest(SimpleTestCase): @@ -73,3 +76,15 @@ def test_extract_docket_id_from_query(self, mock_requests): result = get_docket_id_from_query(test["query"]) self.assertEqual(result, test["docket_id"]) + + +class IsBankruptcyTest(SimpleTestCase): + def test_is_bankruptcy(self): + self.assertTrue(is_bankruptcy("13B")) + self.assertTrue(is_bankruptcy("13b")) + + def test_is_bankruptcy_false(self): + self.assertFalse(is_bankruptcy("13f")) + + def test_is_bankruptcy_none(self): + self.assertFalse(is_bankruptcy(None)) # type: ignore diff --git a/bc/subscription/utils/courtlistener.py b/bc/subscription/utils/courtlistener.py index 928eefd1..cc4318ae 100644 --- a/bc/subscription/utils/courtlistener.py +++ b/bc/subscription/utils/courtlistener.py @@ -330,41 +330,15 @@ def handle_multi_defendant_cases(queue): logger.debug("handle_multi_defendant_cases(): done") -def is_bankruptcy(docket: DocketDict) -> bool: +def is_bankruptcy(court_id: str) -> bool: """ - For a given docket document (i.e., one retrieved via - `lookup_docket_by_cl_id`), try to determine if it's + For a given court id, try to determine if it's a bankruptcy court. Args: - docket (dict): The docket document for the case + court_id (str): The court id to check the type of Returns: - bool: Whether this is a bankruptcy case + bool: Whether this is a bankruptcy court """ - # There seems to be a few approaches we could take here: - # - # - Check the nature_of_suit field and compare against the bankruptcy code (4900) - # listed at https://github.com/freelawproject/courtlistener/blob/main/cl/recap/constants.py#L583 - # - # - Check the nature_of_suit filed inside the idb_data field and compare - # against the bankruptcy code listed above - # - # - Check the court name/id against a list of bankruptcy courts, but I - # don't think we have that anywhere handy at the moment. - # - # For now, I'm going to go with a combo of the first two approaches, since - # it seems like the most efficient path for now. - - if not docket: - return False - - # idb_data is sometimes present and None, so do this instead of - # providing a default to the get function so we always have a dict even - # if it's an empty one. - idb_data = docket.get("idb_data") or cast(DocketIDBDataDict, {}) - - nature_of_suit = docket.get("nature_of_suit") - idb_nature_of_suit = idb_data.get("nature_of_suit") - - return "4900" in [nature_of_suit, idb_nature_of_suit] + return court_id.lower().endswith("b") if court_id is not None else False From 212257f023359e05cba2c9581da98efb0f09a82d Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 8 May 2024 14:02:20 -0400 Subject: [PATCH 09/10] Simplify some of our None checking for enqueue_posts_for_new_case per @erosendo --- bc/subscription/tasks.py | 47 ++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/bc/subscription/tasks.py b/bc/subscription/tasks.py index 0757e353..a42d01f3 100644 --- a/bc/subscription/tasks.py +++ b/bc/subscription/tasks.py @@ -58,30 +58,29 @@ def enqueue_posts_for_new_case( if document: files = get_thumbnails_from_range(document, "[1,2,3,4]") - if initial_document is None: - initial_document = lookup_initial_complaint(subscription.cl_docket_id) - docket: DocketDict | None = None - if subscription.cl_docket_id: - docket = lookup_docket_by_cl_id(subscription.cl_docket_id) - - date_filed: date | None = None + date_filed: date days_old = 0 - date_filed_str = docket["date_filed"] if docket else None - if date_filed_str: - try: - date_filed = ( - datetime.strptime(date_filed_str, "%Y-%m-%d").date() - if date_filed_str - else None + if subscription.cl_docket_id: + if initial_document is None: + initial_document = lookup_initial_complaint( + subscription.cl_docket_id ) + docket = lookup_docket_by_cl_id(subscription.cl_docket_id) + + date_filed_str = docket["date_filed"] if docket else None + if date_filed_str: + try: + date_filed = datetime.strptime( + date_filed_str, "%Y-%m-%d" + ).date() - assert date_filed is not None # For type checking purposes - days_old = (date.today() - date_filed).days - except ValueError: - # If the date_filed_str is not a valid date, just continue on - pass + assert date_filed is not None # For type checking purposes + days_old = (date.today() - date_filed).days + except ValueError: + # If the date_filed_str is not a valid date, just continue on + pass initial_complaint_link = ( f"https://www.courtlistener.com{initial_document['absolute_url']}" @@ -89,13 +88,9 @@ def enqueue_posts_for_new_case( else None ) - initial_complaint_type: Literal["Petition", "Complaint"] | None = None - if initial_complaint_link: - initial_complaint_type = ( - "Petition" - if is_bankruptcy(subscription.cl_court_id) - else "Complaint" - ) + initial_complaint_type: Literal["Petition", "Complaint"] = ( + "Petition" if is_bankruptcy(subscription.cl_court_id) else "Complaint" + ) for channel in get_channels_per_subscription(subscription.pk): template = get_new_case_template(channel.service) From 422eaed7d84ff45e757050cb8de6baf4933140dc Mon Sep 17 00:00:00 2001 From: Adam Weeden Date: Wed, 8 May 2024 14:03:04 -0400 Subject: [PATCH 10/10] Remove a few more unused imports --- bc/subscription/utils/courtlistener.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bc/subscription/utils/courtlistener.py b/bc/subscription/utils/courtlistener.py index cc4318ae..b0c407ec 100644 --- a/bc/subscription/utils/courtlistener.py +++ b/bc/subscription/utils/courtlistener.py @@ -1,6 +1,6 @@ import logging import re -from typing import Any, NotRequired, TypedDict, cast +from typing import NotRequired, TypedDict import courts_db import requests