diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 01aaeb7868..5181fdc9f4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,23 +49,6 @@ jobs: matrix: tag_flags: ["--exclude-tag selenium", "--tag selenium"] steps: - - name: Check out solr - uses: actions/checkout@v4 - with: - repository: freelawproject/courtlistener-solr-server - ref: main - path: courtlistener-solr-server - - name: Set up solr permissions - run: | - cd courtlistener-solr-server - sudo chown -R :1024 data - sudo chown -R :1024 solr - sudo find data -type d -exec chmod g+s {} \; - sudo find solr -type d -exec chmod g+s {} \; - sudo find data -type d -exec chmod 775 {} \; - sudo find solr -type d -exec chmod 775 {} \; - sudo find data -type f -exec chmod 664 {} \; - sudo find solr -type f -exec chmod 664 {} \; - name: Check out CourtListener uses: actions/checkout@v4 with: diff --git a/cl/alerts/tests/tests.py b/cl/alerts/tests/tests.py index 57817100a8..6376730f05 100644 --- a/cl/alerts/tests/tests.py +++ b/cl/alerts/tests/tests.py @@ -2392,7 +2392,7 @@ def test_es_alert_update_and_delete(self, mock_abort_audio): user=self.user_profile.user, rate=Alert.REAL_TIME, name="Test Alert OA", - query="type=oa&docket_number=19-1010", + query="type=oa&docket_number=19-1010&order_by=score desc", alert_type=SEARCH_TYPES.ORAL_ARGUMENT, ) @@ -2402,6 +2402,7 @@ def test_es_alert_update_and_delete(self, mock_abort_audio): response_str = str(doc.to_dict()) self.assertIn("'query': '19-1010'", response_str) self.assertIn("'rate': 'rt'", response_str) + self.assertNotIn("function_score", response_str) # Update Alert search_alert_1.query = "type=oa&docket_number=19-1020" diff --git a/cl/alerts/tests/tests_recap_alerts.py b/cl/alerts/tests/tests_recap_alerts.py index 8b99cba47e..1076802912 100644 --- a/cl/alerts/tests/tests_recap_alerts.py +++ b/cl/alerts/tests/tests_recap_alerts.py @@ -2221,13 +2221,19 @@ def test_index_and_delete_recap_alerts_from_percolator( user=self.user_profile.user, rate=Alert.WEEKLY, name="Test Alert Docket Only", - query='q="401 Civil"&type=r', + query='q="401 Civil"&type=r&order_by=score desc', alert_type=SEARCH_TYPES.RECAP, ) self.assertTrue( RECAPPercolator.exists(id=docket_only_alert.pk), msg=f"Alert id: {docket_only_alert.pk} was not indexed.", ) + alert_doc = RECAPPercolator.get(id=docket_only_alert.pk) + response_str = str(alert_doc.to_dict()) + self.assertIn("401 Civil", response_str) + self.assertIn("'rate': 'wly'", response_str) + # function_score breaks percolator queries. Ensure it is never indexed. + self.assertNotIn("function_score", response_str) docket_only_alert_id = docket_only_alert.pk # Remove the alert. diff --git a/cl/api/static/png/add-webhook-endpoint-v2.png b/cl/api/static/png/add-webhook-endpoint-v2.png new file mode 100644 index 0000000000..63098fc17a Binary files /dev/null and b/cl/api/static/png/add-webhook-endpoint-v2.png differ diff --git a/cl/api/static/png/add-webhook-endpoint.png b/cl/api/static/png/add-webhook-endpoint.png deleted file mode 100644 index a2a4ebf227..0000000000 Binary files a/cl/api/static/png/add-webhook-endpoint.png and /dev/null differ diff --git a/cl/api/static/png/re-enable-webhook-v2.png b/cl/api/static/png/re-enable-webhook-v2.png new file mode 100644 index 0000000000..7241a9efbe Binary files /dev/null and b/cl/api/static/png/re-enable-webhook-v2.png differ diff --git a/cl/api/static/png/re-enable-webhook.png b/cl/api/static/png/re-enable-webhook.png deleted file mode 100644 index 7e8cbb1e14..0000000000 Binary files a/cl/api/static/png/re-enable-webhook.png and /dev/null differ diff --git a/cl/api/static/png/test-curl-webhook-event-v2.png b/cl/api/static/png/test-curl-webhook-event-v2.png new file mode 100644 index 0000000000..484b150e50 Binary files /dev/null and b/cl/api/static/png/test-curl-webhook-event-v2.png differ diff --git a/cl/api/static/png/test-curl-webhook-event.png b/cl/api/static/png/test-curl-webhook-event.png deleted file mode 100644 index 543a371ccd..0000000000 Binary files a/cl/api/static/png/test-curl-webhook-event.png and /dev/null differ diff --git a/cl/api/static/png/test-json-webhook-event-v2.png b/cl/api/static/png/test-json-webhook-event-v2.png new file mode 100644 index 0000000000..184f4fd532 Binary files /dev/null and b/cl/api/static/png/test-json-webhook-event-v2.png differ diff --git a/cl/api/static/png/test-json-webhook-event.png b/cl/api/static/png/test-json-webhook-event.png deleted file mode 100644 index 6c7cf64fc5..0000000000 Binary files a/cl/api/static/png/test-json-webhook-event.png and /dev/null differ diff --git a/cl/api/static/png/webhook-disabled-v2.png b/cl/api/static/png/webhook-disabled-v2.png new file mode 100644 index 0000000000..2d77c55a24 Binary files /dev/null and b/cl/api/static/png/webhook-disabled-v2.png differ diff --git a/cl/api/static/png/webhook-disabled.png b/cl/api/static/png/webhook-disabled.png deleted file mode 100644 index dcb4d2f710..0000000000 Binary files a/cl/api/static/png/webhook-disabled.png and /dev/null differ diff --git a/cl/api/static/png/webhooks-panel-v2.png b/cl/api/static/png/webhooks-panel-v2.png new file mode 100644 index 0000000000..57dbaf1b54 Binary files /dev/null and b/cl/api/static/png/webhooks-panel-v2.png differ diff --git a/cl/api/static/png/webhooks-panel.png b/cl/api/static/png/webhooks-panel.png deleted file mode 100644 index 3644c234d6..0000000000 Binary files a/cl/api/static/png/webhooks-panel.png and /dev/null differ diff --git a/cl/api/templates/search-api-docs-vlatest.html b/cl/api/templates/search-api-docs-vlatest.html index d331361ca4..c0a0d9162b 100644 --- a/cl/api/templates/search-api-docs-vlatest.html +++ b/cl/api/templates/search-api-docs-vlatest.html @@ -155,7 +155,7 @@

Setting the Result type

o - Case law opinions + Case law opinion clusters with nested Opinion documents. r diff --git a/cl/api/templates/webhooks-docs-vlatest.html b/cl/api/templates/webhooks-docs-vlatest.html index c1e9fadc7d..aafcf0344d 100644 --- a/cl/api/templates/webhooks-docs-vlatest.html +++ b/cl/api/templates/webhooks-docs-vlatest.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% load static %} {% load extras %} +{% load waffle_tags %} {% block title %}Webhook API – CourtListener.com{% endblock %} {% block description %} @@ -216,7 +217,7 @@

Retries and Automatic Endpoint Disablement

Fixed webhook endpoints can be re-enabled in the webhooks panel.

- screenshot of how to re-enable a webhook endpointChange Log

v1 First release

+
  • +

    v2 - This release introduces support for Case Law Search Alerts results with nested documents.

    +

    You can now select the webhook version when configuring an endpoint. For most webhook event types, there are no differences between v1 and v2, as the payload remains unchanged. +

    +

    In the Search Alert webhook event type, the Oral Arguments search response remains identical between v1 and v2.

    +

    For Case Law {% flag "recap-alerts-active" %}and RECAP{% endflag %} v2 now includes nested results, which are based on the new changes introduced in v4 of the Search API.

    +
  • {% endblock %} diff --git a/cl/api/templates/webhooks-getting-started.html b/cl/api/templates/webhooks-getting-started.html index 18dc46ea65..a28c6399ad 100644 --- a/cl/api/templates/webhooks-getting-started.html +++ b/cl/api/templates/webhooks-getting-started.html @@ -104,7 +104,7 @@

    Set Up a Webhook Endpoint in CourtListener

    To set up a webhook endpoint, begin by logging into CourtListener and going to the Webhooks panel in your profile:

    - screenshot of the webhook panelSet Up a Webhook Endpoint in CourtListener

    Click the “Add webhook” button and the “Add webhook endpoint” modal pops up:

    - screenshot of how to add a webhook endpointSet Up a Webhook Endpoint in CourtListener

  • Select the Event Type for which you wish to receive events.

    -

    You can only create one Webhook endpoint for each type of event. Please get in touch if this limitation causes difficulty for your application. +

  • +
  • +

    Choose the webhook version you wish to set up.

    +

    We recommend selecting the highest available version for your webhook. Refer to the Change Log for more details on webhook versions.

    +

    You can only create one Webhook endpoint for each type of event and version. Please get in touch if this limitation causes difficulty for your application.

  • @@ -141,7 +145,7 @@

    Set Up a Webhook Endpoint in CourtListener

    Click “Create webhook”

    Your Webhook endpoint is now created:

    - screenshot of a disabled webhook endpointTesting a Webhook endpoint.

    Getting a webhook working properly can be difficult, so we have a testing tool that will send you a sample webhook event on demand.

    To use the tool, go to webhooks panel and click the “Test” button for the endpoint you wish to test:

    - screenshot of a disabled webhook endpointTesting a Webhook endpoint.

    In the “As JSON” tab, you can ask our server to send a test event to your endpoint. When you click “Send Webhook Test Event” a new event is created with the information shown and is sent to your endpoint. Test events are not retried, but can be seen in the “Test Logs” tab.

    - screenshot of the webhook json test modalTesting a Webhook endpoint.

  • In the “As cURL” tab, you can copy/paste a curl command that can be used to send a test event to your local dev environment.

    - screenshot of the webhook curl test modal Dict[str, Dict[str, int]]: - """Invert the user logs for a period of time + """Aggregate API usage statistics per user over a date range. - The user logs have the date in the key and the user as part of the set: + - Anonymous users are aggregated under the key 'AnonymousUser'. + - Both v3 and v4 API counts are combined in the results. - 'api:v3.user.d:2016-10-01.counts': { - mlissner: 22, - joe_hazard: 33, - } - - This inverts these entries to: + :param start: Beginning date (inclusive) for the query range + :param end: End date (inclusive) for the query range + :param add_usernames: If True, replaces user IDs with usernames as keys. + When False, uses only user IDs as keys. - users: { - mlissner: { - 2016-10-01: 22, - total: 22, - }, - joe_hazard: { - 2016-10-01: 33, - total: 33, - } - } - :param start: The beginning date (inclusive) you want the results for. A - :param end: The end date (inclusive) you want the results for. - :param add_usernames: Stats are stored with the user ID. If this is True, - add an alias in the returned dictionary that contains the username as well. - :return The inverted dictionary + :return: Dictionary mapping user identifiers (usernames if `add_usernames=True`, + otherwise user IDs) to their daily API usage counts and totals. + Inner dictionaries are ordered by date. Only dates with usage are included. """ r = get_redis_interface("STATS") pipe = r.pipeline() dates = make_date_str_list(start, end) + versions = ["v3", "v4"] for d in dates: - pipe.zrange(f"api:v3.user.d:{d}.counts", 0, -1, withscores=True) + for version in versions: + pipe.zrange( + f"api:{version}.user.d:{d}.counts", + 0, + -1, + withscores=True, + ) + + # results contains alternating v3/v4 API usage data for each date queried. + # For example, if querying 2023-01-01 to 2023-01-02, results might look like: + # [ + # # 2023-01-01 v3 data: [(user_id, count), ...] + # [("1", 100.0), ("2", 50.0)], + # # 2023-01-01 v4 data + # [("1", 50.0), ("2", 25.0)], + # # 2023-01-02 v3 data + # [("1", 200.0), ("2", 100.0)], + # # 2023-01-02 v4 data + # [("1", 100.0), ("2", 50.0)] + # ] + # We zip this with dates to combine v3/v4 counts per user per day results = pipe.execute() - # results is a list of results for each of the zrange queries above. Zip - # those results with the date that created it, and invert the whole thing. out: defaultdict = defaultdict(dict) - for d, result in zip(dates, results): - for user_id, count in result: - if user_id == "None" or user_id == "AnonymousUser": - user_id = "AnonymousUser" - else: - user_id = int(user_id) - count = int(count) - if out.get(user_id): - out[user_id][d] = count - out[user_id]["total"] += count - else: - out[user_id] = {d: count, "total": count} + + def update_user_counts(_user_id, _count, _date): + user_is_anonymous = _user_id == "None" or _user_id == "AnonymousUser" + _user_id = "AnonymousUser" if user_is_anonymous else int(_user_id) + _count = int(_count) + out.setdefault(_user_id, OrderedDict()) + out[_user_id].setdefault(_date, 0) + out[_user_id][_date] += _count + out[_user_id].setdefault("total", 0) + out[_user_id]["total"] += _count + + for d, api_usage in zip(dates, batched(results, len(versions))): + for user_id, count in chain(*api_usage): + update_user_counts(user_id, count, d) # Sort the values for k, v in out.items(): diff --git a/cl/assets/static-global/css/override.css b/cl/assets/static-global/css/override.css index 1cdfdbb510..f7862d3be7 100644 --- a/cl/assets/static-global/css/override.css +++ b/cl/assets/static-global/css/override.css @@ -1829,3 +1829,11 @@ rect.series-segment { .prayer-button[data-gap-size="large"]{ margin-left: 44px; } + +.gap-1 { + gap: 0.25rem +} + +.gap-2 { + gap: 0.5rem +} diff --git a/cl/assets/templates/admin/docket_change_form.html b/cl/assets/templates/admin/docket_change_form.html new file mode 100644 index 0000000000..c3a4b000fc --- /dev/null +++ b/cl/assets/templates/admin/docket_change_form.html @@ -0,0 +1,19 @@ +{% extends "admin/change_form.html" %} + +{% block object-tools-items %} + {% if docket_entries_url %} +

  • + + View Docket Entries + +
  • + {% endif %} + {% if docket_alerts_url %} +
  • + + View Docket Alerts + +
  • + {% endif %} + {{ block.super }} +{% endblock object-tools-items %} diff --git a/cl/assets/templates/admin/user_change_form.html b/cl/assets/templates/admin/user_change_form.html new file mode 100644 index 0000000000..1fff0b9c1f --- /dev/null +++ b/cl/assets/templates/admin/user_change_form.html @@ -0,0 +1,19 @@ +{% extends "admin/change_form.html" %} + +{% block object-tools-items %} + {% if proxy_events_url %} +
  • + + View UserProxy Events + +
  • + {% endif %} + {% if profile_events_url %} +
  • + + View UserProfile Events + +
  • + {% endif %} + {{ block.super }} +{% endblock object-tools-items %} diff --git a/cl/assets/templates/base.html b/cl/assets/templates/base.html index 667546f937..45af5aa5dc 100644 --- a/cl/assets/templates/base.html +++ b/cl/assets/templates/base.html @@ -233,7 +233,7 @@

    You did not supply the "private" variable to your template. {% flag "pray-and-pay" %}
  • - Pray and Pay Project + Pray and Pay Project
  • {% endflag %}
  • diff --git a/cl/corpus_importer/factories.py b/cl/corpus_importer/factories.py index c9b7bdc86d..3cbe3fbcc3 100644 --- a/cl/corpus_importer/factories.py +++ b/cl/corpus_importer/factories.py @@ -36,29 +36,6 @@ class CaseLawFactory(factory.DictFactory): docket_number = Faker("federal_district_docket_number") -class RssDocketEntryDataFactory(factory.DictFactory): - date_filed = Faker("date_object") - description = "" - document_number = Faker("pyint", min_value=1, max_value=100) - pacer_doc_id = Faker("random_id_string") - pacer_seq_no = Faker("random_id_string") - short_description = Faker("text", max_nb_chars=40) - - -class RssDocketDataFactory(factory.DictFactory): - court_id = FuzzyText(length=4, chars=string.ascii_lowercase, suffix="d") - case_name = Faker("case_name") - docket_entries = factory.List( - [factory.SubFactory(RssDocketEntryDataFactory)] - ) - docket_number = Faker("federal_district_docket_number") - office = Faker("pyint", min_value=1, max_value=100) - chapter = Faker("pyint", min_value=1, max_value=100) - trustee_str = Faker("text", max_nb_chars=15) - type = Faker("text", max_nb_chars=8) - pacer_case_id = Faker("random_id_string") - - class FreeOpinionRowDataFactory(factory.DictFactory): case_name = Faker("case_name") cause = Faker("text", max_nb_chars=8) diff --git a/cl/corpus_importer/management/commands/troller_bk.py b/cl/corpus_importer/management/commands/troller_bk.py deleted file mode 100644 index 054d9e4682..0000000000 --- a/cl/corpus_importer/management/commands/troller_bk.py +++ /dev/null @@ -1,864 +0,0 @@ -# Import the troller BK RSS feeds -import argparse -import concurrent.futures -import gc -import linecache -import re -import sys -import threading -from collections import defaultdict -from datetime import datetime, timezone -from queue import Queue -from typing import Any, DefaultDict, Mapping, TypedDict -from urllib.parse import unquote - -from asgiref.sync import async_to_sync, sync_to_async -from dateutil.parser import ParserError -from django.db import DataError, IntegrityError, transaction -from django.db.models import Q -from django.utils.text import slugify -from django.utils.timezone import make_aware -from juriscraper.pacer import PacerRssFeed - -from cl.custom_filters.templatetags.text_filters import best_case_name -from cl.lib.command_utils import VerboseCommand, logger -from cl.lib.model_helpers import make_docket_number_core -from cl.lib.pacer import map_pacer_to_cl_id -from cl.lib.redis_utils import get_redis_interface -from cl.lib.storage import S3PrivateUUIDStorage -from cl.lib.string_utils import trunc -from cl.lib.timezone_helpers import localize_date_and_time -from cl.recap.mergers import ( - add_bankruptcy_data_to_docket, - calculate_recap_sequence_numbers, - find_docket_object, - update_docket_metadata, -) -from cl.recap_rss.tasks import ( - cache_hash, - get_last_build_date, - hash_item, - is_cached, -) -from cl.search.models import Court, Docket, DocketEntry, RECAPDocument - -FILES_BUFFER_THRESHOLD = 3 - - -async def check_for_early_termination( - court_id: str, docket: dict[str, Any] -) -> str | None: - """Check for early termination, skip the rest of the file in case a cached - item is reached or skip a single item if it doesn't contain required data. - Cache the current item. - - :param court_id: The court the docket entries belong to. - :param docket: A dict containing the item data. - :return: A "break" string indicating if the rest of the file should be - omitted, "continue" if only the current item should be omitted or None. - """ - item_hash = hash_item(docket) - if await is_cached(item_hash): - logger.info( - f"Hit a cached item, finishing adding bulk entries for {court_id} feed. " - ) - return "break" - - await cache_hash(item_hash) - if ( - not docket["pacer_case_id"] - and not docket["docket_number"] - or not len(docket["docket_entries"]) - ): - return "continue" - return None - - -def add_new_docket_from_rss( - court_id: str, - d: Docket, - docket: dict[str, Any], - unique_dockets: dict[str, Any], - dockets_to_create: list[Docket], -) -> None: - """Set metadata and extra values to the Docket object and append it to - the list of dockets to be added in bulk. - - :param court_id: The court the docket entries belong to. - :param d: The Docket object to modify and add. - :param docket: The dict containing the item data. - :param unique_dockets: The dict to keep track of unique dockets to add. - :param dockets_to_create: The list of dockets to add in bulk. - :return: None - """ - - date_filed, time_filed = localize_date_and_time( - court_id, docket["docket_entries"][0]["date_filed"] - ) - async_to_sync(update_docket_metadata)(d, docket) - d.pacer_case_id = docket["pacer_case_id"] - d.slug = slugify(trunc(best_case_name(d), 75)) - d.date_last_filing = date_filed - if d.docket_number: - d.docket_number_core = make_docket_number_core(d.docket_number) - - docket_in_list = unique_dockets.get(docket["docket_number"], None) - if not docket_in_list: - unique_dockets[docket["docket_number"]] = docket - dockets_to_create.append(d) - - -def do_bulk_additions( - court_id: str, - unique_dockets: dict[str, Any], - dockets_to_create: list[Docket], - des_to_add_no_existing_docket: DefaultDict[str, list[dict[str, Any]]], - des_to_add_existing_docket: list[tuple[int, dict[str, Any]]], -) -> tuple[list[int], int]: - """Create dockets, docket entries and recap documents in bulk. - - :param court_id: The court the docket entries belong to. - :param unique_dockets: The dict to keep track of unique dockets to add. - :param dockets_to_create: The list of dockets to add in bulk. - :param des_to_add_no_existing_docket: A DefaultDict containing entries to - add which its parent docket didn't exist, docket_number: [entries] - :param des_to_add_existing_docket: A list of tuples containing entries to - add which its parent docket exists, (docket.pk, docket_entry) - :return: A tuple containing a list of created recap documents pks, the - number of dockets created. - """ - - with transaction.atomic(): - # Create dockets in bulk. - d_bulk_created = Docket.objects.bulk_create(dockets_to_create) - - # Add bankruptcy data to dockets. - for d in d_bulk_created: - docket_data = unique_dockets.get(d.docket_number) - if docket_data: - add_bankruptcy_data_to_docket(d, docket_data) - - # Find and assign the created docket pk to the list of docket entries - # to add. - for d_created in d_bulk_created: - docket_number = d_created.docket_number - des_to_create = des_to_add_no_existing_docket[docket_number] - for de_entry in des_to_create: - des_to_add_existing_docket.append((d_created.pk, de_entry)) - - # Create docket entries in bulk. - docket_entries_to_add_bulk = get_docket_entries_to_add( - court_id, des_to_add_existing_docket - ) - des_bulk_created = DocketEntry.objects.bulk_create( - docket_entries_to_add_bulk - ) - - # Create RECAP documents in bulk. - rds_to_create_bulk = get_rds_to_add( - des_bulk_created, des_to_add_existing_docket - ) - rd_bulk_created = RECAPDocument.objects.bulk_create(rds_to_create_bulk) - - return [rd.pk for rd in rd_bulk_created], len(d_bulk_created) - - -def get_docket_entries_to_add( - court_id: str, des_to_add_existing_docket: list[tuple[int, dict[str, Any]]] -) -> list[DocketEntry]: - """Make and return a list of the DocketEntry objects to save in bulk. - - :param court_id: The court the docket entries belong to. - :param des_to_add_existing_docket: A list of tuples containing the docket - pk the entry belongs to, the docket entry dict. - :return: A list of DocketEntry objects. - """ - - docket_entries_to_add_bulk = [] - for de_add in des_to_add_existing_docket: - d_pk = de_add[0] - docket_entry = de_add[1] - calculate_recap_sequence_numbers([docket_entry], court_id) - date_filed, time_filed = localize_date_and_time( - court_id, docket_entry["date_filed"] - ) - de_to_add = DocketEntry( - docket_id=d_pk, - entry_number=docket_entry["document_number"], - description=docket_entry["description"], - pacer_sequence_number=docket_entry["pacer_seq_no"], - recap_sequence_number=docket_entry["recap_sequence_number"], - time_filed=time_filed, - date_filed=date_filed, - ) - docket_entries_to_add_bulk.append(de_to_add) - return docket_entries_to_add_bulk - - -def get_rds_to_add( - des_bulk_created: list[DocketEntry], - des_to_add_existing_docket: list[tuple[int, dict[str, Any]]], -) -> list[RECAPDocument]: - """Make and return a list of the RECAPDocument objects to save in bulk. - - :param des_bulk_created: The list of DocketEntry objects saved in a - previous step. - :param des_to_add_existing_docket: A list of tuples containing the docket - pk the entry belongs to, the docket entry dict. - :return: A list of RECAPDocument objects. - """ - - rds_to_create_bulk = [] - for d_entry, bulk_created in zip( - des_to_add_existing_docket, des_bulk_created - ): - de_pk = bulk_created.pk - docket_entry = d_entry[1] - document_number = docket_entry["document_number"] or "" - rd = RECAPDocument( - docket_entry_id=de_pk, - document_number=document_number, - description=docket_entry["short_description"], - document_type=RECAPDocument.PACER_DOCUMENT, - pacer_doc_id=docket_entry["pacer_doc_id"], - is_available=False, - ) - rds_to_create_bulk.append(rd) - - return rds_to_create_bulk - - -async def merge_rss_data( - feed_data: list[dict[str, Any]], - court_id: str, - build_date: datetime | None, -) -> tuple[list[int], int]: - """Merge the RSS data into the database - - :param feed_data: Data from an RSS feed file - :param court_id: The PACER court ID for the item - :param build_date: The RSS date build. - :return: A list of RECAPDocument PKs that can be passed to Solr - """ - - court_id = map_pacer_to_cl_id(court_id) - court = await Court.objects.aget(pk=court_id) - dockets_created = 0 - all_rds_created: list[int] = [] - court_ids = ( - Court.federal_courts.district_or_bankruptcy_pacer_courts().values_list( - "pk", flat=True - ) - ) - courts_exceptions_no_rss = ["miwb", "nceb", "pamd", "cit"] - if ( - build_date - and build_date - > make_aware(datetime(year=2018, month=4, day=20), timezone.utc) - and await court_ids.filter(id=court_id).aexists() - and court_id not in courts_exceptions_no_rss - ): - # Avoid parsing/adding feeds after we start scraping RSS Feeds for - # district and bankruptcy courts. - return all_rds_created, dockets_created - - dockets_to_create: list[Docket] = [] - unique_dockets: dict[str, Any] = {} - des_to_add_existing_docket: list[tuple[int, dict[str, Any]]] = [] - des_to_add_no_existing_docket: DefaultDict[str, list[dict[str, Any]]] = ( - defaultdict(list) - ) - for docket in feed_data: - skip_or_break = await check_for_early_termination(court_id, docket) - if skip_or_break == "continue": - continue - elif skip_or_break == "break": - break - - d = await find_docket_object( - court_id, - docket["pacer_case_id"], - docket["docket_number"], - docket.get("federal_defendant_number"), - docket.get("federal_dn_judge_initials_assigned"), - docket.get("federal_dn_judge_initials_referred"), - ) - docket_entry = docket["docket_entries"][0] - document_number = docket["docket_entries"][0]["document_number"] - if ( - document_number - and d.pk - and await d.docket_entries.filter( - entry_number=document_number - ).aexists() - ): - # It's an existing docket entry; let's not add it. - continue - else: - # Try finding the docket entry by short_description. - short_description = docket_entry["short_description"] - query = Q() - if short_description: - query |= Q( - recap_documents__description=docket_entry[ - "short_description" - ] - ) - if ( - d.pk - and await d.docket_entries.filter( - query, - date_filed=docket_entry["date_filed"], - entry_number=docket_entry["document_number"], - ).aexists() - ): - # It's an existing docket entry; let's not add it. - continue - - d.add_recap_source() - if not d.pk: - # Set metadata for the new docket and append the docket and entry - # to the list to add in bulk. - if ( - not docket["pacer_case_id"] - and court.jurisdiction != Court.FEDERAL_APPELLATE - ): - # Avoid adding the docket if it belongs to a district/bankr - # court and doesn't have a pacer_case_id - continue - - await sync_to_async(add_new_docket_from_rss)( - court_id, - d, - docket, - unique_dockets, - dockets_to_create, - ) - # Append docket entries to add in bulk. - des_to_add_no_existing_docket[docket["docket_number"]].append( - docket_entry - ) - else: - # Existing docket, update source, add bankr data and append the - # docket entry to add in bulk. - des_to_add_existing_docket.append((d.pk, docket_entry)) - try: - await d.asave(update_fields=["source"]) - await sync_to_async(add_bankruptcy_data_to_docket)(d, docket) - except (DataError, IntegrityError): - # Trouble. Log and move on - logger.warn( - "Got DataError or IntegrityError while saving docket." - ) - - rds_created_pks, dockets_created = await sync_to_async(do_bulk_additions)( - court_id, - unique_dockets, - dockets_to_create, - des_to_add_no_existing_docket, - des_to_add_existing_docket, - ) - all_rds_created.extend(rds_created_pks) - logger.info( - f"Finished adding {court_id} feed. Added {len(all_rds_created)} RDs." - ) - return all_rds_created, dockets_created - - -def parse_file( - binary_content: bytes, - court_id: str, -) -> tuple[Any, datetime | None]: - """Parse a RSS file and return the data. - - :param binary_content: The binary content of the file to parse. - :param court_id: The PACER court ID for the item - :return The parsed data from the retrieved XML feed. - """ - - feed = PacerRssFeed(court_id) - content = binary_content.decode("utf-8") - feed._parse_text(content) - build_date = get_last_build_date(binary_content) - return feed.data, build_date - - -def get_court_from_line(line: str): - """Get the court_id from the line. - - This is a bit annoying. Each file name looks something like: - - sources/troller-files/o-894|1599853056 - sources/troller-files/w-w-894|1599853056 - sources/troller-files/o-DCCF0395-BDBA-C444-149D8D8EFA2EC03D|1576082101 - sources/troller-files/w-88AC552F-BDBA-C444-1BD52598BA252265|1435103773 - sources/troller-files/w-w-DCCF049E-BDBA-C444-107C577164350B1E|1638858935 - sources/troller-files/w-88AC552F-BDBA-C444-1BD52598BA252265-1399913581 - sources/troller-files/w-w-Mariana|1638779760 - - The court_id is based on the part between the "/o-" and the "|" or "-". - Match it, look it up in our table of court IDs, and return the correct PACER ID. - - :param line: A line to a file in S3 - :return: The PACER court ID for the feed - """ - - court = None - regex = re.compile( - r"([A-Z0-9]{8}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{16})|-([0-9]{3})\||-([0-9]{3})-|(Mariana)" - ) - match = re.search(regex, line) - if match is None: - return None - if match.group(1): - court = match.group(1) - if match.group(2): - court = match.group(2) - if match.group(3): - court = match.group(3) - if match.group(4): - court = match.group(4) - - if not court: - return None - return troller_ids.get(court, None) - - -class OptionsType(TypedDict): - offset: int - limit: int - file: str - - -def log_added_items_to_redis( - dockets_created: int, rds_created: int, line: int -) -> Mapping[str | bytes, int | str]: - """Log the number of dockets and recap documents created to redis. - Get the previous stored values and add the new ones. - - :param dockets_created: The dockets created. - :param rds_created: The recap documents created. - :param line: The last line imported. - :return: The data logged to redis. - """ - - r = get_redis_interface("STATS") - pipe = r.pipeline() - log_key = "troller_bk:log" - pipe.hgetall(log_key) - stored_values = pipe.execute() - current_total_dockets = int(stored_values[0].get("total_dockets", 0)) - current_total_rds = int(stored_values[0].get("total_rds", 0)) - - total_dockets_created = dockets_created + current_total_dockets - total_rds_created = rds_created + current_total_rds - log_info: Mapping[str | bytes, int | str] = { - "total_dockets": total_dockets_created, - "total_rds": total_rds_created, - "last_line": line, - "date_time": datetime.now().isoformat(), - } - pipe.hset(log_key, mapping=log_info) - pipe.expire(log_key, 60 * 60 * 24 * 28) # 4 weeks - pipe.execute() - return log_info - - -def download_file(item_path: str, order: int) -> tuple[bytes, str, int]: - """Small wrapper to download and read a file from S3. - :param item_path: The file path to download. - :param order: The original order of the file to keep in the queue. - :return: A tuple of the binary content of the file, the file path and the - file order. - """ - bucket = S3PrivateUUIDStorage() - with bucket.open(item_path, mode="rb") as f: - binary_content = f.read() - return binary_content, item_path, order - - -def download_files_from_paths( - item_paths: list[str], - files_queue: Queue, - last_thread: threading.Thread | None, -) -> None: - """Download multiple files concurrently and store them to a Queue. - :param item_paths: The list of file paths to download. - :param files_queue: The Queue where store the downloaded files. - :param last_thread: The previous thread launched. - :return: None - """ - - order = 0 - with concurrent.futures.ThreadPoolExecutor() as executor: - concurrent_downloads = [] - for item_path in item_paths: - concurrent_downloads.append( - executor.submit(download_file, item_path, order) - ) - order += 1 - - # Wait for all the downloads to complete. - completed_downloads = list( - concurrent.futures.as_completed(concurrent_downloads) - ) - # Order the downloads to preserver their original chron order. - completed_downloads.sort(key=lambda a: a.result()[2]) - # Add files to the Queue - for download in completed_downloads: - if last_thread: - # # Wait until the last thread completes, so we don't mess up - # the chronological order. - last_thread.join() - files_queue.put(download.result()) - - -def download_files_concurrently( - files_queue: Queue, - file_path: str, - files_downloaded_offset: int, - threads: list[threading.Thread], -) -> int: - """Get the next files to download and start a thread to download them. - :param files_queue: The Queue where store the downloaded files. - :param file_path: The file containing the list of paths to download. - :param files_downloaded_offset: The files that have been already downloaded - :param threads: The list of threads. - :return: The files_downloaded_offset updated - """ - - files_to_download = [] - linecache.clearcache() - linecache.checkcache(file_path) - if files_queue.qsize() < FILES_BUFFER_THRESHOLD - 1: - for j in range(FILES_BUFFER_THRESHOLD): - # Get the next paths to download. - next_line = linecache.getline( - file_path, files_downloaded_offset + 1 - ) - if next_line: - files_to_download.append(unquote(next_line).replace("\n", "")) - files_downloaded_offset += 1 - - # Download the files concurrently. - if files_to_download: - last_thread = None - if threads: - last_thread = threads[-1] - download_thread = threading.Thread( - target=download_files_from_paths, - args=(files_to_download, files_queue, last_thread), - ) - download_thread.start() - threads.append(download_thread) - - return files_downloaded_offset - - -def iterate_and_import_files( - options: OptionsType, threads: list[threading.Thread] -) -> None: - """Iterate over the inventory file and import all new items. - - - Merge into the DB - - Add to solr - - Do not send alerts or webhooks - - Do not touch dockets with entries (troller data is old) - - Do not parse (add) district/bankruptcy courts feeds after 2018-4-20 - that is the RSS feeds started being scraped by RECAP. - - :param options: The command line options - :param threads: A list of Threads. - :return: None - """ - - # Enable automatic garbage collection. - gc.enable() - f = open(options["file"], "r", encoding="utf-8") - total_dockets_created = 0 - total_rds_created = 0 - - files_queue: Queue = Queue(maxsize=FILES_BUFFER_THRESHOLD) - files_downloaded_offset = options["offset"] - for i, line in enumerate(f): - if i < options["offset"]: - continue - if i >= options["limit"] > 0: - break - - # If the files_queue has less than FILES_BUFFER_THRESHOLD files, then - # download more files ahead and store them to the queue. - files_downloaded_offset = download_files_concurrently( - files_queue, f.name, files_downloaded_offset, threads - ) - - # Process a file from the queue. - binary, item_path, order = files_queue.get() - court_id = get_court_from_line(item_path) - logger.info(f"Attempting: {item_path=} with {court_id=} \n") - if not court_id: - # Probably a court we don't know - continue - try: - feed_data, build_date = parse_file(binary, court_id) - except ParserError: - logger.info( - f"Skipping: {item_path=} with {court_id=} due to incorrect date format. \n" - ) - continue - rds_for_solr, dockets_created = async_to_sync(merge_rss_data)( - feed_data, court_id, build_date - ) - - total_dockets_created += dockets_created - total_rds_created += len(rds_for_solr) - - # Mark the file as completed and remove it from the queue. - files_queue.task_done() - - # Remove completed download threads from the list of threads. - for thread in threads: - if not thread.is_alive(): - threads.remove(thread) - logger.info(f"Last line imported: {i} \n") - - if not i % 25: - # Log every 25 lines. - log_added_items_to_redis( - total_dockets_created, total_rds_created, i - ) - # Restart counters after logging into redis. - total_dockets_created = 0 - total_rds_created = 0 - - # Ensure garbage collector is called at the end of each iteration. - gc.collect() - f.close() - - -class Command(VerboseCommand): - help = "Import the troller BK RSS files from S3 to the DB" - - def add_arguments(self, parser): - parser.add_argument( - "--offset", - type=int, - default=0, - help="The number of items to skip before beginning. Default is to " - "skip none.", - ) - parser.add_argument( - "--limit", - type=int, - default=0, - help="After doing this number, stop. This number is not additive " - "with the offset parameter. Default is to do all of them.", - ) - parser.add_argument( - "--file", - type=str, - help="Where is the text file that has the list of paths from the " - "bucket? Create this from an S3 inventory file, by removing " - "all but the path column", - ) - - def handle(self, *args, **options): - super().handle(*args, **options) - if not options["file"]: - raise argparse.ArgumentError( - "The 'file' argument is required for that action." - ) - - threads = [] - try: - iterate_and_import_files(options, threads) - except KeyboardInterrupt: - logger.info("The importer has stopped, waiting threads to exit.") - for thread in threads: - thread.join() - sys.exit(1) - - -troller_ids = { - "88AC552F-BDBA-C444-1BD52598BA252265": "nmb", - "DCCF0395-BDBA-C444-149D8D8EFA2EC03D": "almb", - "DCCF03A4-BDBA-C444-13AFEC481CF81C91": "alnb", - "DCCF03B4-BDBA-C444-180877EB555CF90A": "alsb", - "DCCF03C3-BDBA-C444-10B70B118120A4F8": "akb", - "DCCF03D3-BDBA-C444-1EA2D2D99D26D437": "azb", - "DCCF03E3-BDBA-C444-11C3D8B9C688D49E": "areb", - "DCCF03F2-BDBA-C444-14974FDC2C6DD113": "arwb", - "DCCF0412-BDBA-C444-1C60416590832545": "cacb", - "DCCF0421-BDBA-C444-12F451A14D4239AC": "caeb", - "DCCF0431-BDBA-C444-1CE9AB1898357D63": "canb", - "DCCF0440-BDBA-C444-1C8FEECE5B5AD482": "casb", - "DCCF0460-BDBA-C444-1282B46DCB6DF058": "cob", - "DCCF046F-BDBA-C444-126D999DD997D9A5": "ctb", - "DCCF047F-BDBA-C444-16EA4D3A7417C840": "deb", - "DCCF048F-BDBA-C444-12505144CA111B75": "dcb", - "DCCF049E-BDBA-C444-107C577164350B1E": "flmb", - "DCCF04BD-BDBA-C444-17B566BCA4E30864": "flnb", - "DCCF04CD-BDBA-C444-13315D191ADF5852": "flsb", - "DCCF04DD-BDBA-C444-11B09E58A8308286": "gamb", - "DCCF04EC-BDBA-C444-113648D978F0FF3B": "ganb", - "DCCF04FC-BDBA-C444-167F8376D8DF181B": "gasb", - "DCCF050C-BDBA-C444-1191B98D5C279255": "gub", - "DCCF051B-BDBA-C444-10E608B4E279AE73": "hib", - "DCCF052B-BDBA-C444-1128ADF2BE776FF5": "idb", - "DCCF053A-BDBA-C444-1E17C5EDDAAA98B3": "ilcb", - "DCCF055A-BDBA-C444-1B33BEAA267C9EF3": "ilnb", - "DCCF0569-BDBA-C444-10AAC89D6254827B": "ilsb", - "DCCF0579-BDBA-C444-13FDD2CBFCA0428E": "innb", - "DCCF0589-BDBA-C444-1403298F660F3248": "insb", - "DCCF0598-BDBA-C444-1D4AA3760C808AC6": "ianb", - "DCCF05A8-BDBA-C444-147676B19FFD9A64": "iasb", - "DCCF05B7-BDBA-C444-1159BABEABFF7AD8": "ksb", - "DCCF05C7-BDBA-C444-181132DD188F5B98": "kyeb", - "DCCF05D7-BDBA-C444-173EA852DA3C02F3": "kywb", - "DCCF05E6-BDBA-C444-1BBCF61EC04D7339": "laeb", - "DCCF05F6-BDBA-C444-1CC8B0B3A0BA9BBE": "lamb", - "DCCF0606-BDBA-C444-156EC6BFC06D300C": "lawb", - "DCCF0615-BDBA-C444-12DA3916397575D1": "meb", - "DCCF0625-BDBA-C444-16B46E54DD6D2B3F": "mdb", - "DCCF0634-BDBA-C444-172D1B61491F44EB": "mab", - "DCCF0644-BDBA-C444-16D30512F57AD7E7": "mieb", - "DCCF0654-BDBA-C444-1B26AFB780F7E57D": "miwb", - "DCCF0663-BDBA-C444-1E2D50E14B7E69B6": "mnb", - "DCCF0673-BDBA-C444-162C60670DF8F3CC": "msnb", - "DCCF0683-BDBA-C444-16D08467B7FFD39C": "mssb", - "DCCF0692-BDBA-C444-105A607741D9B25E": "moeb", - "DCCF06B1-BDBA-C444-1D0081621397B587": "mowb", - "DCCF06C1-BDBA-C444-116BC0B37A3105FA": "mtb", - "DCCF06D1-BDBA-C444-16605BEF7E402AFF": "neb", - "DCCF06E0-BDBA-C444-142566FBDE706DF9": "nvb", - "DCCF06F0-BDBA-C444-15CEC5BC7E8811B0": "nhb", - "DCCF0700-BDBA-C444-1833C704F349B4C5": "njb", - "DCCF071F-BDBA-C444-12E80A7584DAB242": "nyeb", - "DCCF072E-BDBA-C444-161CCB961DC28EAA": "nynb", - "DCCF073E-BDBA-C444-195A319E0477A40F": "nysb", - "DCCF075D-BDBA-C444-1A4574BEA4332780": "nywb", - "DCCF076D-BDBA-C444-1D86BA6110EAC8EB": "nceb", - "DCCF077D-BDBA-C444-19E00357E47293C6": "ncmb", - "DCCF078C-BDBA-C444-13A763C27712238D": "ncwb", - "DCCF079C-BDBA-C444-152775C142804DBF": "ndb", - "DCCF07AB-BDBA-C444-1909DD6A1D03789A": "ohnb", - "DCCF07BB-BDBA-C444-15CC4C79DA8F0883": "ohsb", - "DCCF07CB-BDBA-C444-16A03EA3C59A0E65": "okeb", - "DCCF07DA-BDBA-C444-19C1613A6E47E8CC": "oknb", - "DCCF07EA-BDBA-C444-11A55B458254CDA2": "okwb", - "DCCF07FA-BDBA-C444-1931F6C553EEC927": "orb", - "DCCF0819-BDBA-C444-121A57E62D0F901B": "paeb", - "DCCF0838-BDBA-C444-11578199813DA094": "pamb", - "DCCF0848-BDBA-C444-1FDC44C3E5C7F028": "pawb", - "DCCF0857-BDBA-C444-1249D33530373C4A": "prb", - "DCCF0867-BDBA-C444-11F248F5A172BED7": "rib", - "DCCF0877-BDBA-C444-140D6F0E2517D28A": "scb", - "DCCF0886-BDBA-C444-1FA114144D695156": "sdb", - "DCCF0896-BDBA-C444-19AE23DDBC293010": "tneb", - "DCCF08A5-BDBA-C444-16F88B92DFEFF2D7": "tnmb", - "DCCF08B5-BDBA-C444-1015B0D4FD4EA2BB": "tnwb", - "DCCF08D4-BDBA-C444-17A1F7F9130C2B5A": "txeb", - "DCCF08E4-BDBA-C444-1FF320EDE23FE1C4": "txnb", - "DCCF08F4-BDBA-C444-137D9095312F2A26": "txsb", - "DCCF0903-BDBA-C444-1F1B7B299E8BEDEC": "txwb", - "DCCF0913-BDBA-C444-1426E01E34A098A8": "utb", - "DCCF0922-BDBA-C444-1E7C4839C9DDE0DD": "vtb", - "DCCF0932-BDBA-C444-1E3B6019198C4AF3": "vib", - "DCCF0942-BDBA-C444-15DE36A8BF619EE3": "vaeb", - "DCCF0951-BDBA-C444-156287CAA9B5EA92": "vawb", - "DCCF0961-BDBA-C444-113035CFC50A69B8": "waeb", - "DCCF0971-BDBA-C444-1AE1249D4E72B62E": "wawb", - "DCCF0980-BDBA-C444-12EE39B96F6E2CAD": "wvnb", - "DCCF0990-BDBA-C444-16831E0CC62633BB": "wvsb", - "DCCF099F-BDBA-C444-163A7EEE0EB991F6": "wieb", - "DCCF09BF-BDBA-C444-1D3842A8131499EF": "wiwb", - "DCCF09CE-BDBA-C444-1B4915E476D3A9D2": "wyb", - "Mariana": "nmib", - "640": "almd", - "645": "alsd", - "648": "akd", - "651": "azd", - "653": "ared", - "656": "arwd", - "659": "cacd", - "662": "caed", - "664": "cand", - "667": "casd", - "670": "cod", - "672": "ctd", - "675": "ded", - "678": "dcd", - "681": "flmd", - "686": "flsd", - "689": "gamd", - "696": "gud", - "699": "hid", - "701": "idd", - "704": "ilcd", - "707": "ilnd", - "712": "innd", - "715": "insd", - "717": "iand", - "720": "iasd", - "723": "ksd", - "728": "kywd", - "731": "laed", - "734": "lamd", - "737": "lawd", - "740": "med", - "744": "mad", - "747": "mied", - "750": "miwd", - "757": "mssd", - "759": "moed", - "762": "mowd", - "765": "mtd", - "768": "ned", - "771": "nvd", - "773": "nhd", - "776": "njd", - "779": "nmd", - "781": "nyed", - "784": "nynd", - "787": "nysd", - "792": "nced", - "795": "ncmd", - "798": "ncwd", - "803": "nmid", - "806": "ohnd", - "811": "ohsd", - "818": "okwd", - "821": "ord", - "823": "paed", - "826": "pamd", - "829": "pawd", - "832": "prd", - "835": "rid", - "840": "sdd", - "843": "tned", - "846": "tnmd", - "849": "tnwd", - "851": "txed", - "854": "txnd", - "856": "txsd", - "859": "txwd", - "862": "utd", - "865": "vtd", - "868": "vid", - "873": "vawd", - "876": "waed", - "879": "wawd", - "882": "wvnd", - "885": "wvsd", - "888": "wied", - "891": "wiwd", - "894": "wyd", - # Appellate - "609": "ca6", - "619": "ca10", - "625": "cadc", - "628": "cafc", - # I don't think we currently crawl these. Worth checking. - "633": "uscfc", - "636": "cit", -} diff --git a/cl/corpus_importer/tasks.py b/cl/corpus_importer/tasks.py index a00a5e4448..cf242656fa 100644 --- a/cl/corpus_importer/tasks.py +++ b/cl/corpus_importer/tasks.py @@ -1614,7 +1614,7 @@ def get_docket_by_pacer_case_id( :param tag_names: A list of tag names that should be stored with the item in the DB. :param kwargs: A variety of keyword args to pass to DocketReport.query(). - :return: A dict indicating if we need to update Solr. + :return: A dict indicating if we need to update the search engine. """ if data is None: logger.info("Empty data argument. Terminating chains and exiting.") diff --git a/cl/corpus_importer/tests.py b/cl/corpus_importer/tests.py index c26207e871..b631fc3829 100644 --- a/cl/corpus_importer/tests.py +++ b/cl/corpus_importer/tests.py @@ -27,8 +27,6 @@ CaseLawCourtFactory, CaseLawFactory, CitationFactory, - RssDocketDataFactory, - RssDocketEntryDataFactory, ) from cl.corpus_importer.import_columbia.columbia_utils import fix_xml_tags from cl.corpus_importer.import_columbia.parse_opinions import ( @@ -57,11 +55,6 @@ normalize_authors_in_opinions, normalize_panel_in_opinioncluster, ) -from cl.corpus_importer.management.commands.troller_bk import ( - download_files_concurrently, - log_added_items_to_redis, - merge_rss_data, -) from cl.corpus_importer.management.commands.update_casenames_wl_dataset import ( check_case_names_match, parse_citations, @@ -90,7 +83,6 @@ ) from cl.lib.pacer import process_docket_data from cl.lib.redis_utils import get_redis_interface -from cl.lib.timezone_helpers import localize_date_and_time from cl.people_db.factories import PersonWithChildrenFactory, PositionFactory from cl.people_db.lookup_utils import ( extract_judge_last_name, @@ -99,22 +91,18 @@ ) from cl.people_db.models import Attorney, AttorneyOrganization, Party from cl.recap.models import UPLOAD_TYPE, PacerHtmlFiles -from cl.recap_rss.models import RssItemCache from cl.scrapers.models import PACERFreeDocumentRow from cl.search.factories import ( CourtFactory, - DocketEntryWithParentsFactory, DocketFactory, OpinionClusterFactory, OpinionClusterFactoryMultipleOpinions, OpinionClusterFactoryWithChildrenAndParents, OpinionClusterWithParentsFactory, OpinionWithChildrenFactory, - RECAPDocumentFactory, ) from cl.search.models import ( SOURCES, - BankruptcyInformation, Citation, Court, Docket, @@ -1120,1281 +1108,6 @@ def test_normalize_panel_str(self): self.assertEqual(len(cluster.panel.all()), 2) -def mock_download_file(item_path, order): - time.sleep(randint(1, 10) / 100) - return b"", item_path, order - - -class TrollerBKTests(TestCase): - @classmethod - def setUpTestData(cls) -> None: - # District factories - cls.court = CourtFactory(id="canb", jurisdiction="FB") - cls.court_neb = CourtFactory(id="nebraskab", jurisdiction="FD") - cls.court_pamd = CourtFactory(id="pamd", jurisdiction="FD") - cls.docket_d_before_2018 = DocketFactory( - case_name="Young v. State", - docket_number="3:17-CV-01477", - court=cls.court, - source=Docket.HARVARD, - pacer_case_id="1234", - ) - - cls.docket_d_after_2018 = DocketFactory( - case_name="Dragon v. State", - docket_number="3:15-CV-01455", - court=cls.court, - source=Docket.HARVARD, - pacer_case_id="5431", - ) - - cls.de_d_before_2018 = DocketEntryWithParentsFactory( - docket__court=cls.court, - docket__case_name="Young Entry v. Dragon", - docket__docket_number="3:87-CV-01400", - docket__source=Docket.HARVARD, - docket__pacer_case_id="9038", - entry_number=1, - date_filed=make_aware( - datetime(year=2018, month=1, day=4), timezone.utc - ), - ) - - # Appellate factories - cls.court_appellate = CourtFactory(id="ca1", jurisdiction="F") - cls.docket_a_before_2018 = DocketFactory( - case_name="Young v. State", - docket_number="12-2532", - court=cls.court_appellate, - source=Docket.HARVARD, - pacer_case_id=None, - ) - cls.docket_a_after_2018 = DocketFactory( - case_name="Dragon v. State", - docket_number="15-1232", - court=cls.court_appellate, - source=Docket.HARVARD, - pacer_case_id=None, - ) - cls.de_a_before_2018 = DocketEntryWithParentsFactory( - docket__court=cls.court_appellate, - docket__case_name="Young Entry v. Dragon", - docket__docket_number="12-3242", - docket__source=Docket.HARVARD, - docket__pacer_case_id=None, - entry_number=1, - date_filed=make_aware( - datetime(year=2018, month=1, day=4), timezone.utc - ), - ) - cls.docket_a_2018_case_id = DocketFactory( - case_name="Young v. State", - docket_number="12-5674", - court=cls.court_appellate, - source=Docket.RECAP, - pacer_case_id="12524", - ) - - @classmethod - def restart_troller_log(cls): - r = get_redis_interface("STATS") - key = r.keys("troller_bk:log") - if key: - r.delete(*key) - - def setUp(self) -> None: - self.restart_troller_log() - - def test_merge_district_rss_before_2018(self): - """1 Test merge district RSS file before 2018-4-20 into an existing - docket - - Before 2018-4-20 - District - Docket exists - No docket entries - - Merge docket entries, avoid updating metadata. - """ - d_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court.pk, - case_name="Young v. Dragon", - docket_number="3:17-CV-01473", - pacer_case_id="1234", - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2017, month=1, day=4), timezone.utc - ) - ) - ], - ) - - build_date = d_rss_data_before_2018["docket_entries"][0]["date_filed"] - self.assertEqual( - len(self.docket_d_before_2018.docket_entries.all()), 0 - ) - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_before_2018], self.court.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 0) - self.docket_d_before_2018.refresh_from_db() - self.assertEqual(self.docket_d_before_2018.case_name, "Young v. State") - self.assertEqual( - self.docket_d_before_2018.docket_number, "3:17-CV-01477" - ) - self.assertEqual( - len(self.docket_d_before_2018.docket_entries.all()), 1 - ) - self.assertEqual( - self.docket_d_before_2018.source, Docket.HARVARD_AND_RECAP - ) - - def test_avoid_merging_district_rss_after_2018(self): - """2 Test avoid merging district RSS file after 2018-4-20 - - After 2018-4-20 - District - Docket exists - No docket entries - - Don't merge docket entries, avoid updating metadata. - """ - d_rss_data_after_2018 = RssDocketDataFactory( - court_id=self.court.pk, - case_name="Dragon 1 v. State", - docket_number="3:15-CV-01456", - pacer_case_id="5431", - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2018, month=4, day=21), timezone.utc - ) - ) - ], - ) - - build_date = d_rss_data_after_2018["docket_entries"][0]["date_filed"] - self.assertEqual(len(self.docket_d_after_2018.docket_entries.all()), 0) - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_after_2018], self.court.pk, build_date - ) - self.assertEqual(len(rds_created), 0) - self.assertEqual(d_created, 0) - self.docket_d_after_2018.refresh_from_db() - self.assertEqual(self.docket_d_after_2018.case_name, "Dragon v. State") - self.assertEqual( - self.docket_d_after_2018.docket_number, "3:15-CV-01455" - ) - self.assertEqual(len(self.docket_d_after_2018.docket_entries.all()), 0) - self.assertEqual(self.docket_d_after_2018.source, Docket.HARVARD) - - def test_merge_district_courts_rss_exceptions_after_2018(self): - """Test merging district RSS exceptions after 2018-4-20 - - After 2018-4-20 - District ["miwb", "nceb", "pamd", "cit"] - Docket doesn't exists - No docket entries - - Create docket, merge docket entries. - """ - d_rss_data_after_2018 = RssDocketDataFactory( - court_id=self.court_pamd.pk, - case_name="Dragon 1 v. State", - docket_number="3:15-CV-01456", - pacer_case_id="54312", - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2018, month=4, day=21), timezone.utc - ) - ) - ], - ) - - build_date = d_rss_data_after_2018["docket_entries"][0]["date_filed"] - self.assertEqual(len(self.docket_d_after_2018.docket_entries.all()), 0) - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_after_2018], self.court_pamd.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 1) - - docket = Docket.objects.get(pacer_case_id="54312") - self.assertEqual(docket.case_name, "Dragon 1 v. State") - self.assertEqual(docket.docket_number, "3:15-CV-01456") - - def test_merging_district_docket_with_entries_before_2018(self): - """3 Test merge district RSS file before 2018-4-20 into a - docket with entries. - - Before 2018-4-20 - District - Docket exists - Docket entries - - Only merge entry if it doesn't exist, avoid updating metadata. - """ - d_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court.pk, - case_name="Young v. Dragon", - docket_number="3:17-CV-01473", - pacer_case_id="9038", - docket_entries=[ - RssDocketEntryDataFactory( - document_number="2", - date_filed=make_aware( - datetime(year=2017, month=1, day=4), timezone.utc - ), - ) - ], - ) - - build_date = d_rss_data_before_2018["docket_entries"][0]["date_filed"] - self.assertEqual( - len(self.de_d_before_2018.docket.docket_entries.all()), 1 - ) - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_before_2018], self.court.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 0) - self.de_d_before_2018.refresh_from_db() - self.assertEqual( - self.de_d_before_2018.docket.case_name, "Young Entry v. Dragon" - ) - self.assertEqual( - self.de_d_before_2018.docket.docket_number, "3:87-CV-01400" - ) - self.assertEqual( - len(self.de_d_before_2018.docket.docket_entries.all()), 2 - ) - self.assertEqual( - self.de_d_before_2018.docket.source, Docket.HARVARD_AND_RECAP - ) - - def test_avoid_merging_updating_docket_item_without_docket_entries( - self, - ): - """Test avoid merging or updating the docket when the RSS item doesn't - contain entries. - - Docket exists - Docket entries - - Avoid updating metadata. - """ - d_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court.pk, - case_name="Young v. Dragon", - docket_number="3:17-CV-01473", - pacer_case_id="9038", - docket_entries=[], - ) - - build_date = make_aware( - datetime(year=2017, month=1, day=4), timezone.utc - ) - self.assertEqual( - len(self.de_d_before_2018.docket.docket_entries.all()), 1 - ) - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_before_2018], self.court.pk, build_date - ) - self.assertEqual(len(rds_created), 0) - self.assertEqual(d_created, 0) - self.assertEqual(self.de_d_before_2018.docket.source, Docket.HARVARD) - - def test_add_new_district_rss_before_2018(self): - """4 Test adds a district RSS file before 2018-4-20, new docket. - - Before: 2018-4-20 - District - Docket doesn't exist - No docket entries - - Create docket, merge docket entries. - """ - d_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court.pk, - case_name="Youngs v. Dragon", - docket_number="3:20-CV-01473", - pacer_case_id="43562", - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2017, month=1, day=4), timezone.utc - ) - ) - ], - ) - - build_date = d_rss_data_before_2018["docket_entries"][0]["date_filed"] - dockets = Docket.objects.filter(pacer_case_id="43562") - self.assertEqual(dockets.count(), 0) - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_before_2018], self.court.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 1) - self.assertEqual(dockets[0].case_name, "Youngs v. Dragon") - self.assertEqual(dockets[0].docket_number, "3:20-CV-01473") - self.assertEqual(len(dockets[0].docket_entries.all()), 1) - self.assertEqual(dockets[0].source, Docket.RECAP) - - def test_avoid_merging_rss_docket_with_entries_district_after_2018(self): - """5 Test avoid merging district RSS file after 2018-4-20 into a - docket with entries. - - After 2018-4-20 - District - Docket exists - Docket entries - - Don't merge docket entries, avoid updating metadata. - """ - d_rss_data_after_2018 = RssDocketDataFactory( - court_id=self.court.pk, - case_name="Young v. Dragons 2", - docket_number="3:57-CV-01453", - pacer_case_id="9038", - docket_entries=[ - RssDocketEntryDataFactory( - document_number="2", - date_filed=make_aware( - datetime(year=2019, month=1, day=4), timezone.utc - ), - ) - ], - ) - - build_date = d_rss_data_after_2018["docket_entries"][0]["date_filed"] - self.assertEqual( - len(self.de_d_before_2018.docket.docket_entries.all()), 1 - ) - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_after_2018], self.court.pk, build_date - ) - self.assertEqual(len(rds_created), 0) - self.assertEqual(d_created, 0) - self.de_d_before_2018.refresh_from_db() - self.assertEqual( - self.de_d_before_2018.docket.case_name, "Young Entry v. Dragon" - ) - self.assertEqual( - self.de_d_before_2018.docket.docket_number, "3:87-CV-01400" - ) - self.assertEqual( - len(self.de_d_before_2018.docket.docket_entries.all()), 1 - ) - self.assertEqual(self.de_d_before_2018.docket.source, Docket.HARVARD) - - def test_avoid_adding_new_district_rss_after_2018(self): - """6 Test avoid adding district RSS file after 2018-4-20. - - After 2018-4-20 - District - Docket doesn't exist - No docket entries - - Do not create docket, do not merge docket entries. - """ - d_rss_data_after_2018 = RssDocketDataFactory( - court_id=self.court.pk, - case_name="Youngs v. Dragon", - docket_number="3:20-CV-01473", - pacer_case_id="53432", - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2019, month=1, day=4), timezone.utc - ) - ) - ], - ) - - build_date = d_rss_data_after_2018["docket_entries"][0]["date_filed"] - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_after_2018], self.court.pk, build_date - ) - self.assertEqual(len(rds_created), 0) - self.assertEqual(d_created, 0) - - # Appellate - def test_merge_appellate_rss_before_2018(self): - """7 Test merge an appellate RSS file before 2018-4-20 - - Before 2018-4-20 - Appellate - Docket exists - No docket entries - - Merge docket entries, avoid updating metadata. - """ - a_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - case_name="Young v. Dragon", - docket_number="12-2532", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2017, month=1, day=4), timezone.utc - ) - ) - ], - ) - - build_date = a_rss_data_before_2018["docket_entries"][0]["date_filed"] - self.assertEqual( - len(self.docket_a_before_2018.docket_entries.all()), 0 - ) - rds_created, d_created = async_to_sync(merge_rss_data)( - [a_rss_data_before_2018], self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 0) - self.docket_a_before_2018.refresh_from_db() - self.assertEqual(self.docket_a_before_2018.case_name, "Young v. State") - self.assertEqual(self.docket_a_before_2018.docket_number, "12-2532") - self.assertEqual( - len(self.docket_a_before_2018.docket_entries.all()), 1 - ) - self.assertEqual( - self.docket_a_before_2018.source, Docket.HARVARD_AND_RECAP - ) - - def test_merging_appellate_rss_after_2018(self): - """8 Test appellate RSS file after 2018-4-20 - - After 2018-4-20 - Appellate - Docket exists - No docket entries - - Merge docket entries, avoid updating metadata. - """ - a_rss_data_after_2018 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - case_name="Dragon 1 v. State", - docket_number="15-1232", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2018, month=4, day=21), timezone.utc - ) - ) - ], - ) - - build_date = a_rss_data_after_2018["docket_entries"][0]["date_filed"] - self.assertEqual(len(self.docket_a_after_2018.docket_entries.all()), 0) - rds_created, d_created = async_to_sync(merge_rss_data)( - [a_rss_data_after_2018], self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 0) - self.docket_a_after_2018.refresh_from_db() - self.assertEqual(self.docket_a_after_2018.case_name, "Dragon v. State") - self.assertEqual(self.docket_a_after_2018.docket_number, "15-1232") - self.assertEqual(len(self.docket_a_after_2018.docket_entries.all()), 1) - self.assertEqual( - self.docket_a_after_2018.source, Docket.HARVARD_AND_RECAP - ) - - def test_avoid_merging_existing_appellate_entry_before_2018(self): - """9 Test avoid merging appellate RSS file before 2018-4-20, docket - with entries. - - Before 2018-4-20 - Appellate - Docket exists - Docket entries - - Don't merge docket entries, avoid updating metadata. - """ - a_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - case_name="Young v. Dragon", - docket_number="12-3242", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number="2", - date_filed=make_aware( - datetime(year=2017, month=1, day=4), timezone.utc - ), - ) - ], - ) - - build_date = a_rss_data_before_2018["docket_entries"][0]["date_filed"] - self.assertEqual( - len(self.de_a_before_2018.docket.docket_entries.all()), 1 - ) - rds_created, d_created = async_to_sync(merge_rss_data)( - [a_rss_data_before_2018], self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 0) - self.de_a_before_2018.refresh_from_db() - self.assertEqual( - self.de_a_before_2018.docket.case_name, "Young Entry v. Dragon" - ) - self.assertEqual(self.de_a_before_2018.docket.docket_number, "12-3242") - self.assertEqual( - len(self.de_a_before_2018.docket.docket_entries.all()), 2 - ) - self.assertEqual( - self.de_a_before_2018.docket.source, Docket.HARVARD_AND_RECAP - ) - - def test_merge_new_appellate_rss_before_2018(self): - """10 Merge a new appellate RSS file before 2018-4-20 - - Before: 2018-4-20 - Appellate - Docket doesn't exist - No docket entries - - Create docket, merge docket entries. - """ - a_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - case_name="Youngs v. Dragon", - docket_number="23-4233", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2017, month=1, day=4), timezone.utc - ) - ) - ], - ) - - build_date = a_rss_data_before_2018["docket_entries"][0]["date_filed"] - dockets = Docket.objects.filter(docket_number="23-4233") - self.assertEqual(dockets.count(), 0) - rds_created, d_created = async_to_sync(merge_rss_data)( - [a_rss_data_before_2018], self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 1) - self.assertEqual(dockets[0].case_name, "Youngs v. Dragon") - self.assertEqual(dockets[0].docket_number, "23-4233") - self.assertEqual(len(dockets[0].docket_entries.all()), 1) - self.assertEqual(dockets[0].source, Docket.RECAP) - - def test_avoid_merging_existing_appellate_entry_after_2018(self): - """11 Test avoid merging appellate RSS file after 2018-4-20, docket with - entries. - - After: 2018-4-20 - Appellate - Docket exists - Docket entry exist - - Don't merge the existing entry, avoid updating metadata. - """ - a_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - case_name="Young v. Dragon", - docket_number="12-3242", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number="1", - date_filed=make_aware( - datetime(year=2019, month=1, day=4), timezone.utc - ), - ) - ], - ) - - build_date = a_rss_data_before_2018["docket_entries"][0]["date_filed"] - self.assertEqual( - len(self.de_a_before_2018.docket.docket_entries.all()), 1 - ) - rds_created, d_created = async_to_sync(merge_rss_data)( - [a_rss_data_before_2018], self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 0) - self.assertEqual(d_created, 0) - - def test_merging_appellate_docket_with_entries_after_2018(self): - """Test merge appellate RSS file after 2018-4-20, docket with - entries. - - After: 2018-4-20 - Appellate - Docket exists - Docket entries - - Only merge entry if it doesn't exist, avoid updating metadata. - """ - a_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - case_name="Young v. Dragon", - docket_number="12-3242", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number="2", - date_filed=make_aware( - datetime(year=2019, month=1, day=4), timezone.utc - ), - ) - ], - ) - - build_date = a_rss_data_before_2018["docket_entries"][0]["date_filed"] - self.assertEqual( - len(self.de_a_before_2018.docket.docket_entries.all()), 1 - ) - rds_created, d_created = async_to_sync(merge_rss_data)( - [a_rss_data_before_2018], self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 0) - self.de_a_before_2018.refresh_from_db() - self.assertEqual( - self.de_a_before_2018.docket.case_name, "Young Entry v. Dragon" - ) - self.assertEqual(self.de_a_before_2018.docket.docket_number, "12-3242") - self.assertEqual( - len(self.de_a_before_2018.docket.docket_entries.all()), 2 - ) - self.assertEqual( - self.de_a_before_2018.docket.source, Docket.HARVARD_AND_RECAP - ) - - def test_merge_new_appellate_rss_after_2018(self): - """12 Merge a new appellate RSS file after 2018-4-20 - - After: 2018-4-20 - Appellate - Docket doesn't exist - No docket entries - - Create docket, merge docket entries, . - """ - - d_rss_data_after_2018 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - case_name="Youngs v. Dragon", - docket_number="45-3232", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2019, month=1, day=4), timezone.utc - ) - ) - ], - ) - - build_date = d_rss_data_after_2018["docket_entries"][0]["date_filed"] - dockets = Docket.objects.filter(docket_number="45-3232") - self.assertEqual(dockets.count(), 0) - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_after_2018], self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 1) - self.assertEqual(dockets.count(), 1) - self.assertEqual(dockets[0].case_name, "Youngs v. Dragon") - self.assertEqual(dockets[0].docket_number, "45-3232") - self.assertEqual(len(dockets[0].docket_entries.all()), 1) - self.assertEqual(dockets[0].source, Docket.RECAP) - - def test_merging_appellate_docket_with_entries_case_id(self): - """Test merge an appellate RSS file into a docket with pacer_case_id - Find docket by docket_number_core, avoid duplicating. - Merge docket entries, avoid updating metadata. - """ - a_rss_data_before_2018 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - case_name="Young v. Dragon", - docket_number="12-5674", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number="2", - date_filed=make_aware( - datetime(year=2019, month=1, day=4), timezone.utc - ), - ) - ], - ) - - build_date = a_rss_data_before_2018["docket_entries"][0]["date_filed"] - self.assertEqual( - len(self.docket_a_2018_case_id.docket_entries.all()), 0 - ) - rds_created, d_created = async_to_sync(merge_rss_data)( - [a_rss_data_before_2018], self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 0) - self.docket_a_2018_case_id.refresh_from_db() - self.assertEqual( - self.docket_a_2018_case_id.case_name, "Young v. State" - ) - self.assertEqual(self.docket_a_2018_case_id.docket_number, "12-5674") - self.assertEqual(self.docket_a_2018_case_id.pacer_case_id, "12524") - self.assertEqual( - len(self.docket_a_2018_case_id.docket_entries.all()), 1 - ) - self.assertEqual(self.docket_a_2018_case_id.source, Docket.RECAP) - - def test_log_added_items_to_redis(self): - """Can we log dockets and rds added to redis, adding the previous - value? - """ - last_values = log_added_items_to_redis(100, 100, 50) - self.assertEqual(last_values["total_dockets"], 100) - self.assertEqual(last_values["total_rds"], 100) - self.assertEqual(last_values["last_line"], 50) - - last_values = log_added_items_to_redis(50, 80, 100) - self.assertEqual(last_values["total_dockets"], 150) - self.assertEqual(last_values["total_rds"], 180) - self.assertEqual(last_values["last_line"], 100) - - self.restart_troller_log() - - def test_merge_mapped_court_rss_before_2018(self): - """Merge a court mapped RSS file before 2018-4-20 - - before: 2018-4-20 - District neb -> nebraskab - Docket doesn't exist - No docket entries - - Create docket, merge docket entries, verify is assigned to nebraskab. - """ - - d_rss_data_before_2018 = RssDocketDataFactory( - court_id="neb", - case_name="Youngs v. Dragon", - docket_number="3:20-CV-01473", - pacer_case_id="43565", - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2017, month=1, day=4), timezone.utc - ) - ) - ], - ) - - build_date = d_rss_data_before_2018["docket_entries"][0]["date_filed"] - dockets = Docket.objects.filter(docket_number="3:20-CV-01473") - self.assertEqual(dockets.count(), 0) - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_before_2018], "neb", build_date - ) - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 1) - self.assertEqual(dockets.count(), 1) - self.assertEqual(dockets[0].case_name, "Youngs v. Dragon") - self.assertEqual(dockets[0].docket_number, "3:20-CV-01473") - self.assertEqual(len(dockets[0].docket_entries.all()), 1) - self.assertEqual(dockets[0].source, Docket.RECAP) - self.assertEqual(dockets[0].court.pk, "nebraskab") - - def test_avoid_merging_district_mapped_court_rss_after_2018(self): - """Avoid merging a new district RSS file with mapped court - after 2018-4-20. - - After: 2018-4-20 - District neb -> nebraskab - Docket doesn't exist - No docket entries - - Don't merge. - """ - - d_rss_data_after_2018 = RssDocketDataFactory( - court_id="neb", - case_name="Youngs v. Dragon", - docket_number="3:20-CV-01473", - pacer_case_id="43565", - docket_entries=[ - RssDocketEntryDataFactory( - date_filed=make_aware( - datetime(year=2019, month=1, day=4), timezone.utc - ) - ) - ], - ) - build_date = d_rss_data_after_2018["docket_entries"][0]["date_filed"] - rds_created, d_created = async_to_sync(merge_rss_data)( - [d_rss_data_after_2018], "neb", build_date - ) - self.assertEqual(len(rds_created), 0) - self.assertEqual(d_created, 0) - - def test_avoid_updating_docket_entry_metadata(self): - """Test merge appellate RSS file after 2018-4-20, docket with - entries. - - After: 2018-4-20 - Appellate - Docket exists - Docket entries - - Only merge entry if it doesn't exist, avoid updating metadata. - """ - - de_a_unnumbered = DocketEntryWithParentsFactory( - docket__court=self.court_appellate, - docket__case_name="Young Entry v. Dragon", - docket__docket_number="12-3245", - docket__source=Docket.HARVARD, - docket__pacer_case_id=None, - entry_number=None, - description="Original docket entry description", - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ) - RECAPDocumentFactory( - docket_entry=de_a_unnumbered, description="Opinion Issued" - ) - - a_rss_data_unnumbered = RssDocketDataFactory( - court_id=self.court_appellate.pk, - case_name="Young v. Dragon", - docket_number="12-3245", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number=None, - description="New docket entry description", - short_description="Opinion Issued", - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ) - ], - ) - build_date = a_rss_data_unnumbered["docket_entries"][0]["date_filed"] - self.assertEqual(len(de_a_unnumbered.docket.docket_entries.all()), 1) - rds_created, d_created = async_to_sync(merge_rss_data)( - [a_rss_data_unnumbered], self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 0) - self.assertEqual(d_created, 0) - de_a_unnumbered.refresh_from_db() - self.assertEqual( - de_a_unnumbered.docket.case_name, "Young Entry v. Dragon" - ) - self.assertEqual(de_a_unnumbered.docket.docket_number, "12-3245") - self.assertEqual( - de_a_unnumbered.description, "Original docket entry description" - ) - self.assertEqual(len(de_a_unnumbered.docket.docket_entries.all()), 1) - self.assertEqual( - de_a_unnumbered.date_filed, - datetime(year=2018, month=1, day=4).date(), - ) - self.assertEqual(de_a_unnumbered.docket.source, Docket.HARVARD) - - @patch("cl.corpus_importer.management.commands.troller_bk.logger") - def test_avoid_cached_items(self, mock_logger): - """Can we skip a whole file when a cached item is hit?""" - - a_rss_data_0 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - docket_number="12-3247", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number=1, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ), - ], - ) - - a_rss_data_1 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - docket_number="12-3245", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number=1, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ) - ], - ) - a_rss_data_2 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - docket_number="12-3246", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number=1, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ) - ], - ) - - list_rss_data_1 = [a_rss_data_1, a_rss_data_2] - list_rss_data_2 = [a_rss_data_0, a_rss_data_1] - - cached_items = RssItemCache.objects.all() - self.assertEqual(cached_items.count(), 0) - build_date = a_rss_data_0["docket_entries"][0]["date_filed"] - rds_created, d_created = async_to_sync(merge_rss_data)( - list_rss_data_1, self.court_appellate.pk, build_date - ) - self.assertEqual(len(rds_created), 2) - self.assertEqual(d_created, 2) - self.assertEqual(cached_items.count(), 2) - - # Remove recap_sequence_number from the dict to simulate the same item - del a_rss_data_1["docket_entries"][0]["recap_sequence_number"] - rds_created, d_created = async_to_sync(merge_rss_data)( - list_rss_data_2, self.court_appellate.pk, build_date - ) - - # The file is aborted when a cached item is hit - self.assertEqual(len(rds_created), 1) - self.assertEqual(d_created, 1) - self.assertEqual(cached_items.count(), 3) - mock_logger.info.assert_called_with( - f"Finished adding {self.court_appellate.pk} feed. Added {len(rds_created)} RDs." - ) - - @patch( - "cl.corpus_importer.management.commands.troller_bk.download_file", - side_effect=mock_download_file, - ) - def test_download_files_concurrently(self, mock_download): - """Test the download_files_concurrently method to verify proper - fetching of the next paths to download from a file. Concurrently - download these paths and add them to a queue in the original chronological order. - """ - test_dir = ( - Path(settings.INSTALL_ROOT) - / "cl" - / "corpus_importer" - / "test_assets" - ) - import_filename = "import.csv" - import_path = os.path.join(test_dir, import_filename) - - files_queue = Queue() - threads = [] - files_downloaded_offset = 0 - - with open(import_path, "rb") as f: - files_downloaded_offset = download_files_concurrently( - files_queue, f.name, files_downloaded_offset, threads - ) - self.assertEqual(len(threads), 1) - self.assertEqual(files_downloaded_offset, 3) - files_downloaded_offset = download_files_concurrently( - files_queue, f.name, files_downloaded_offset, threads - ) - - for thread in threads: - thread.join() - - self.assertEqual(len(threads), 2) - self.assertEqual(files_downloaded_offset, 6) - self.assertEqual(files_queue.qsize(), 6) - - # Verifies original chronological order. - binary, item_path, order = files_queue.get() - self.assertEqual(order, 0) - self.assertEqual(item_path.split("|")[1], "1575330086") - files_queue.task_done() - - binary, item_path, order = files_queue.get() - self.assertEqual(order, 1) - self.assertEqual(item_path.split("|")[1], "1575333374") - files_queue.task_done() - - binary, item_path, order = files_queue.get() - self.assertEqual(order, 2) - self.assertEqual(item_path.split("|")[1], "1575336978") - files_queue.task_done() - - binary, item_path, order = files_queue.get() - self.assertEqual(order, 0) - self.assertEqual(item_path.split("|")[1], "1575340576") - files_queue.task_done() - - binary, item_path, order = files_queue.get() - self.assertEqual(order, 1) - self.assertEqual(item_path.split("|")[1], "1575344176") - files_queue.task_done() - - binary, item_path, order = files_queue.get() - self.assertEqual(order, 2) - self.assertEqual(item_path.split("|")[1], "1575380176") - files_queue.task_done() - - self.assertEqual(files_queue.qsize(), 0) - - def test_add_objects_in_bulk(self): - """Can we properly add related RSS feed objects in bulk?""" - - a_rss_data_0 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - docket_number="15-3247", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number=1, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ), - ], - ) - - a_rss_data_1 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - docket_number="15-3245", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number=1, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ) - ], - ) - a_rss_data_2 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - docket_number="15-3247", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number=2, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ) - ], - ) - - a_rss_data_3 = RssDocketDataFactory( - court_id=self.court_appellate.pk, - docket_number="12-2532", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number=5, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ) - ], - ) - - list_rss_data = [ - a_rss_data_0, - a_rss_data_1, - a_rss_data_2, - a_rss_data_3, - ] - cached_items = RssItemCache.objects.all() - self.assertEqual(cached_items.count(), 0) - - build_date = a_rss_data_0["docket_entries"][0]["date_filed"] - rds_created, d_created = async_to_sync(merge_rss_data)( - list_rss_data, self.court_appellate.pk, build_date - ) - - date_filed, time_filed = localize_date_and_time( - self.court_appellate.pk, build_date - ) - - # Only two dockets created: 15-3247 and 15-3245, 12-2532 already exists - self.assertEqual(d_created, 2) - self.assertEqual(len(rds_created), 4) - - # Compare docket entries and rds created for each docket. - des_to_compare = [("15-3245", 1), ("15-3247", 2), ("12-2532", 1)] - for d_number, de_count in des_to_compare: - docket = Docket.objects.get(docket_number=d_number) - self.assertEqual(len(docket.docket_entries.all()), de_count) - - # For every docket entry there is one recap document created. - docket_entries = docket.docket_entries.all() - for de in docket_entries: - self.assertEqual(len(de.recap_documents.all()), 1) - self.assertEqual(de.time_filed, time_filed) - self.assertEqual(de.date_filed, date_filed) - self.assertNotEqual(de.recap_sequence_number, "") - - # docket_number_core generated for every docket - self.assertNotEqual(docket.docket_number_core, "") - # Slug is generated for every docket - self.assertNotEqual(docket.slug, "") - - # Verify RECAP source is added to existing and new dockets. - if d_number == "12-2532": - self.assertEqual(docket.source, Docket.HARVARD_AND_RECAP) - else: - self.assertEqual(docket.source, Docket.RECAP) - # Confirm date_last_filing is added to each new docket. - self.assertEqual(docket.date_last_filing, date_filed) - - # BankruptcyInformation is added only on new dockets. - bankr_objs_created = BankruptcyInformation.objects.all() - self.assertEqual(len(bankr_objs_created), 3) - - # Compare bankruptcy data is linked correctly to the parent docket. - bankr_d_1 = BankruptcyInformation.objects.get( - docket__docket_number=a_rss_data_0["docket_number"] - ) - self.assertEqual(bankr_d_1.chapter, str(a_rss_data_0["chapter"])) - self.assertEqual( - bankr_d_1.trustee_str, str(a_rss_data_0["trustee_str"]) - ) - - bankr_d_2 = BankruptcyInformation.objects.get( - docket__docket_number=a_rss_data_1["docket_number"] - ) - self.assertEqual(bankr_d_2.chapter, str(a_rss_data_1["chapter"])) - self.assertEqual( - bankr_d_2.trustee_str, str(a_rss_data_1["trustee_str"]) - ) - - bankr_d_3 = BankruptcyInformation.objects.get( - docket__docket_number=a_rss_data_3["docket_number"] - ) - self.assertEqual(bankr_d_3.chapter, str(a_rss_data_3["chapter"])) - self.assertEqual( - bankr_d_3.trustee_str, str(a_rss_data_3["trustee_str"]) - ) - - def test_avoid_adding_district_dockets_no_pacer_case_id_in_bulk(self): - """Can we avoid adding district/bankr dockets that don't have a - pacer_case_id?""" - - a_rss_data_0 = RssDocketDataFactory( - court_id=self.court_neb.pk, - docket_number="15-3247", - pacer_case_id=None, - docket_entries=[ - RssDocketEntryDataFactory( - document_number=1, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ), - ], - ) - - a_rss_data_1 = RssDocketDataFactory( - court_id=self.court_neb.pk, - docket_number="15-3245", - pacer_case_id="12345", - docket_entries=[ - RssDocketEntryDataFactory( - document_number=1, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ) - ], - ) - - list_rss_data = [ - a_rss_data_0, - a_rss_data_1, - ] - - build_date = a_rss_data_0["docket_entries"][0]["date_filed"] - rds_created, d_created = async_to_sync(merge_rss_data)( - list_rss_data, self.court_neb.pk, build_date - ) - - # Only one docket created: 15-3245, since 15-3247 don't have pacer_case_id - self.assertEqual(d_created, 1) - self.assertEqual(len(rds_created), 1) - - # Compare docket entries and rds created for each docket. - des_to_compare = [("15-3245", 1)] - for d_number, de_count in des_to_compare: - docket = Docket.objects.get(docket_number=d_number) - self.assertEqual(len(docket.docket_entries.all()), de_count) - # For every docket entry there is one recap document created. - docket_entries = docket.docket_entries.all() - for de in docket_entries: - self.assertEqual(len(de.recap_documents.all()), 1) - self.assertNotEqual(de.recap_sequence_number, "") - - # docket_number_core generated for every docket - self.assertNotEqual(docket.docket_number_core, "") - # Slug is generated for every docket - self.assertNotEqual(docket.slug, "") - self.assertEqual(docket.source, Docket.RECAP) - - # BankruptcyInformation is added only on new dockets. - bankr_objs_created = BankruptcyInformation.objects.all() - self.assertEqual(len(bankr_objs_created), 1) - - def test_avoid_adding_existing_entries_by_description(self): - """Can we avoid adding district/bankr dockets that don't have a - pacer_case_id?""" - - de = DocketEntryWithParentsFactory( - docket__court=self.court, - docket__case_name="Young Entry v. Dragon", - docket__docket_number="3:87-CV-01409", - docket__source=Docket.HARVARD, - docket__pacer_case_id="90385", - entry_number=None, - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ) - RECAPDocumentFactory(docket_entry=de, description="Opinion Issued") - a_rss_data_0 = RssDocketDataFactory( - court_id=self.court, - docket_number="3:87-CV-01409", - pacer_case_id="90385", - docket_entries=[ - RssDocketEntryDataFactory( - document_number=None, - short_description="Opinion Issued", - date_filed=make_aware( - datetime(year=2018, month=1, day=5), timezone.utc - ), - ), - ], - ) - list_rss_data = [ - a_rss_data_0, - ] - build_date = a_rss_data_0["docket_entries"][0]["date_filed"] - rds_created, d_created = async_to_sync(merge_rss_data)( - list_rss_data, self.court.pk, build_date - ) - - # No docket entry should be created - self.assertEqual(d_created, 0) - self.assertEqual(len(rds_created), 0) - - @patch( "cl.corpus_importer.management.commands.clean_up_mis_matched_dockets.download_file", side_effect=lambda a: { diff --git a/cl/favorites/templates/prayer_email.html b/cl/favorites/templates/prayer_email.html index 121b2c93af..dbbf0a0e6e 100644 --- a/cl/favorites/templates/prayer_email.html +++ b/cl/favorites/templates/prayer_email.html @@ -1,5 +1,6 @@ {% load text_filters %} {% load humanize %} +{% load extras %} {% load tz %} @@ -44,7 +45,7 @@

    {{ docket_entry.entry_number }}{% if rd.attachment_number %}-{{ rd.attachment_number }}{% endif %} {% if docket_entry.datetime_filed %} - {{ docket_entry.datetime_filed|timezone:timezone|date:"M j, Y" }} + {{ docket_entry.datetime_filed|utc|date:"M j, Y" }} {% else %} {{ docket_entry.date_filed|date:"M j, Y"|default:'Unknown' }} {% endif %} @@ -64,8 +65,8 @@

    {{num_waiting}} people were also waiting for it.

    +

    You requested it on {{ date_created|date:"F j, Y" }}.

    +

    {{num_waiting}} {{ num_waiting|pluralize:"person was,people were" }} waiting for it.

    {% if price %}

    Somebody paid ${{ price }} to make it available to all of us.

    {% endif %}

    diff --git a/cl/favorites/templates/prayer_email.txt b/cl/favorites/templates/prayer_email.txt index 6fe2b8c73e..035ea6aeb8 100644 --- a/cl/favorites/templates/prayer_email.txt +++ b/cl/favorites/templates/prayer_email.txt @@ -1,4 +1,4 @@ -{% load text_filters %}{% load humanize %}{% load tz %}***************** +{% load text_filters %}{% load humanize %}{% load tz %}{% load extras %}***************** CourtListener.com ***************** ------------------------------------------------------- @@ -14,12 +14,12 @@ The document you were waiting for is now available in RECAP. View Docket: https://www.courtlistener.com{{ docket.get_absolute_url }}?order_by=desc Document Number: {{ docket_entry.entry_number }}{% if rd.attachment_number %}-{{ rd.attachment_number }}{% endif %} -Date Filed: {% if docket_entry.datetime_filed %}{{ docket_entry.datetime_filed|timezone:timezone|date:"M j, Y" }}{% else %}{{ docket_entry.date_filed|date:"M j, Y"|default:'Unknown' }}{% endif %} +Date Filed: {% if docket_entry.datetime_filed %}{{ docket_entry.datetime_filed|utc|date:"M j, Y" }}{% else %}{{ docket_entry.date_filed|date:"M j, Y"|default:'Unknown' }}{% endif %} Description: {% if rd.description %}{{ rd.description|safe|wordwrap:80 }}{% else %}{{ docket_entry.description|default:"Unknown docket entry description"|safe|wordwrap:80 }}{% endif %} View Document: https://www.courtlistener.com{{document_url}} ~~~ -You requested it on {{ date_created|date:"M j, Y" }} -{{num_waiting}} people were also waiting for it. +You requested it on {{ date_created|date:"F j, Y" }}. +{{num_waiting}} {{ num_waiting|pluralize:"person was,people were" }} waiting for it. Somebody paid ${{ price }} to make it available to all of us. ************************ diff --git a/cl/favorites/templates/top_prayers.html b/cl/favorites/templates/top_prayers.html index 980d6a76e8..f19935dd8c 100644 --- a/cl/favorites/templates/top_prayers.html +++ b/cl/favorites/templates/top_prayers.html @@ -3,37 +3,60 @@ {% load text_filters %} {% load static %} {% load pacer %} +{% load humanize %} -{% block title %}RECAP Requests – CourtListener.com{% endblock %} -{% block og_title %}RECAP Requests – CourtListener.com{% endblock %} - -{% block description %}RECAP Requests on CourtListener.{% endblock %} -{% block og_description %}RECAP Requests on CourtListener.{% endblock %} +{% block title %}Most Wanted PACER Documents — CourtListener.com{% endblock %} +{% block og_title %}Most Wanted PACER Documents — CourtListener.com{% endblock %} +{% block description %}CourtListener lets you request the purchase of legal documents. View the community's most wanted documents.{% endblock %} +{% block og_description %}CourtListener lets you request the purchase of legal documents. View the community's most wanted documents.{% endblock %} {% block content %} +

    +

    Community's Most Wanted PACER Documents

    +

    {{ granted_stats.prayer_count|intcomma }} prayers granted totaling ${{ granted_stats.total_cost }}.

    +

    {{ waiting_stats.prayer_count|intcomma }} prayers pending totaling at least ${{ waiting_stats.total_cost }}.

    +
    + +
    +
    +

    {% if user.is_authenticated %} + View your prayers + {% else %} + Sign in to view your prayers + {% endif %}

    +
    +
    - - + + - - + {% for prayer in top_prayers %} - + + {% with docket=prayer.docket_entry.docket %} + + + {% endwith %} - - -
    User PreferenceDocument DescriptionCourtCase Name Document NumberPACER Doc IDDocument CourtDocument Description Buy on Pacer
    {{ forloop.counter }}{{ prayer.docket_entry.docket.court.citation_string }} + + {{ prayer.docket_entry.docket|best_case_name|safe|v_wrapper }} ({{ prayer.docket_entry.docket.docket_number }}) + + + + {{ prayer.document_number }} + + {{ prayer.description }}{{ prayer.document_number }}{{ prayer.pacer_doc_id }}{{ prayer.docket_entry.docket.court_id }} +

    {% if is_page_owner %}Your PACER Document Prayers{% else %}PACER Document Requests for: {{ requested_user }}{% endif %}

    + {% if is_page_owner %}

    {{ count|intcomma }} prayers granted totaling ${{total_cost|floatformat:2 }}.

    {% endif %} + + +
    +
    + {% if is_page_owner %} +

    + {% if is_eligible %}You are eligible to make document requests.{% else %}You have reached your daily limit; wait 24 hours to make new requests.{% endif %} +

    + {% endif %} +
    +
    + +
    +
    + + + + + + + + + + {% if is_page_owner %}{% endif %} + + + + {% for rd in rd_with_prayers %} + + + {% with docket=rd.docket_entry.docket %} + + + {% endwith %} + + + + {% if is_page_owner and rd.prayer_status != 2 %} + + {% endif %} + + {% empty %} + + + + {% endfor %} + +
    CourtCase NameDocument NumberDocument DescriptionRequested OnStatusAction
    {{ rd.docket_entry.docket.court.citation_string }} + + {{ docket|best_case_name|safe|v_wrapper }} ({{ docket.docket_number }}) + + + + {{ rd.document_number }} + + {{ rd.description }}{{ rd.prayer_date_created|date:"M j, Y" }}{% if rd.prayer_status == 1 %} Pending {% elif rd.prayer_status == 2 %} + + {% endif %} + +
    +
    + + +
    +   + + + +
    +
    No document requests made. Consider making one!
    +
    +
    +{% endblock %} + +{% block footer-scripts %} + + {% if DEBUG %} + + + {% else %} + + {% endif %} + {% include "includes/buy_pacer_modal.html" %} +{% endblock %} diff --git a/cl/favorites/tests.py b/cl/favorites/tests.py index 9518884d95..35c32652a2 100644 --- a/cl/favorites/tests.py +++ b/cl/favorites/tests.py @@ -2,11 +2,13 @@ import time from datetime import date, timedelta from http import HTTPStatus +from unittest.mock import patch import time_machine from asgiref.sync import sync_to_async from django.contrib.auth.hashers import make_password from django.core import mail +from django.core.cache import cache from django.template.defaultfilters import date as template_date from django.test import AsyncClient, override_settings from django.urls import reverse @@ -23,8 +25,10 @@ create_prayer, delete_prayer, get_existing_prayers_in_bulk, + get_lifetime_prayer_stats, get_prayer_counts_in_bulk, get_top_prayers, + get_user_prayer_history, prayer_eligible, ) from cl.lib.test_helpers import AudioTestCase, SimpleUserDataMixin @@ -784,9 +788,9 @@ async def test_get_top_prayers_by_number(self) -> None: self.assertEqual(await prays.acount(), 6) top_prayers = await get_top_prayers() - self.assertEqual(len(top_prayers), 3) + self.assertEqual(await top_prayers.acount(), 3) expected_top_prayers = [self.rd_2.pk, self.rd_4.pk, self.rd_3.pk] - actual_top_prayers = [top_rd.pk for top_rd in top_prayers] + actual_top_prayers = [top_rd.pk async for top_rd in top_prayers] self.assertEqual( actual_top_prayers, expected_top_prayers, @@ -814,9 +818,9 @@ async def test_get_top_prayers_by_age(self) -> None: await create_prayer(self.user_2, self.rd_3) top_prayers = await get_top_prayers() - self.assertEqual(len(top_prayers), 3) + self.assertEqual(await top_prayers.acount(), 3) expected_top_prayers = [self.rd_3.pk, self.rd_2.pk, self.rd_4.pk] - actual_top_prayers = [top_rd.pk for top_rd in top_prayers] + actual_top_prayers = [top_rd.pk async for top_rd in top_prayers] self.assertEqual( actual_top_prayers, @@ -850,7 +854,7 @@ async def test_get_top_prayers_by_number_and_age(self) -> None: ) # 2 prayers, 4 days old top_prayers = await get_top_prayers() - self.assertEqual(len(top_prayers), 4) + self.assertEqual(await top_prayers.acount(), 4) expected_top_prayers = [ self.rd_4.pk, @@ -858,7 +862,7 @@ async def test_get_top_prayers_by_number_and_age(self) -> None: self.rd_5.pk, self.rd_3.pk, ] - actual_top_prayers = [top_rd.pk for top_rd in top_prayers] + actual_top_prayers = [top_rd.pk async for top_rd in top_prayers] self.assertEqual( actual_top_prayers, @@ -885,6 +889,135 @@ async def test_get_top_prayers_by_number_and_age(self) -> None: top_prayers[3].geometric_mean, rd_3_score, places=2 ) + async def test_get_user_prayer_history(self) -> None: + """Does the get_user_prayer_history method work properly?""" + # Prayers for user_2 + await create_prayer(self.user_2, self.rd_4) + + # Prayers for user + await create_prayer(self.user, self.rd_2) + prayer_rd3 = await create_prayer(self.user, self.rd_3) + prayer_rd5 = await create_prayer(self.user, self.rd_5) + + # Verify that the initial prayer count and total cost are 0. + count, total_cost = await get_user_prayer_history(self.user) + self.assertEqual(count, 0) + self.assertEqual(total_cost, 0.0) + + # Update `rd_3`'s page count and set `prayer_rd3`'s status to `GRANTED` + self.rd_3.page_count = 2 + await self.rd_3.asave() + + prayer_rd3.status = Prayer.GRANTED + await prayer_rd3.asave() + + # Verify that the count is 1 and total cost is 0.20. + count, total_cost = await get_user_prayer_history(self.user) + self.assertEqual(count, 1) + self.assertEqual(total_cost, 0.20) + + # Update `rd_5`'s page count and set `prayer_rd5`'s status to `GRANTED` + self.rd_5.page_count = 40 + await self.rd_5.asave() + + prayer_rd5.status = Prayer.GRANTED + await prayer_rd5.asave() + + # Verify that the count is 2 and the total cost is now 3.20. + count, total_cost = await get_user_prayer_history(self.user) + self.assertEqual(count, 2) + self.assertEqual(total_cost, 3.20) + + @patch("cl.favorites.utils.cache.aget") + async def test_get_lifetime_prayer_stats(self, mock_cache_aget) -> None: + """Does the get_lifetime_prayer_stats method work properly?""" + mock_cache_aget.return_value = None + + # Update page counts for recap documents + self.rd_2.page_count = 5 + await self.rd_2.asave() + self.rd_3.page_count = 1 + await self.rd_3.asave() + self.rd_4.page_count = 45 + await self.rd_4.asave() + self.rd_5.page_count = 20 + await self.rd_5.asave() + + # Create prayer requests for the following user-document pairs: + # - User: Recap Document 2, Recap Document 3, Recap Document 5 + # - User 2: Recap Document 2, Recap Document 3, Recap Document 4 + await create_prayer(self.user, self.rd_2) + await create_prayer(self.user_2, self.rd_2) + await create_prayer(self.user, self.rd_3) + await create_prayer(self.user_2, self.rd_3) + await create_prayer(self.user_2, self.rd_4) + await create_prayer(self.user, self.rd_5) + + # Verify expected values for waiting prayers: + # - Total count of 6 prayers + # - 4 distinct documents + # - Total cost of $5.60 (sum of individual document costs) + prayer_stats = await get_lifetime_prayer_stats(Prayer.WAITING) + self.assertEqual(prayer_stats.prayer_count, 6) + self.assertEqual(prayer_stats.distinct_count, 4) + self.assertEqual(prayer_stats.total_cost, "5.60") + + # Verify that no prayers have been granted: + # - Zero count of granted prayers + # - Zero distinct documents + # - Zero total cost + prayer_stats = await get_lifetime_prayer_stats(Prayer.GRANTED) + self.assertEqual(prayer_stats.prayer_count, 0) + self.assertEqual(prayer_stats.distinct_count, 0) + self.assertEqual(prayer_stats.total_cost, "0.00") + + # rd_2 is granted. + self.rd_2.is_available = True + await self.rd_2.asave() + + # Verify that granting `rd_2` reduces the number of waiting prayers: + # - Total waiting prayers should decrease by 2 (as `rd_2` had 2 prayers) + # - Distinct documents should decrease by 1 + # - Total cost should decrease to 5.10 (excluding `rd_2`'s cost) + prayer_stats = await get_lifetime_prayer_stats(Prayer.WAITING) + self.assertEqual(prayer_stats.prayer_count, 4) + self.assertEqual(prayer_stats.distinct_count, 3) + self.assertEqual(prayer_stats.total_cost, "5.10") + + # Verify that granting `rd_2` increases the number of granted prayers: + # - Total granted prayers should increase by 2 (as `rd_2` had 2 prayers) + # - Distinct documents should increase by 1 + # - Total cost should increase by 0.50 (the cost of granting `rd_2`) + prayer_stats = await get_lifetime_prayer_stats(Prayer.GRANTED) + self.assertEqual(prayer_stats.prayer_count, 2) + self.assertEqual(prayer_stats.distinct_count, 1) + self.assertEqual(prayer_stats.total_cost, "0.50") + + # rd_4 is granted. + self.rd_4.is_available = True + await self.rd_4.asave() + + # Verify that granting `rd_4` reduces the number of waiting prayers: + # - Total waiting prayers should decrease by 3 (2 from `rd_2` and 1 from `rd_4`) + # - Distinct documents should decrease by 2 (`rd_2` and `rd_4`) + # - Total cost should decrease to 2.10 (excluding costs of `rd_2` and `rd_4`) + prayer_stats = await get_lifetime_prayer_stats(Prayer.WAITING) + self.assertEqual(prayer_stats.prayer_count, 3) + self.assertEqual(prayer_stats.distinct_count, 2) + self.assertEqual(prayer_stats.total_cost, "2.10") + + # Verify that granting `rd_4` increases the number of granted prayers: + # - Total granted prayers should increase by 3 (2 from `rd_2` and 1 from `rd_4`) + # - Distinct documents should increase by 1 (`rd_2` and `rd_4` are now granted) + # - Total cost should increase by 3.50 (the combined cost of `rd_2` and `rd_4`) + prayer_stats = await get_lifetime_prayer_stats(Prayer.GRANTED) + self.assertEqual(prayer_stats.prayer_count, 3) + self.assertEqual(prayer_stats.distinct_count, 2) + self.assertEqual(prayer_stats.total_cost, "3.50") + + await cache.adelete(f"prayer-stats-{Prayer.WAITING}") + await cache.adelete(f"prayer-stats-{Prayer.GRANTED}") + async def test_prayers_integration(self) -> None: """Integration test for prayers.""" @@ -916,11 +1049,11 @@ async def test_prayers_integration(self) -> None: # Confirm top prayers list is as expected. top_prayers = await get_top_prayers() self.assertEqual( - len(top_prayers), 2, msg="Wrong number of top prayers" + await top_prayers.acount(), 2, msg="Wrong number of top prayers" ) expected_top_prayers = [rd_6.pk, self.rd_4.pk] - actual_top_prayers = [top_rd.pk for top_rd in top_prayers] + actual_top_prayers = [top_rd.pk async for top_rd in top_prayers] self.assertEqual( actual_top_prayers, expected_top_prayers, msg="Wrong top_prayers." ) @@ -983,11 +1116,11 @@ async def test_prayers_integration(self) -> None: email_text_content, ) self.assertIn( - f"You requested it on {template_date(make_naive(prayer_1.date_created), 'M j, Y')}", + f"You requested it on {template_date(make_naive(prayer_1.date_created), 'F j, Y')}", email_text_content, ) self.assertIn( - f"{len(actual_top_prayers)} people were also waiting for it.", + f"{len(actual_top_prayers)} people were waiting for it.", email_text_content, ) self.assertIn( @@ -1000,11 +1133,11 @@ async def test_prayers_integration(self) -> None: html_content, ) self.assertIn( - f"{len(actual_top_prayers)} people were also waiting for it.", + f"{len(actual_top_prayers)} people were waiting for it.", html_content, ) self.assertIn( - f"You requested it on {template_date(make_naive(prayer_1.date_created), 'M j, Y')}", + f"You requested it on {template_date(make_naive(prayer_1.date_created), 'F j, Y')}", html_content, ) self.assertIn( @@ -1017,9 +1150,13 @@ async def test_prayers_integration(self) -> None: ) top_prayers = await get_top_prayers() - self.assertEqual(len(top_prayers), 1, msg="Wrong top_prayers.") self.assertEqual( - top_prayers[0], self.rd_4, msg="The top prayer didn't match." + await top_prayers.acount(), 1, msg="Wrong top_prayers." + ) + self.assertEqual( + await top_prayers.afirst(), + self.rd_4, + msg="The top prayer didn't match.", ) diff --git a/cl/favorites/urls.py b/cl/favorites/urls.py index a97cc9cc8e..5b8621b720 100644 --- a/cl/favorites/urls.py +++ b/cl/favorites/urls.py @@ -6,6 +6,7 @@ delete_prayer_view, open_prayers, save_or_update_note, + user_prayers_view, view_tag, view_tags, ) @@ -25,6 +26,7 @@ name="view_tag", ), path("tags//", view_tags, name="tag_list"), + # Prayer pages path("prayers/top/", open_prayers, name="top_prayers"), path( "prayer/create//", @@ -36,4 +38,5 @@ delete_prayer_view, name="delete_prayer", ), + path("prayers//", user_prayers_view, name="user_prayers"), ] diff --git a/cl/favorites/utils.py b/cl/favorites/utils.py index bbe40309c3..9e87389890 100644 --- a/cl/favorites/utils.py +++ b/cl/favorites/utils.py @@ -1,18 +1,25 @@ +from dataclasses import dataclass from datetime import timedelta from django.conf import settings from django.contrib.auth.models import User +from django.core.cache import cache from django.core.mail import EmailMultiAlternatives, get_connection from django.db.models import ( Avg, + Case, Count, ExpressionWrapper, F, FloatField, Q, + QuerySet, Subquery, + Sum, + Value, + When, ) -from django.db.models.functions import Cast, Extract, Now, Sqrt +from django.db.models.functions import Cast, Extract, Least, Now, Sqrt from django.template import loader from django.utils import timezone @@ -93,7 +100,7 @@ async def get_existing_prayers_in_bulk( return {rd_id: True async for rd_id in existing_prayers} -async def get_top_prayers() -> list[RECAPDocument]: +async def get_top_prayers() -> QuerySet[RECAPDocument]: # Calculate the age of each prayer prayer_age = ExpressionWrapper( Extract(Now() - F("prayers__date_created"), "epoch"), @@ -117,11 +124,18 @@ async def get_top_prayers() -> list[RECAPDocument]: "attachment_number", "pacer_doc_id", "page_count", + "is_free_on_pacer", "description", + "docket_entry__entry_number", "docket_entry__docket_id", "docket_entry__docket__slug", + "docket_entry__docket__case_name", + "docket_entry__docket__case_name_short", + "docket_entry__docket__case_name_full", + "docket_entry__docket__docket_number", "docket_entry__docket__pacer_case_id", "docket_entry__docket__court__jurisdiction", + "docket_entry__docket__court__citation_string", "docket_entry__docket__court_id", ) .annotate( @@ -144,7 +158,84 @@ async def get_top_prayers() -> list[RECAPDocument]: .order_by("-geometric_mean")[:50] ) - return [doc async for doc in documents.aiterator()] + return documents + + +async def get_user_prayers(user: User) -> QuerySet[RECAPDocument]: + user_prayers = Prayer.objects.filter(user=user).values("recap_document_id") + + documents = ( + RECAPDocument.objects.filter(id__in=Subquery(user_prayers)) + .select_related( + "docket_entry", + "docket_entry__docket", + "docket_entry__docket__court", + ) + .only( + "pk", + "document_type", + "document_number", + "attachment_number", + "pacer_doc_id", + "page_count", + "filepath_local", + "filepath_ia", + "is_free_on_pacer", + "description", + "date_upload", + "date_created", + "docket_entry__entry_number", + "docket_entry__docket_id", + "docket_entry__docket__slug", + "docket_entry__docket__case_name", + "docket_entry__docket__case_name_short", + "docket_entry__docket__case_name_full", + "docket_entry__docket__docket_number", + "docket_entry__docket__pacer_case_id", + "docket_entry__docket__court__jurisdiction", + "docket_entry__docket__court__citation_string", + "docket_entry__docket__court_id", + ) + .annotate( + prayer_status=F("prayers__status"), + prayer_date_created=F("prayers__date_created"), + ) + .order_by("-prayers__date_created") + ) + + return documents + + +async def compute_prayer_total_cost(queryset: QuerySet[Prayer]) -> float: + """ + Computes the total cost of a given queryset of Prayer objects. + + Args: + queryset: A QuerySet of Prayer objects. + + Returns: + The total cost of the prayers in the queryset, as a float. + """ + cost = await ( + queryset.values("recap_document") + .distinct() + .annotate( + price=Case( + When(recap_document__is_free_on_pacer=True, then=Value(0.0)), + When( + recap_document__page_count__gt=0, + then=Least( + Value(3.0), + F("recap_document__page_count") * Value(0.10), + ), + ), + default=Value(0.0), + ) + ) + .aaggregate(Sum("price", default=0.0)) + ) + + return cost["price__sum"] def send_prayer_emails(instance: RECAPDocument) -> None: @@ -200,30 +291,54 @@ def send_prayer_emails(instance: RECAPDocument) -> None: async def get_user_prayer_history(user: User) -> tuple[int, float]: - filtered_list = Prayer.objects.filter(user=user, status=Prayer.GRANTED) + filtered_list = Prayer.objects.filter( + user=user, status=Prayer.GRANTED + ).select_related("recap_document") count = await filtered_list.acount() - total_cost = 0 - async for prayer in filtered_list: - total_cost += float(price(prayer.recap_document)) + total_cost = await compute_prayer_total_cost(filtered_list) return count, total_cost -async def get_lifetime_prayer_stats() -> tuple[int, int, float]: +@dataclass +class PrayerStats: + prayer_count: int + distinct_count: int + total_cost: str - filtered_list = Prayer.objects.filter(status=Prayer.GRANTED) - count = await filtered_list.acount() +async def get_lifetime_prayer_stats( + status: int, +) -> ( + PrayerStats +): # status can be only 1 (WAITING) or 2 (GRANTED) based on the Prayer model + + cache_key = f"prayer-stats-{status}" + + data = await cache.aget(cache_key) + if data is not None: + return PrayerStats(**data) - total_cost = 0 - distinct_documents = set() + prayer_by_status = Prayer.objects.filter(status=status) - async for prayer in filtered_list: - distinct_documents.add(prayer.recap_document) - total_cost += float(await price(prayer.recap_document)) + prayer_count = await prayer_by_status.acount() - num_distinct_purchases = len(distinct_documents) + distinct_prayers = ( + await prayer_by_status.values("recap_document").distinct().acount() + ) + + total_cost = await compute_prayer_total_cost( + prayer_by_status.select_related("recap_document") + ) + + data = { + "prayer_count": prayer_count, + "distinct_count": distinct_prayers, + "total_cost": f"{total_cost:,.2f}", + } + one_day = 60 * 60 * 24 + await cache.aset(cache_key, data, one_day) - return count, num_distinct_purchases, total_cost + return PrayerStats(**data) diff --git a/cl/favorites/views.py b/cl/favorites/views.py index 0b05a6eb72..bd2c8ea5b5 100644 --- a/cl/favorites/views.py +++ b/cl/favorites/views.py @@ -11,16 +11,19 @@ HttpResponseNotAllowed, HttpResponseServerError, ) -from django.shortcuts import aget_object_or_404 +from django.shortcuts import aget_object_or_404, redirect from django.template.response import TemplateResponse from django.utils.datastructures import MultiValueDictKeyError from cl.favorites.forms import NoteForm -from cl.favorites.models import DocketTag, Note, UserTag +from cl.favorites.models import DocketTag, Note, Prayer, UserTag from cl.favorites.utils import ( create_prayer, delete_prayer, + get_lifetime_prayer_stats, get_top_prayers, + get_user_prayer_history, + get_user_prayers, prayer_eligible, ) from cl.lib.decorators import cache_page_ignore_params @@ -189,9 +192,14 @@ async def open_prayers(request: HttpRequest) -> HttpResponse: top_prayers = await get_top_prayers() + granted_stats = await get_lifetime_prayer_stats(Prayer.GRANTED) + waiting_stats = await get_lifetime_prayer_stats(Prayer.WAITING) + context = { "top_prayers": top_prayers, "private": False, + "granted_stats": granted_stats, + "waiting_stats": waiting_stats, } return TemplateResponse(request, "top_prayers.html", context) @@ -203,6 +211,7 @@ async def create_prayer_view( ) -> HttpResponse: user = request.user is_htmx_request = request.META.get("HTTP_HX_REQUEST", False) + regular_size = bool(request.POST.get("regular_size")) if not await prayer_eligible(request.user): if is_htmx_request: return TemplateResponse( @@ -213,6 +222,8 @@ async def create_prayer_view( "document_id": recap_document, "count": 0, "daily_limit_reached": True, + "regular_size": regular_size, + "should_swap": True, }, ) return HttpResponseServerError( @@ -232,6 +243,8 @@ async def create_prayer_view( "document_id": recap_document.pk, "count": 0, "daily_limit_reached": False, + "regular_size": regular_size, + "should_swap": True, }, ) return HttpResponse("It worked.") @@ -246,7 +259,8 @@ async def delete_prayer_view( # Call the delete_prayer async function await delete_prayer(user, recap_document) - + regular_size = bool(request.POST.get("regular_size")) + source = request.POST.get("source", "") if request.META.get("HTTP_HX_REQUEST"): return TemplateResponse( request, @@ -255,6 +269,38 @@ async def delete_prayer_view( "prayer_exists": False, "document_id": recap_document.pk, "count": 0, + "regular_size": regular_size, + "should_swap": True if source != "user_prayer_list" else False, }, + headers={"HX-Trigger": "prayersListChanged"}, ) return HttpResponse("It worked.") + + +async def user_prayers_view( + request: HttpRequest, username: str +) -> HttpResponse: + requested_user = await aget_object_or_404(User, username=username) + is_page_owner = await request.auser() == requested_user + + # this is a temporary restriction for the MVP. The intention is to eventually treat like tags. + if not is_page_owner: + return redirect("top_prayers") + + rd_with_prayers = await get_user_prayers(requested_user) + + count, total_cost = await get_user_prayer_history(requested_user) + + is_eligible = await prayer_eligible(requested_user) + + context = { + "rd_with_prayers": rd_with_prayers, + "requested_user": requested_user, + "is_page_owner": is_page_owner, + "count": count, + "total_cost": total_cost, + "is_eligible": is_eligible, + "private": False, + } + + return TemplateResponse(request, "user_prayers.html", context) diff --git a/cl/lib/admin.py b/cl/lib/admin.py index 754bce6f08..41f1fe4931 100644 --- a/cl/lib/admin.py +++ b/cl/lib/admin.py @@ -1,4 +1,9 @@ +from typing import Any, Type +from urllib.parse import urlencode + from django.contrib.contenttypes.admin import GenericTabularInline +from django.db.models import Model +from django.urls import reverse from cl.lib.models import Note @@ -13,3 +18,30 @@ class Media: class NotesInline(GenericTabularInline): model = Note extra = 1 + + +def build_admin_url( + model_class: Type[Model], + query_params: dict[str, Any] | None = None, +) -> str: + """ + Construct a URL for a given model's admin view, optionally appending query parameters. + + :param model_class: The Django model class for which the admin URL will be built. + :param query_params: A dictionary of query parameters to append to the URL (e.g. {"docket": 123}). + :return: A string representing the fully constructed admin URL, including any query parameters. + + Example usage: + >>> from cl.search.models import DocketEntry + >>> build_admin_url(DocketEntry, {"docket": "1234"}) + '/admin/search/docketentry/?docket=1234' + """ + query_params = query_params or {} + app_label = model_class._meta.app_label + model_name = model_class._meta.model_name + # "admin:app_label_modelname_changelist" is the standard naming for admin changelist + entries_changelist_url = reverse( + f"admin:{app_label}_{model_name}_changelist" + ) + encoded_query_params = urlencode(query_params) + return f"{entries_changelist_url}?{encoded_query_params}" diff --git a/cl/lib/context_processors.py b/cl/lib/context_processors.py index d5b3957f4a..b92c5070ea 100644 --- a/cl/lib/context_processors.py +++ b/cl/lib/context_processors.py @@ -80,7 +80,7 @@ def inject_settings(request): 'CourtListener has
    every free opinion and order available in PACER and gets the latest ones every night.', 'Want to learn more about PACER? We have an extensive fact sheet.', 'You can use the link to any RECAP PDF to pull up the docket.', - "We have more than 80 million pages of PACER documents searchable in the RECAP Archive.", + "We have more than 200 million pages of PACER documents searchable in the RECAP Archive.", 'You can create an alert for any docket in the RECAP Archive. Just press the "Get Alerts" button.' % reverse("alert_help"), ) diff --git a/cl/lib/elasticsearch_utils.py b/cl/lib/elasticsearch_utils.py index de19b030b5..c39292e9aa 100644 --- a/cl/lib/elasticsearch_utils.py +++ b/cl/lib/elasticsearch_utils.py @@ -37,6 +37,7 @@ ApiPositionMapping, BasePositionMapping, CleanData, + EsJoinQueries, EsMainQueries, ESRangeQueryParams, ) @@ -71,6 +72,7 @@ SEARCH_RECAP_PARENT_QUERY_FIELDS, api_child_highlight_map, cardinality_query_unique_ids, + date_decay_relevance_types, recap_boosts_es, ) from cl.search.exception import ( @@ -395,7 +397,13 @@ def build_fulltext_query( q_should.append( Q( "match_phrase", - caseName={"query": query_value, "boost": 2, "slop": 1}, + **{ + "caseName.exact": { + "query": query_value, + "boost": 2, + "slop": 1, + } + }, ) ) @@ -481,26 +489,14 @@ def build_text_filter(field: str, value: str) -> List: if isinstance(value, str): validate_query_syntax(value, QueryType.FILTER) - base_query_string = { - "query_string": { - "query": value, - "fields": [field], - "default_operator": "AND", - } - } - if "caseName" in field and '"' not in value: - # Use phrase with slop, and give it a boost to prioritize the - # phrase match to ensure that caseName filtering returns exactly - # this order as the first priority. - # Avoid applying slop to quoted queries, as they expect exact matches. - base_query_string["query_string"].update( - { - "type": "phrase", - "phrase_slop": "1", - "boost": "2", # Boosting the phrase match to ensure it's ranked higher than individual term matches - } + return [ + Q( + "query_string", + query=value, + fields=[field], + default_operator="AND", ) - return [Q(base_query_string)] + ] return [] @@ -944,6 +940,74 @@ def build_custom_function_score_for_date( return query +def build_decay_relevance_score( + query: QueryString | str, + date_field: str, + scale: int, + decay: float, + default_missing_date: str = "1600-01-01T00:00:00Z", + boost_mode: str = "multiply", + min_score: float = 0.0, +) -> QueryString: + """ + Build a decay relevance score query for Elasticsearch that adjusts the + relevance of documents based on a date field. + + :param query: The Elasticsearch query string or QueryString object. + :param date_field: The date field used to compute the relevance decay. + :param scale: The scale (in years) that determines the rate of decay. + :param decay: The decay factor. + :param default_missing_date: The default date to use when the date field + is null. + :param boost_mode: The mode to combine the decay score with the query's + original relevance score. + :param min_score: The minimum score where the decay function stabilizes. + :return: The modified QueryString object with applied function score. + """ + + query = Q( + "function_score", + query=query, + script_score={ + "script": { + "source": f""" + def default_missing_date = Instant.parse(params.default_missing_date).toEpochMilli(); + def decay = (double)params.decay; + def now = new Date().getTime(); + def min_score = (double)params.min_score; + + // Convert scale parameter into milliseconds. + double years = (double)params.scale; + // Convert years to milliseconds 1 year = 365 days + long scaleMillis = (long)(years * 365 * 24 * 60 * 60 * 1000); + + // Retrieve the document date. If missing or null, use default_missing_date + def docDate = default_missing_date; + if (doc['{date_field}'].size() > 0) {{ + docDate = doc['{date_field}'].value.toInstant().toEpochMilli(); + }} + // λ = ln(decay)/scale + def lambda = Math.log(decay) / scaleMillis; + // Absolute distance from now + def diff = Math.abs(docDate - now); + // Score: exp( λ * max(0, |docDate - now|) ) + def decay_score = Math.exp(lambda * diff); + // Adjust the decay score to have a minimum value + return min_score + ((1 - min_score) * decay_score); + """, + "params": { + "default_missing_date": default_missing_date, + "scale": scale, # Years + "decay": decay, + "min_score": min_score, + }, + }, + }, + boost_mode=boost_mode, + ) + return query + + def build_has_child_query( query: QueryString | str, child_type: str, @@ -1027,30 +1091,21 @@ def combine_plain_filters_and_queries( final_query.filter = reduce(operator.iand, filters) if filters and string_query: final_query.minimum_should_match = 1 - - if cd["type"] == SEARCH_TYPES.ORAL_ARGUMENT: - # Apply custom score for dateArgued sorting in the V4 API. - final_query = apply_custom_score_to_main_query( - cd, final_query, api_version - ) return final_query def get_match_all_query( cd: CleanData, - search_query: Search, api_version: Literal["v3", "v4"] | None = None, child_highlighting: bool = True, -) -> Search: +) -> Query: """Build and return a match-all query for each type of document. :param cd: The query CleanedData - :param search_query: Elasticsearch DSL Search object :param api_version: Optional, the request API version. :param child_highlighting: Whether highlighting should be enabled in child docs. - :return: The modified Search object based on the given conditions. + :return: The Match All Query object. """ - _, query_hits_limit = get_child_top_hits_limit( cd, cd["type"], api_version=api_version ) @@ -1074,9 +1129,6 @@ def get_match_all_query( final_match_all_query = Q( "bool", should=q_should, minimum_should_match=1 ) - final_match_all_query = apply_custom_score_to_main_query( - cd, final_match_all_query, api_version - ) case SEARCH_TYPES.RECAP | SEARCH_TYPES.DOCKETS: # Match all query for RECAP and Dockets, it'll return dockets # with child documents and also empty dockets. @@ -1098,9 +1150,6 @@ def get_match_all_query( should=[match_all_child_query, match_all_parent_query], minimum_should_match=1, ) - final_match_all_query = apply_custom_score_to_main_query( - cd, final_match_all_query, api_version - ) case SEARCH_TYPES.OPINION: # Only return Opinion clusters. match_all_child_query = build_has_child_query( @@ -1121,12 +1170,9 @@ def get_match_all_query( case _: # No string_query or filters in plain search types like OA and # Parentheticals. Use a match_all query. - match_all_query = Q("match_all") - final_match_all_query = apply_custom_score_to_main_query( - cd, match_all_query, api_version - ) + final_match_all_query = Q("match_all") - return search_query.query(final_match_all_query) + return final_match_all_query def build_es_base_query( @@ -1153,10 +1199,13 @@ def build_es_base_query( main_query = None string_query = None - child_docs_query = None + child_query = None parent_query = None filters = [] plain_doc = False + join_queries = None + has_text_query = False + match_all_query = False match cd["type"]: case SEARCH_TYPES.PARENTHETICAL: filters = build_es_plain_filters(cd) @@ -1199,14 +1248,12 @@ def build_es_base_query( ], ) ) - main_query, child_docs_query, parent_query = ( - build_full_join_es_queries( - cd, - child_query_fields, - parent_query_fields, - child_highlighting=child_highlighting, - api_version=api_version, - ) + join_queries = build_full_join_es_queries( + cd, + child_query_fields, + parent_query_fields, + child_highlighting=child_highlighting, + api_version=api_version, ) case ( @@ -1232,15 +1279,13 @@ def build_es_base_query( ], ) ) - main_query, child_docs_query, parent_query = ( - build_full_join_es_queries( - cd, - child_query_fields, - parent_query_fields, - child_highlighting=child_highlighting, - api_version=api_version, - alerts=alerts, - ) + join_queries = build_full_join_es_queries( + cd, + child_query_fields, + parent_query_fields, + child_highlighting=child_highlighting, + api_version=api_version, + alerts=alerts, ) case SEARCH_TYPES.OPINION: @@ -1252,20 +1297,19 @@ def build_es_base_query( mlt_query = async_to_sync(build_more_like_this_query)( cluster_pks ) - main_query, child_docs_query, parent_query = ( - build_full_join_es_queries( - cd, - {"opinion": []}, - [], - mlt_query, - child_highlighting=True, - api_version=api_version, - ) + join_queries = build_full_join_es_queries( + cd, + {"opinion": []}, + [], + mlt_query, + child_highlighting=True, + api_version=api_version, ) return EsMainQueries( - search_query=search_query.query(main_query), - parent_query=parent_query, - child_query=child_docs_query, + search_query=search_query.query(join_queries.main_query), + boost_mode="multiply", + parent_query=join_queries.parent_query, + child_query=join_queries.child_query, ) opinion_search_fields = SEARCH_OPINION_QUERY_FIELDS @@ -1292,41 +1336,48 @@ def build_es_base_query( ], ) ) - main_query, child_docs_query, parent_query = ( - build_full_join_es_queries( - cd, - child_query_fields, - parent_query_fields, - mlt_query, - child_highlighting=child_highlighting, - api_version=api_version, - alerts=alerts, - ) + join_queries = build_full_join_es_queries( + cd, + child_query_fields, + parent_query_fields, + mlt_query, + child_highlighting=child_highlighting, + api_version=api_version, + alerts=alerts, ) + if join_queries is not None: + main_query = join_queries.main_query + parent_query = join_queries.parent_query + child_query = join_queries.child_query + has_text_query = join_queries.has_text_query + if not any([filters, string_query, main_query]): # No filters, string_query or main_query provided by the user, return a # match_all query - match_all_query = get_match_all_query( - cd, search_query, api_version, child_highlighting - ) - return EsMainQueries( - search_query=match_all_query, - parent_query=parent_query, - child_query=child_docs_query, - ) + main_query = get_match_all_query(cd, api_version, child_highlighting) + match_all_query = True - if plain_doc: + boost_mode = "multiply" if has_text_query else "replace" + if plain_doc and not match_all_query: # Combine the filters and string query for plain documents like Oral # arguments and parentheticals main_query = combine_plain_filters_and_queries( cd, filters, string_query, api_version ) + boost_mode = "multiply" if string_query else "replace" + + # Apply a custom function score to the main query, useful for cursor pagination + # in the V4 API and for date decay relevance. + main_query = apply_custom_score_to_main_query( + cd, main_query, api_version, boost_mode=boost_mode + ) return EsMainQueries( search_query=search_query.query(main_query), + boost_mode=boost_mode, parent_query=parent_query, - child_query=child_docs_query, + child_query=child_query, ) @@ -2082,15 +2133,27 @@ def merge_unavailable_fields_on_parent_document( def clean_count_query(search_query: Search) -> SearchDSL: """Cleans a given ES Search object for a count query. - Modifies the input Search object by removing 'inner_hits' from - any 'has_child' queries within the 'should' clause of the boolean query. + Modifies the input Search object by removing 'function_score' from the main + query if present and/or 'inner_hits' from any 'has_child' queries within + the 'should' clause of the boolean query. It then creates a new Search object with the modified query. :param search_query: The ES Search object. :return: A new ES Search object with the count query. """ - parent_total_query_dict = search_query.to_dict() + parent_total_query_dict = search_query.to_dict(count=True) + try: + # Clean function_score in queries that contain it + parent_total_query_dict = parent_total_query_dict["query"][ + "function_score" + ] + del parent_total_query_dict["boost_mode"] + del parent_total_query_dict["functions"] + except KeyError: + # Omit queries that don't contain it. + pass + try: # Clean the has_child query in queries that contain it. for query in parent_total_query_dict["query"]["bool"]["should"]: @@ -2495,13 +2558,17 @@ def nullify_query_score(query: Query) -> Query: def apply_custom_score_to_main_query( - cd: CleanData, query: Query, api_version: Literal["v3", "v4"] | None = None + cd: CleanData, + query: Query, + api_version: Literal["v3", "v4"] | None = None, + boost_mode: str = "multiply", ) -> Query: """Apply a custom function score to the main query. :param cd: The query CleanedData :param query: The ES Query object to be modified. :param api_version: Optional, the request API version. + :param boost_mode: Optional, the boost mode to apply for the decay relevancy score :return: The function_score query contains the base query, applied when child_order is used. """ @@ -2522,6 +2589,10 @@ def apply_custom_score_to_main_query( else False ) + valid_decay_relevance_types: dict[str, dict[str, str | int | float]] = ( + date_decay_relevance_types + ) + main_order_by = cd.get("order_by", "") if is_valid_custom_score_field and api_version == "v4": # Applies a custom function score to sort Documents based on # a date field. This serves as a workaround to enable the use of the @@ -2532,7 +2603,23 @@ def apply_custom_score_to_main_query( default_score=0, default_current_date=cd["request_date"], ) - + elif ( + main_order_by == "score desc" + and cd["type"] in valid_decay_relevance_types + ): + decay_settings = valid_decay_relevance_types[cd["type"]] + date_field = str(decay_settings["field"]) + scale = int(decay_settings["scale"]) + decay = float(decay_settings["decay"]) + min_score = float(decay_settings["min_score"]) + query = build_decay_relevance_score( + query, + date_field, + scale=scale, + decay=decay, + boost_mode=boost_mode, + min_score=min_score, + ) return query @@ -2544,7 +2631,7 @@ def build_full_join_es_queries( child_highlighting: bool = True, api_version: Literal["v3", "v4"] | None = None, alerts: bool = False, -) -> tuple[QueryString | list, QueryString | None, QueryString | None]: +) -> EsJoinQueries: """Build a complete Elasticsearch query with both parent and child document conditions. @@ -2560,6 +2647,7 @@ def build_full_join_es_queries( """ q_should = [] + has_text_query = False match cd["type"]: case ( SEARCH_TYPES.RECAP @@ -2689,6 +2777,7 @@ def build_full_join_es_queries( string_query = build_fulltext_query( parent_query_fields, cd.get("q", ""), only_queries=True ) + has_text_query = True if string_query else False # If child filters are set, add a has_child query as a filter to the # parent query to exclude results without matching children. @@ -2736,17 +2825,22 @@ def build_full_join_es_queries( q_should.append(parent_query) if not q_should: - return [], child_docs_query, parent_query + return EsJoinQueries( + main_query=[], + parent_query=parent_query, + child_query=child_docs_query, + has_text_query=has_text_query, + ) - main_join_query = apply_custom_score_to_main_query( - cd, - Q( + return EsJoinQueries( + main_query=Q( "bool", should=q_should, ), - api_version, + parent_query=parent_query, + child_query=child_docs_query, + has_text_query=has_text_query, ) - return (main_join_query, child_docs_query, parent_query) def limit_inner_hits( @@ -3006,11 +3100,14 @@ def do_es_api_query( # and sorting are set. # Note that in V3 Case Law Search, opinions are collapsed by cluster_id # meaning that only one result per cluster is shown. - s = build_child_docs_query( + child_docs_query = build_child_docs_query( child_docs_query, cd=cd, ) - main_query = search_query.query(s) + main_query = apply_custom_score_to_main_query( + cd, child_docs_query, api_version, boost_mode=es_queries.boost_mode + ) + main_query = search_query.query(main_query) highlight_options, fields_to_exclude = build_highlights_dict( highlighting_fields, hl_tag ) @@ -3053,7 +3150,10 @@ def do_es_api_query( # field exclusion are set. s = apply_custom_score_to_main_query( - cd, child_docs_query, api_version + cd, + child_docs_query, + api_version, + boost_mode=es_queries.boost_mode, ) main_query = search_query.query(s) highlight_options, fields_to_exclude = build_highlights_dict( @@ -3433,6 +3533,7 @@ def get_opinions_coverage_over_time( format="yyyy", ), ) + try: response = search_query.execute() except (TransportError, ConnectionError, RequestError): diff --git a/cl/lib/pacer.py b/cl/lib/pacer.py index bd36bd2fed..44ef903653 100644 --- a/cl/lib/pacer.py +++ b/cl/lib/pacer.py @@ -84,7 +84,6 @@ def lookup_and_save(new, debug=False): d = ds[0] elif count > 1: # Too many dockets returned. Disambiguate. - logger.error("Got multiple results while attempting save.") def is_different(x): return x.pacer_case_id and x.pacer_case_id != new.pacer_case_id diff --git a/cl/lib/tests.py b/cl/lib/tests.py index 2805629bed..ce1aa40741 100644 --- a/cl/lib/tests.py +++ b/cl/lib/tests.py @@ -1157,16 +1157,31 @@ def test_check_and_sanitize_queries_bad_syntax(self) -> None: "output": True, "sanitized": "This is unbalanced", }, + { + "input_str": "This is “unbalanced", + "output": True, + "sanitized": "This is unbalanced", + }, { "input_str": 'This is "unbalanced""', "output": True, "sanitized": 'This is "unbalanced"', }, + { + "input_str": "This is “unbalanced””", + "output": True, + "sanitized": 'This is "unbalanced"', + }, { "input_str": 'This "is" unbalanced"', "output": True, "sanitized": 'This "is" unbalanced', }, + { + "input_str": 'This "is” unbalanced"', + "output": True, + "sanitized": 'This "is" unbalanced', + }, { "input_str": 'This "is" unbalanced"""', "output": True, diff --git a/cl/lib/types.py b/cl/lib/types.py index ff257574e9..e4c29c31e6 100644 --- a/cl/lib/types.py +++ b/cl/lib/types.py @@ -195,10 +195,19 @@ def get_db_to_dataclass_map(self): @dataclass class EsMainQueries: search_query: Search + boost_mode: str parent_query: QueryString | None = None child_query: QueryString | None = None +@dataclass +class EsJoinQueries: + main_query: QueryString | list + parent_query: QueryString | None + child_query: QueryString | None + has_text_query: bool + + @dataclass class ApiPositionMapping(BasePositionMapping): position_type_dict: defaultdict[int, list[str]] = field( diff --git a/cl/lib/utils.py b/cl/lib/utils.py index 172298c808..047389acd6 100644 --- a/cl/lib/utils.py +++ b/cl/lib/utils.py @@ -249,6 +249,8 @@ def cleanup_main_query(query_string: str) -> str: """ inside_a_phrase = False cleaned_items = [] + # Replace smart quotes with standard double quotes for consistency. + query_string = re.sub(r"[“”]", '"', query_string) for item in re.split(r'([^a-zA-Z0-9_\-^~":]+)', query_string): if not item: continue @@ -330,8 +332,8 @@ def check_unbalanced_quotes(query: str) -> bool: :param query: The input query string :return: True if the query contains unbalanced quotes. Otherwise False """ - quotes_count = query.count('"') - return quotes_count % 2 != 0 + all_quotes = re.findall(r"[“”\"]", query) + return len(all_quotes) % 2 != 0 def remove_last_symbol_occurrence( @@ -382,6 +384,8 @@ def sanitize_unbalanced_quotes(query: str) -> str: :param query: The input query string :return: The sanitized query string, after removing unbalanced quotes. """ + # Replace smart quotes with standard double quotes for consistency. + query = re.sub(r"[“”]", '"', query) quotes_count = query.count('"') while quotes_count % 2 != 0: query, quotes_count = remove_last_symbol_occurrence( diff --git a/cl/opinion_page/static/js/pay_and_pray.js b/cl/opinion_page/static/js/pay_and_pray.js deleted file mode 100644 index aeab8195a2..0000000000 --- a/cl/opinion_page/static/js/pay_and_pray.js +++ /dev/null @@ -1,65 +0,0 @@ -function updatePrayerButton(button) { - // Get the document ID and prayer counter element from the button. - let documentId = button.dataset.documentId; - let prayerCounterSpan = document.querySelector(`#counter_${documentId}`); - - // Get the current prayer count. - let prayerCount = parseInt(prayerCounterSpan.innerText, 10); - - // Update the button's class and prayer count based on its current state. - if (button.classList.contains('btn-primary')) { - // If the button is primary (already prayed), change it to default and - // decrement the count. - button.classList.add('btn-default'); - button.classList.remove('btn-primary'); - prayerCount--; - } else { - // If the button is default (not yet prayed), change it to primary and - // increment the count. - button.classList.remove('btn-default'); - button.classList.add('btn-primary'); - prayerCount++; - } - // Update the prayer counter display. - prayerCounterSpan.innerText = prayerCount; -} - -document.addEventListener('htmx:beforeRequest', function (event) { - // Before sending the request, update the button's appearance and counter to - // provide instant feedback. - let form = event.detail.elt; - let button = form.querySelector('button'); - updatePrayerButton(button); -}); - -document.addEventListener('htmx:afterRequest', function (event) { - // If the request was successful, don't update the button as it will be - // updated by another HTMX event. - if (event.detail.successful) return; - - // If there was an error, revert the changes made to the button and counter. - let form = event.detail.elt; - let button = form.querySelector('button'); - updatePrayerButton(button); -}); - -document.addEventListener('htmx:oobBeforeSwap', function (event) { - // Before swapping the new content, update the prayer counter in the incoming - // fragment to avoid unnecessary server calculations. - let form = event.detail.elt; - let button = form.querySelector('button'); - // If the daily limit tooltip is present in the fragment, it means the user - // has reached their limit. Therefore, we should revert any changes made to - // the prayer button. - if (event.detail.fragment.querySelector('#daily_limit_tooltip')) { - updatePrayerButton(button); - } - let documentId = button.dataset.documentId; - let prayerCounterSpan = document.querySelector(`#counter_${documentId}`); - let prayerCount = parseInt(prayerCounterSpan.innerText, 10); - event.detail.fragment.getElementById(`counter_${documentId}`).innerText = prayerCount; -}); - -document.addEventListener('htmx:oobAfterSwap', function (event) { - $('[data-toggle="tooltip"]').tooltip(); -}); diff --git a/cl/opinion_page/static/js/pray_and_pay.js b/cl/opinion_page/static/js/pray_and_pay.js new file mode 100644 index 0000000000..c75d605a72 --- /dev/null +++ b/cl/opinion_page/static/js/pray_and_pay.js @@ -0,0 +1,81 @@ +function updatePrayerButton(button, lock = false) { + // Get the document ID. + let documentId = button.dataset.documentId; + // Get all the forms on the page with the same documentId + let prayerForms = document.querySelectorAll(`#pray_${documentId}`); + let prayerFormsArray = [...prayerForms]; + prayerFormsArray.forEach((form) => { + let prayerCounterSpan = form.querySelector(`#counter_${documentId}`); + let prayerButton = form.querySelector('button'); + + // Get the current prayer count. + let prayerCount = parseInt(prayerCounterSpan.innerText, 10); + + // Update the button's class and prayer count based on its current state. + if (prayerButton.classList.contains('btn-primary')) { + // If the button is primary (already prayed), change it to default and + // decrement the count. + prayerButton.classList.add('btn-default'); + prayerButton.classList.remove('btn-primary'); + prayerCount--; + } else { + // If the button is default (not yet prayed), change it to primary and + // increment the count. + prayerButton.classList.remove('btn-default'); + prayerButton.classList.add('btn-primary'); + prayerCount++; + } + // Update the prayer counter display. + prayerCounterSpan.innerText = prayerCount; + + if (lock) { + prayerButton.classList.add('locked'); + } else { + if (prayerButton.classList.contains('locked')) button.classList.remove('locked'); + } + }); +} + +document.addEventListener('htmx:beforeRequest', function (event) { + // Before sending the request, update the button's appearance and counter to + // provide instant feedback. + let form = event.detail.elt; + let button = form.querySelector('button'); + updatePrayerButton(button); +}); + +document.addEventListener('htmx:afterRequest', function (event) { + // If the request was successful, don't update the button as it will be + // updated by another HTMX event. + if (event.detail.successful) return; + console.log(event.detail.successful); + // If there was an error, revert the changes made to the button and counter. + let form = event.detail.elt; + let button = form.querySelector('button'); + updatePrayerButton(button); +}); + +document.addEventListener('htmx:oobBeforeSwap', function (event) { + // Before swapping the new content, update the prayer counter in the incoming + // fragment to avoid unnecessary server calculations. + let form = event.detail.elt; + let button = form.querySelector('button'); + // If the daily limit tooltip is present in the fragment, it means the user + // has reached their limit. Therefore, we should revert any changes made to + // the prayer button. + // To prevent redundant prayer button updates on a single page with multiple + // prayer buttons, we check if the current element has the "locked" class. + // This indicates that the prayer button has already been processed in a + // previous update cycle. + if (event.detail.fragment.querySelector('#daily_limit_tooltip') && !button.classList.contains('locked')) { + updatePrayerButton(button, true); + } + let documentId = button.dataset.documentId; + let prayerCounterSpan = document.querySelector(`#counter_${documentId}`); + let prayerCount = parseInt(prayerCounterSpan.innerText, 10); + event.detail.fragment.getElementById(`counter_${documentId}`).innerText = prayerCount; +}); + +document.addEventListener('htmx:oobAfterSwap', function (event) { + $('[data-toggle="tooltip"]').tooltip(); +}); diff --git a/cl/opinion_page/templates/docket_tabs.html b/cl/opinion_page/templates/docket_tabs.html index 474074bd21..b011393786 100644 --- a/cl/opinion_page/templates/docket_tabs.html +++ b/cl/opinion_page/templates/docket_tabs.html @@ -56,7 +56,7 @@ {% flag "pray-and-pay" %} - + {% endflag %} {% endblock %} diff --git a/cl/opinion_page/templates/includes/authorities_list.html b/cl/opinion_page/templates/includes/authorities_list.html index 279cd34df2..ed9fa3a942 100644 --- a/cl/opinion_page/templates/includes/authorities_list.html +++ b/cl/opinion_page/templates/includes/authorities_list.html @@ -3,7 +3,14 @@
      {% for authority in authorities %}
    • - {{ authority.depth }} reference{{ authority.depth|pluralize }} to + {% if docket %} + + {% endif %} + {{ authority.depth }} reference{{ authority.depth|pluralize }} + {% if docket %} + + {% endif %} + to {{ authority.cited_opinion.cluster.caption|safe|v_wrapper }} diff --git a/cl/opinion_page/templates/includes/de_list.html b/cl/opinion_page/templates/includes/de_list.html index 5ab5f72e5c..6f96ac8f7b 100644 --- a/cl/opinion_page/templates/includes/de_list.html +++ b/cl/opinion_page/templates/includes/de_list.html @@ -165,7 +165,7 @@ {% if not request.COOKIES.buy_on_pacer_modal and not request.COOKIES.recap_install_plea %} class="open_buy_pacer_modal btn btn-default btn-xs" data-toggle="modal" data-target="#modal-buy-pacer" - {% else%} + {% else %} class="btn btn-default btn-xs" {% endif %} target="_blank" diff --git a/cl/opinion_page/templates/includes/pray_and_pay_htmx/pray_button.html b/cl/opinion_page/templates/includes/pray_and_pay_htmx/pray_button.html index 6b8c526a39..00f70fbe4d 100644 --- a/cl/opinion_page/templates/includes/pray_and_pay_htmx/pray_button.html +++ b/cl/opinion_page/templates/includes/pray_and_pay_htmx/pray_button.html @@ -1,8 +1,9 @@ {% if request.user.is_authenticated %}
      + hx-swap="none" class="flex" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' {% if should_swap %}hx-swap-oob="true"{% endif %}> + {% if daily_limit_reached %}   - + {% endif %} diff --git a/cl/opinion_page/templates/includes/redirect_to_pacer_modal.html b/cl/opinion_page/templates/includes/redirect_to_pacer_modal.html index 02e45971cc..ee9e756603 100644 --- a/cl/opinion_page/templates/includes/redirect_to_pacer_modal.html +++ b/cl/opinion_page/templates/includes/redirect_to_pacer_modal.html @@ -1,4 +1,6 @@ {% load humanize %} +{% load waffle_tags %} + diff --git a/cl/opinion_page/templates/opinion.html b/cl/opinion_page/templates/opinion.html index a0c4c797c7..ab887b494b 100644 --- a/cl/opinion_page/templates/opinion.html +++ b/cl/opinion_page/templates/opinion.html @@ -48,7 +48,7 @@

      Admin

      class="btn btn-primary btn-xs">Cluster {% endif %} {% if perms.search.change_opinion %} - {% for sub_opinion in cluster.sub_opinions.all|dictsort:"type" %} + {% for sub_opinion in cluster.sub_opinions.all %} {{ sub_opinion.get_type_display|cut:"Opinion" }} opinion {% endfor %} @@ -359,7 +359,7 @@

      {{ cluster.docket.court }}

      Download Original