diff --git a/.env.example b/.env.example index 7d111c1b3f..25cd8994a9 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,6 @@ IA_ACCESS_KEY="" IA_SECRET_KEY="" FTM_KEY="" + +# CL API key for cloning data +CL_API_KEY="" diff --git a/cl/api/urls.py b/cl/api/urls.py index cf600790d9..e407d4b94a 100644 --- a/cl/api/urls.py +++ b/cl/api/urls.py @@ -96,6 +96,9 @@ r"docket-tags", favorite_views.DocketTagViewSet, basename="DocketTag" ) +# Prayers +router.register(r"prayers", favorite_views.PrayerViewSet, basename="prayer") + # Visualizations router.register( r"visualizations/json", viz_views.JSONViewSet, basename="jsonversion" diff --git a/cl/assets/static-global/css/override.css b/cl/assets/static-global/css/override.css index 7a27e9f08f..3693518187 100644 --- a/cl/assets/static-global/css/override.css +++ b/cl/assets/static-global/css/override.css @@ -1723,3 +1723,11 @@ rect.series-segment { opacity 150ms 150ms ease-in; transform: translate3d(0, 0, 0); } + +.prayer-button[data-gap-size="small"] { + margin-left: 8px; +} + +.prayer-button[data-gap-size="large"]{ + margin-left: 44px; +} diff --git a/cl/assets/templates/base.html b/cl/assets/templates/base.html index 33181f52e1..be91672e34 100644 --- a/cl/assets/templates/base.html +++ b/cl/assets/templates/base.html @@ -231,17 +231,22 @@

You did not supply the "private" variable to your template.
  • Install RECAP
  • + {% flag "pray-and-pay" %} +
  • + Pray and Pay Project +
  • + {% endflag %}
  • - Get Case Alerts + Get Case Alerts
  • - About this Collection + About this Collection
  • - Bulk Data Service + Bulk Data Service
  • - API + API
  • diff --git a/cl/favorites/api_serializers.py b/cl/favorites/api_serializers.py index 7f0ed014df..a12bb4c101 100644 --- a/cl/favorites/api_serializers.py +++ b/cl/favorites/api_serializers.py @@ -1,8 +1,12 @@ +from asgiref.sync import async_to_sync +from django.conf import settings from drf_dynamic_fields import DynamicFieldsMixin from rest_framework import serializers +from rest_framework.exceptions import ValidationError from rest_framework.serializers import ModelSerializer -from cl.favorites.models import DocketTag, UserTag +from cl.favorites.models import DocketTag, Prayer, UserTag +from cl.favorites.utils import prayer_eligible from cl.search.models import Docket @@ -37,3 +41,34 @@ class DocketTagSerializer(DynamicFieldsMixin, ModelSerializer): class Meta: model = DocketTag fields = "__all__" + + +class PrayerSerializer(DynamicFieldsMixin, serializers.ModelSerializer): + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + class Meta: + model = Prayer + fields = "__all__" + read_only_fields = ( + "date_created", + "user", + ) + + def validate(self, data): + user = self.context["request"].user + recap_document = data.get("recap_document") + + # Check if a Prayer for the same user and recap_document already exists + if Prayer.objects.filter( + user=user, recap_document=recap_document + ).exists(): + raise ValidationError( + "A prayer for this recap document already exists." + ) + + # Check if the user is eligible to create a new prayer + if not async_to_sync(prayer_eligible)(user): + raise ValidationError( + f"You have reached the maximum number of prayers ({settings.ALLOWED_PRAYER_COUNT}) allowed in the last 24 hours." + ) + return data diff --git a/cl/favorites/api_views.py b/cl/favorites/api_views.py index f53d5c4fb1..981c1d3ebb 100644 --- a/cl/favorites/api_views.py +++ b/cl/favorites/api_views.py @@ -4,10 +4,15 @@ from rest_framework.viewsets import ModelViewSet from cl.api.pagination import MediumAdjustablePagination +from cl.api.utils import LoggingMixin from cl.favorites.api_permissions import IsTagOwner -from cl.favorites.api_serializers import DocketTagSerializer, UserTagSerializer -from cl.favorites.filters import DocketTagFilter, UserTagFilter -from cl.favorites.models import DocketTag, UserTag +from cl.favorites.api_serializers import ( + DocketTagSerializer, + PrayerSerializer, + UserTagSerializer, +) +from cl.favorites.filters import DocketTagFilter, PrayerFilter, UserTagFilter +from cl.favorites.models import DocketTag, Prayer, UserTag class UserTagViewSet(ModelViewSet): @@ -51,3 +56,29 @@ def get_queryset(self): return DocketTag.objects.filter( Q(tag__user=self.request.user) | Q(tag__published=True) ) + + +class PrayerViewSet(LoggingMixin, ModelViewSet): + """A ModelViewset to handle CRUD operations for Prayer.""" + + permission_classes = [IsAuthenticated] + serializer_class = PrayerSerializer + pagination_class = MediumAdjustablePagination + filterset_class = PrayerFilter + ordering_fields = ("date_created",) + # Default cursor ordering key + ordering = "-date_created" + # Additional cursor ordering fields + cursor_ordering_fields = ["date_created"] + # Only allow these methods. Restricting PUT and PATCH. + http_method_names = ["get", "post", "delete", "head", "options"] + + def get_queryset(self): + """ + Return a list of all the open prayers + for the currently authenticated user. + """ + user = self.request.user + return Prayer.objects.filter( + user=user, status=Prayer.WAITING + ).order_by("-date_created") diff --git a/cl/favorites/filters.py b/cl/favorites/filters.py index e0e2e0e724..cbd3c24a1f 100644 --- a/cl/favorites/filters.py +++ b/cl/favorites/filters.py @@ -1,7 +1,7 @@ import rest_framework_filters as filters from cl.api.utils import BASIC_TEXT_LOOKUPS, BOOLEAN_LOOKUPS, NoEmptyFilterSet -from cl.favorites.models import DocketTag, UserTag +from cl.favorites.models import DocketTag, Prayer, UserTag class UserTagFilter(NoEmptyFilterSet): @@ -21,3 +21,19 @@ class DocketTagFilter(NoEmptyFilterSet): class Meta: model = DocketTag fields = {"id": ["exact"], "docket": ["exact"]} + + +class PrayerFilter(NoEmptyFilterSet): + date_created = filters.DateFromToRangeFilter( + field_name="date_created", + help_text="Filter prayers by a date range (e.g., ?date_created_after=2024-09-01&date_created_before=2024-12-31).", + ) + + class Meta: + model = Prayer + fields = { + "date_created": ["exact", "range"], + "user": ["exact"], + "recap_document": ["exact"], + "status": ["exact", "in"], + } diff --git a/cl/favorites/templates/top_prayers.html b/cl/favorites/templates/top_prayers.html index a12a5edba3..980d6a76e8 100644 --- a/cl/favorites/templates/top_prayers.html +++ b/cl/favorites/templates/top_prayers.html @@ -2,15 +2,17 @@ {% load extras %} {% load text_filters %} {% load static %} - +{% load pacer %} {% block title %}RECAP Requests – CourtListener.com{% endblock %} {% block og_title %}RECAP Requests – CourtListener.com{% endblock %} -{% block description %}Lorem Ipsum on CourtListener.{% endblock %} -{% block og_description %}Lorem Ipsum on CourtListener.{% endblock %} +{% block description %}RECAP Requests on CourtListener.{% endblock %} +{% block og_description %}RECAP Requests on CourtListener.{% endblock %} + {% block content %} +
    @@ -21,6 +23,7 @@ + @@ -31,6 +34,15 @@ + {% empty %} @@ -42,3 +54,9 @@ {% endblock %} + +{% block footer-scripts %} + + {% include "includes/buy_pacer_modal.html" %} +{% endblock %} diff --git a/cl/favorites/tests.py b/cl/favorites/tests.py index e60ff89fe0..e2aa34ab56 100644 --- a/cl/favorites/tests.py +++ b/cl/favorites/tests.py @@ -10,6 +10,7 @@ from django.template.defaultfilters import date as template_date from django.test import AsyncClient, override_settings from django.urls import reverse +from django.utils import timezone from django.utils.timezone import make_naive, now from selenium.webdriver.common.by import By from timeout_decorator import timeout_decorator @@ -17,7 +18,14 @@ from cl.custom_filters.templatetags.pacer import price from cl.favorites.factories import NoteFactory, PrayerFactory from cl.favorites.models import DocketTag, Note, Prayer, UserTag -from cl.favorites.utils import create_prayer, get_top_prayers, prayer_eligible +from cl.favorites.utils import ( + create_prayer, + delete_prayer, + get_existing_prayers_in_bulk, + get_prayer_counts_in_bulk, + get_top_prayers, + prayer_eligible, +) from cl.lib.test_helpers import AudioTestCase, SimpleUserDataMixin from cl.search.factories import RECAPDocumentFactory from cl.search.views import get_homepage_stats @@ -671,9 +679,15 @@ def setUpTestData(cls) -> None: is_available=False, ) + cls.rd_6 = RECAPDocumentFactory( + pacer_doc_id="98763426", + document_number="6", + is_available=False, + ) + @override_settings(ALLOWED_PRAYER_COUNT=2) async def test_prayer_eligible(self) -> None: - """Does the prayer_eligible method works properly?""" + """Does the prayer_eligible method work properly?""" current_time = now() with time_machine.travel(current_time, tick=False): @@ -713,7 +727,7 @@ async def test_prayer_eligible(self) -> None: self.assertTrue(user_is_eligible) async def test_create_prayer(self) -> None: - """Does the create_prayer method works properly?""" + """Does the create_prayer method work properly?""" # Prayer is not created if the document is already available. prayer_created = await create_prayer(self.user, self.rd_1) @@ -727,8 +741,29 @@ async def test_create_prayer(self) -> None: same_prayer_created = await create_prayer(self.user, self.rd_2) self.assertIsNone(same_prayer_created) + async def test_delete_prayer(self) -> None: + """Does the delete_prayer method work properly?""" + + # Prayer is added, then deleted successfully + prayer_created = await create_prayer(self.user, self.rd_2) + prayer_deleted = await delete_prayer(self.user, self.rd_2) + self.assertTrue(prayer_deleted) + + # Prayer is created, then document is made available to check that a user can't delete a prayer that has been granted + prayer_created = await create_prayer(self.user, self.rd_6) + self.rd_6.is_available = True + await sync_to_async(self.rd_6.save)() + prayer_deleted = await delete_prayer(self.user, self.rd_6) + self.assertFalse(prayer_deleted) + + # Ensure that a user cannot delete the same prayer twice + prayer_created = await create_prayer(self.user, self.rd_2) + prayer_deleted = await delete_prayer(self.user, self.rd_2) + prayer_deleted = await delete_prayer(self.user, self.rd_2) + self.assertFalse(prayer_deleted) + async def test_get_top_prayers_by_number(self) -> None: - """Does the get_top_prayers method works properly?""" + """Does the get_top_prayers method work properly?""" # Test top documents based on prayers count. current_time = now() @@ -756,7 +791,7 @@ async def test_get_top_prayers_by_number(self) -> None: ) async def test_get_top_prayers_by_age(self) -> None: - """Does the get_top_prayers method works properly?""" + """Does the get_top_prayers method work properly?""" # Test top documents based on prayer age. current_time = now() @@ -787,7 +822,7 @@ async def test_get_top_prayers_by_age(self) -> None: ) async def test_get_top_prayers_by_number_and_age(self) -> None: - """Does the get_top_prayers method works properly?""" + """Does the get_top_prayers method work properly?""" # Create prayers with different counts and ages current_time = now() @@ -887,6 +922,26 @@ async def test_prayers_integration(self) -> None: actual_top_prayers, expected_top_prayers, msg="Wrong top_prayers." ) + # Assert prayer_counts dict. + prayers_counts_dict = await get_prayer_counts_in_bulk( + [rd_6, self.rd_4] + ) + self.assertEqual({rd_6.pk: 2, self.rd_4.pk: 1}, prayers_counts_dict) + + # Assert existing_prayers dict for user + existing_prayers_dict = await get_existing_prayers_in_bulk( + self.user, [rd_6, self.rd_4] + ) + self.assertEqual( + {rd_6.pk: True, self.rd_4.pk: True}, existing_prayers_dict + ) + + # Assert existing_prayers dict for user_2 + existing_prayers_dict = await get_existing_prayers_in_bulk( + self.user_2, [rd_6, self.rd_4] + ) + self.assertEqual({rd_6.pk: True}, existing_prayers_dict) + # rd_6 is granted. rd_6.is_available = True await rd_6.asave() @@ -963,3 +1018,189 @@ async def test_prayers_integration(self) -> None: self.assertEqual( top_prayers[0], self.rd_4, msg="The top prayer didn't match." ) + + +class PrayerAPITests(APITestCase): + """Check that Prayer API operations work as expected.""" + + @classmethod + def setUpTestData(cls) -> None: + cls.user_1 = UserFactory() + cls.user_2 = UserFactory() + + cls.rd_1 = RECAPDocumentFactory( + pacer_doc_id="98763421", + document_number="1", + is_available=True, + ) + cls.rd_2 = RECAPDocumentFactory( + pacer_doc_id="98763422", + document_number="2", + is_available=False, + ) + cls.rd_3 = RECAPDocumentFactory( + pacer_doc_id="98763423", + document_number="3", + is_available=False, + ) + + def setUp(self) -> None: + self.prayer_path = reverse("prayer-list", kwargs={"version": "v4"}) + self.client = make_client(self.user_1.pk) + self.client_2 = make_client(self.user_2.pk) + + async def make_a_prayer( + self, + client, + recap_doc_id, + ): + data = { + "recap_document": recap_doc_id, + } + return await client.post(self.prayer_path, data, format="json") + + async def test_make_a_prayer(self) -> None: + """Can we make a prayer?""" + + prayer = Prayer.objects.all() + response = await self.make_a_prayer(self.client, self.rd_1.pk) + prayer_first = await prayer.afirst() + self.assertIsNotNone(prayer_first) + self.assertEqual(await prayer.acount(), 1) + self.assertEqual(response.status_code, HTTPStatus.CREATED) + + async def test_duplicate_prayer_fails(self) -> None: + """Ensure a user can't create multiple prayers for the same document + and user. + """ + await self.make_a_prayer(self.client, self.rd_1.pk) + response = await self.make_a_prayer(self.client, self.rd_1.pk) + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + + async def test_list_users_prayers(self) -> None: + """Can we list user's own prayers?""" + + # Make two prayers for user_1 + await self.make_a_prayer(self.client, self.rd_1.pk) + await self.make_a_prayer(self.client, self.rd_3.id) + + # Make one prayer for user_2 + await self.make_a_prayer(self.client_2, self.rd_1.pk) + + # Get the prayers for user_1, should be 2 + response = await self.client.get(self.prayer_path) + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(len(response.json()["results"]), 2) + + # Get the prayers for user_2, should be 1 + response_2 = await self.client_2.get(self.prayer_path) + self.assertEqual(response_2.status_code, HTTPStatus.OK) + self.assertEqual(len(response_2.json()["results"]), 1) + + async def test_delete_prayer(self) -> None: + """Can we delete a prayer? + Avoid users from deleting other users' prayers. + """ + + # Make two prayers for user_1 + prayer_1 = await self.make_a_prayer(self.client, self.rd_1.pk) + prayer_2 = await self.make_a_prayer(self.client, self.rd_3.id) + + prayer = Prayer.objects.all() + self.assertEqual(await prayer.acount(), 2) + + prayer_1_path_detail = reverse( + "prayer-detail", + kwargs={"pk": prayer_1.json()["id"], "version": "v4"}, + ) + + # Delete the prayer for user_1 + response = await self.client.delete(prayer_1_path_detail) + self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT) + self.assertEqual(await prayer.acount(), 1) + + prayer_2_path_detail = reverse( + "prayer-detail", + kwargs={"pk": prayer_2.json()["id"], "version": "v3"}, + ) + + # user_2 tries to delete a user_1 prayer, it should fail + response = await self.client_2.delete(prayer_2_path_detail) + self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) + self.assertEqual(await prayer.acount(), 1) + + async def test_prayer_detail(self) -> None: + """Can we get the detail for a prayer? Avoid users from getting other + users prayers. + """ + + # Make one prayer for user_1 + prayer_1 = await self.make_a_prayer(self.client, self.rd_1.pk) + prayer = Prayer.objects.all() + self.assertEqual(await prayer.acount(), 1) + prayer_1_path_detail = reverse( + "prayer-detail", + kwargs={"pk": prayer_1.json()["id"], "version": "v3"}, + ) + + # Get the prayer detail for user_1 + response = await self.client.get(prayer_1_path_detail) + self.assertEqual(response.status_code, HTTPStatus.OK) + + # user_2 tries to get user_1 prayer, it should fail + response = await self.client_2.get(prayer_1_path_detail) + self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND) + + async def test_prayer_update_fails(self) -> None: + """PUT AND PATCH methods are restricted.""" + + # Make one prayer for user_1 + prayer_1 = await self.make_a_prayer(self.client, self.rd_1.pk) + prayer_1_path_detail = reverse( + "prayer-detail", + kwargs={"pk": prayer_1.json()["id"], "version": "v3"}, + ) + # PATCH not allowed + data = {"status": 2} + response = await self.client.patch( + prayer_1_path_detail, data, format="json" + ) + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + # PUT not allowed + data = {"status": 2, "recap_document": self.rd_1.pk} + response = await self.client.put( + prayer_1_path_detail, data, format="json" + ) + self.assertEqual(response.status_code, HTTPStatus.METHOD_NOT_ALLOWED) + + @override_settings(ALLOWED_PRAYER_COUNT=2) + async def test_prayer_creation_eligibility(self): + """Test the prayer creation eligibility and limits in the API.""" + current_time = timezone.now() + prayers = Prayer.objects.all() + + with time_machine.travel(current_time, tick=False): + # First prayer succeed + response = await self.make_a_prayer(self.client, self.rd_1.pk) + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertEqual(await prayers.acount(), 1) + + # Second prayer succeed + response = await self.make_a_prayer(self.client, self.rd_2.pk) + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertEqual(await prayers.acount(), 2) + + # Third prayer fails due to limit + response = await self.make_a_prayer(self.client, self.rd_3.pk) + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertIn("maximum number of prayers", str(response.data)) + self.assertEqual(await prayers.acount(), 2) + + # After more than 24 hours the user is eligible to create more prays. + with time_machine.travel( + current_time + timedelta(hours=25), tick=False + ): + response = await self.make_a_prayer(self.client, self.rd_3.pk) + self.assertEqual(response.status_code, HTTPStatus.CREATED) + self.assertEqual(await prayers.acount(), 3) diff --git a/cl/favorites/urls.py b/cl/favorites/urls.py index 65ef65544d..a97cc9cc8e 100644 --- a/cl/favorites/urls.py +++ b/cl/favorites/urls.py @@ -1,7 +1,9 @@ from django.urls import path from cl.favorites.views import ( + create_prayer_view, delete_note, + delete_prayer_view, open_prayers, save_or_update_note, view_tag, @@ -24,4 +26,14 @@ ), path("tags//", view_tags, name="tag_list"), path("prayers/top/", open_prayers, name="top_prayers"), + path( + "prayer/create//", + create_prayer_view, + name="create_prayer", + ), + path( + "prayer/delete//", + delete_prayer_view, + name="delete_prayer", + ), ] diff --git a/cl/favorites/utils.py b/cl/favorites/utils.py index dae437abad..bbe40309c3 100644 --- a/cl/favorites/utils.py +++ b/cl/favorites/utils.py @@ -46,6 +46,53 @@ async def create_prayer( return None +async def delete_prayer(user: User, recap_document: RECAPDocument) -> bool: + deleted, _ = await Prayer.objects.filter( + user=user, recap_document=recap_document, status=Prayer.WAITING + ).adelete() + + return deleted > 0 + + +async def get_prayer_counts_in_bulk( + recap_documents: list[RECAPDocument], +) -> dict[str, int]: + """Retrieve the count of prayers with a status of "WAITING" for a list of recap documents. + + :param recap_documents: A list of RECAPDocument instances to filter prayers. + :return: A dictionary where keys are RECAPDocument IDs and values are the + count of "WAITING" prayers for each document. + """ + + prayer_counts = ( + Prayer.objects.filter( + recap_document__in=recap_documents, status=Prayer.WAITING + ) + .values("recap_document") + .annotate(count=Count("id")) + ) + return { + prayer_count["recap_document"]: prayer_count["count"] + async for prayer_count in prayer_counts + } + + +async def get_existing_prayers_in_bulk( + user: User, recap_documents: list[RECAPDocument] +) -> dict[int, bool]: + """Check if prayers exist for a user and a list of recap documents. + + :param user: The user for whom to check prayer existence. + :param recap_documents: A list of RECAPDocument instances to check prayers. + :return: A dictionary where keys are RECAPDocument IDs and values are True + if a prayer exists for the user and RD. + """ + existing_prayers = Prayer.objects.filter( + user=user, recap_document__in=recap_documents + ).values_list("recap_document_id", flat=True) + return {rd_id: True async for rd_id in existing_prayers} + + async def get_top_prayers() -> list[RECAPDocument]: # Calculate the age of each prayer prayer_age = ExpressionWrapper( @@ -69,6 +116,7 @@ async def get_top_prayers() -> list[RECAPDocument]: "document_number", "attachment_number", "pacer_doc_id", + "page_count", "description", "docket_entry__docket_id", "docket_entry__docket__slug", @@ -95,6 +143,7 @@ async def get_top_prayers() -> list[RECAPDocument]: ) .order_by("-geometric_mean")[:50] ) + return [doc async for doc in documents.aiterator()] @@ -148,3 +197,33 @@ def send_prayer_emails(instance: RECAPDocument) -> None: messages.append(msg) connection = get_connection() connection.send_messages(messages) + + +async def get_user_prayer_history(user: User) -> tuple[int, float]: + filtered_list = Prayer.objects.filter(user=user, status=Prayer.GRANTED) + + count = await filtered_list.acount() + + total_cost = 0 + async for prayer in filtered_list: + total_cost += float(price(prayer.recap_document)) + + return count, total_cost + + +async def get_lifetime_prayer_stats() -> tuple[int, int, float]: + + filtered_list = Prayer.objects.filter(status=Prayer.GRANTED) + + count = await filtered_list.acount() + + total_cost = 0 + distinct_documents = set() + + async for prayer in filtered_list: + distinct_documents.add(prayer.recap_document) + total_cost += float(await price(prayer.recap_document)) + + num_distinct_purchases = len(distinct_documents) + + return count, num_distinct_purchases, total_cost diff --git a/cl/favorites/views.py b/cl/favorites/views.py index 1cd5b8ba15..0b05a6eb72 100644 --- a/cl/favorites/views.py +++ b/cl/favorites/views.py @@ -17,10 +17,16 @@ from cl.favorites.forms import NoteForm from cl.favorites.models import DocketTag, Note, UserTag -from cl.favorites.utils import get_top_prayers +from cl.favorites.utils import ( + create_prayer, + delete_prayer, + get_top_prayers, + prayer_eligible, +) from cl.lib.decorators import cache_page_ignore_params from cl.lib.http import is_ajax from cl.lib.view_utils import increment_view_count +from cl.search.models import RECAPDocument async def get_note(request: HttpRequest) -> HttpResponse: @@ -182,11 +188,73 @@ async def open_prayers(request: HttpRequest) -> HttpResponse: """Show the user top open prayer requests.""" top_prayers = await get_top_prayers() - return TemplateResponse( - request, - "top_prayers.html", - { - "top_prayers": top_prayers, - "private": True, # temporary to prevent Google indexing - }, - ) + + context = { + "top_prayers": top_prayers, + "private": False, + } + + return TemplateResponse(request, "top_prayers.html", context) + + +@login_required +async def create_prayer_view( + request: HttpRequest, recap_document: int +) -> HttpResponse: + user = request.user + is_htmx_request = request.META.get("HTTP_HX_REQUEST", False) + if not await prayer_eligible(request.user): + if is_htmx_request: + return TemplateResponse( + request, + "includes/pray_and_pay_htmx/pray_button.html", + { + "prayer_exists": False, + "document_id": recap_document, + "count": 0, + "daily_limit_reached": True, + }, + ) + return HttpResponseServerError( + "User have reached your daily request limit" + ) + + recap_document = await RECAPDocument.objects.aget(id=recap_document) + + # Call the create_prayer async function + await create_prayer(user, recap_document) + if is_htmx_request: + return TemplateResponse( + request, + "includes/pray_and_pay_htmx/pray_button.html", + { + "prayer_exists": True, + "document_id": recap_document.pk, + "count": 0, + "daily_limit_reached": False, + }, + ) + return HttpResponse("It worked.") + + +@login_required +async def delete_prayer_view( + request: HttpRequest, recap_document: int +) -> HttpResponse: + user = request.user + recap_document = await RECAPDocument.objects.aget(id=recap_document) + + # Call the delete_prayer async function + await delete_prayer(user, recap_document) + + if request.META.get("HTTP_HX_REQUEST"): + return TemplateResponse( + request, + "includes/pray_and_pay_htmx/pray_button.html", + { + "prayer_exists": False, + "document_id": recap_document.pk, + "count": 0, + }, + ) + return HttpResponse("It worked.") diff --git a/cl/opinion_page/static/js/pay_and_pray.js b/cl/opinion_page/static/js/pay_and_pray.js new file mode 100644 index 0000000000..aeab8195a2 --- /dev/null +++ b/cl/opinion_page/static/js/pay_and_pray.js @@ -0,0 +1,65 @@ +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/templates/docket.html b/cl/opinion_page/templates/docket.html index be857c1da5..084bbba8c0 100644 --- a/cl/opinion_page/templates/docket.html +++ b/cl/opinion_page/templates/docket.html @@ -22,6 +22,16 @@ href="{% url "docket_feed" docket.pk %}" /> {% endblock %} +{% block footer-scripts %} + {% if DEBUG %} + + + {% else %} + + {% endif %} + +{% endblock %} + {% block nav-de %}active{% endblock %} {% block tab-content %} {% if docket_entries.paginator.count %} diff --git a/cl/opinion_page/templates/includes/de_list.html b/cl/opinion_page/templates/includes/de_list.html index 0c0776ef9f..5ab5f72e5c 100644 --- a/cl/opinion_page/templates/includes/de_list.html +++ b/cl/opinion_page/templates/includes/de_list.html @@ -1,6 +1,6 @@ {% load pacer %} {% load tz %} - +{% load waffle_tags %}
    @@ -171,6 +171,11 @@ target="_blank" rel="nofollow">Buy on PACER {% if rd.page_count %}(${{ rd|price }}){% endif %} + {% flag "pray-and-pay" %} +
    + {% include "includes/pray_and_pay_htmx/pray_button.html" with prayer_exists=rd.prayer_exists document_id=rd.id count=rd.prayer_count%} +
    + {% endflag %} {% endif %} {% endif %} {% endif %} 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 new file mode 100644 index 0000000000..6b8c526a39 --- /dev/null +++ b/cl/opinion_page/templates/includes/pray_and_pay_htmx/pray_button.html @@ -0,0 +1,27 @@ +{% if request.user.is_authenticated %} +
    + + {% if daily_limit_reached %} +   + + + + {% endif %} + +{% else %} + +{% endif %} diff --git a/cl/opinion_page/views.py b/cl/opinion_page/views.py index 678de041d7..95cbec270d 100644 --- a/cl/opinion_page/views.py +++ b/cl/opinion_page/views.py @@ -44,6 +44,11 @@ from cl.custom_filters.templatetags.text_filters import best_case_name from cl.favorites.forms import NoteForm from cl.favorites.models import Note +from cl.favorites.utils import ( + get_existing_prayers_in_bulk, + get_prayer_counts_in_bulk, + prayer_eligible, +) from cl.lib.auth import group_required from cl.lib.bot_detector import is_og_bot from cl.lib.decorators import cache_page_ignore_params @@ -390,15 +395,45 @@ def paginate_docket_entries(docket_entries, docket_page): except EmptyPage: return paginator.page(paginator.num_pages) + paginated_entries = await paginate_docket_entries(de_list, page) + + prayer_is_eligible = False + flag_for_prayers = await sync_to_async(waffle.flag_is_active)( + request, "pray-and-pay" + ) + if flag_for_prayers: + # Extract recap documents from the current page. + recap_documents = [ + rd + for entry in await sync_to_async(list)(paginated_entries) + async for rd in entry.recap_documents.all() + ] + # Get prayer counts in bulk. + prayer_counts = await get_prayer_counts_in_bulk(recap_documents) + existing_prayers = {} + + if request.user.is_authenticated: + # Check prayer existence in bulk. + existing_prayers = await get_existing_prayers_in_bulk( + request.user, recap_documents + ) + prayer_is_eligible = await prayer_eligible(request.user) + + # Merge counts and existing prayer status to RECAPDocuments. + for rd in recap_documents: + rd.prayer_count = prayer_counts.get(rd.id, 0) + rd.prayer_exists = existing_prayers.get(rd.id, False) + context.update( { "parties": await docket.parties.aexists(), # Needed to show/hide parties tab. "authorities": await docket.ahas_authorities(), - "docket_entries": await paginate_docket_entries(de_list, page), + "docket_entries": paginated_entries, "sort_order_asc": sort_order_asc, "form": form, "get_string": make_get_string(request), + "prayer_eligible": prayer_is_eligible, } ) return TemplateResponse(request, "docket.html", context)
    Document Number PACER Doc ID Document CourtBuy on Pacer
    {{ prayer.document_number }} {{ prayer.pacer_doc_id }} {{ prayer.docket_entry.docket.court_id }}Buy on PACER {% if prayer.page_count %}(${{ prayer|price }}){% endif %}