diff --git a/cfgov/cfgov/settings/base.py b/cfgov/cfgov/settings/base.py index 90ebcf74d10..1383ddbe3d4 100644 --- a/cfgov/cfgov/settings/base.py +++ b/cfgov/cfgov/settings/base.py @@ -114,6 +114,7 @@ "wagtail_draftail_anchors", "tccp", "django_filters", + "django_htmx", ) MIDDLEWARE = ( @@ -122,6 +123,7 @@ "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "core.middleware.PathBasedCsrfViewMiddleware", + "django_htmx.middleware.HtmxMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "core.middleware.ParseLinksMiddleware", @@ -630,9 +632,6 @@ ("environment is", "local"), ("environment is", "test"), ], - "TCCP_DEBUG_DETAILS": [ - ("environment is", "local"), - ], } REGULATIONS_REFERENCE_MAPPING = [ diff --git a/cfgov/paying_for_college/jinja2/paying-for-college/disclosure.html b/cfgov/paying_for_college/jinja2/paying-for-college/disclosure.html index ad7da1c4139..0fc6dcb43a1 100644 --- a/cfgov/paying_for_college/jinja2/paying-for-college/disclosure.html +++ b/cfgov/paying_for_college/jinja2/paying-for-college/disclosure.html @@ -3135,70 +3135,70 @@

-

+

+ You can adjust some of the offer amounts + in the tool using some of the strategies + outlined above. For instance, you can + try reducing the amount of federal or + private loans to see how it affects your + overall debt and you can try reducing + your out-of-school expenses. +

+

+ Changing amounts in this tool has no + effect on what financial aid is + actually being offered. If you want to + move forward with different amounts, + you will need to contact your school’s + financial aid representative to have + your financial aid package updated. +

+

+ Useful resources for new + college students +

+
+ College + Scorecard +

Compare College - Scorecard -

Compare your - school’s annual costs, - graduation rates, and post-college - earnings.

-
-

- Questions to ask - your college -

-
- FAFSA® -

Apply for federal, state, and school - financial aid.

-
-
- Student - loan guide -

Choose a student loan that’s right - for you.

-
-
- Student banking guide -

Manage your college money.

-
+ target="_blank" rel="noopener noreferrer">your + school’s annual costs, + graduation rates, and post-college + earnings.

+ +

+ Questions to ask + your college +

+
+ FAFSA® +

Apply for federal, state, and school + financial aid.

+
+
+ Student + loan guide +

Choose a student loan that’s right + for you.

+
+
+ Student banking guide +

Manage your college money.

diff --git a/cfgov/tccp/jinja2/tccp/card.html b/cfgov/tccp/jinja2/tccp/card.html index 94053448f02..40c608048cc 100644 --- a/cfgov/tccp/jinja2/tccp/card.html +++ b/cfgov/tccp/jinja2/tccp/card.html @@ -16,11 +16,12 @@ {% block content_main %}
-

{{ card.institution_name }} {{ card.product_name }}

+
{{ card.institution_name }}
+

{{ card.product_name }}

{{ data_published(card.report_date) }} -

Availability

+

Application requirements

Location
@@ -76,7 +77,7 @@

Availability

- Secured card + Down payment required?
{{ "Yes" if card.secured_card else "No" }} @@ -690,27 +691,56 @@

Rewards and perks

{% endif %}
- - {% if flag_enabled("TCCP_DEBUG_DETAILS") %} -

All fields

- - - - - - - - - - {% for field in card._meta.fields %} - - - - - {% endfor %} - -
FieldValue
{{ field.name }}{{ card.__getattribute__(field.name) }}
- {% endif %} +

Fine print

+
+
Grace period
+
+ {{ card.grace_period ~ ' days until interest begins to accrue on + purchases' if grace_period_offered else 'None, interest begins + accruing on purchases immediately' + }} +
+
Minimum finance charge
+
+ {{ '$' ~ '%.2f' | format(card.minimum_finance_charge_dollars | float) + if card.minimum_finance_charge else 'None' + }} +
+
Balance computation method
+
+ {{ card.balance_computation_method | join(', ') if 'Other' is not in + card.balance_computation_method else + card.balance_computation_method_details + }} +
+
+

Contact information

+
+ {% if card.website_for_consumer %} +
Website
+
+ {# Some issuers submitted more than one URL for a card. In all + of those instances, the URLs are separated with a space, so + we'll only turn the submitted URL into a link if it doesn't + have a space in it for now. TODO: maybe split multiple URLs + up. + #} + {% if ' ' is not in card.website_for_consumer %} + + {{- card.website_for_consumer -}} + + {% else %} + {{ card.website_for_consumer }} + {% endif %} +
+ {% endif %} + {% if card.telephone_number_for_consumers %} +
Phone
+
+ {{ card.telephone_number_for_consumers }} +
+ {% endif %} +
{% endblock content_main %} diff --git a/cfgov/tccp/jinja2/tccp/cards.html b/cfgov/tccp/jinja2/tccp/cards.html index e52faf25604..4ed45ec43b4 100644 --- a/cfgov/tccp/jinja2/tccp/cards.html +++ b/cfgov/tccp/jinja2/tccp/cards.html @@ -1,10 +1,6 @@ {% extends "v1/layouts/layout-full.html" %} -{% from 'tccp/includes/data_published.html' import data_published %} -{% from 'tccp/includes/fields.html' import apr, apr_range %} {% from 'tccp/includes/filter_form.html' import filter_form with context %} -{% import 'v1/includes/molecules/breadcrumbs.html' as breadcrumbs with context %} -{% import 'v1/includes/molecules/notification.html' as notification %} {% from 'v1/includes/organisms/expandable.html' import expandable with context %} {% block title -%} @@ -67,97 +63,13 @@

{{ situation.title }}

{% endcall %} -{%- set purchase_apr_adjectives = ["less", "average", "more"] %} -
-
- - {% if count -%} - {{- notification.render( - 'success', - true, - count ~ ' result' ~ count | pluralize() - ) -}} - {%- else -%} - {{- notification.render( - 'warning', - true, - 'There are no results for your search.' - ) -}} - {%- endif %} - -
-
Key
- - {% for adjective in purchase_apr_adjectives %} -

- - {%- for i in range(loop.index) -%} - {{ svg_icon("dollar-round") }} - {%- endfor -%} - - Pay {{ adjective ~ (" than average" if adjective != "average") }} interest - -

- {% endfor %} -
- -

- {% if stats_all.first_report_date -%} - {{ data_published(stats_all.first_report_date) }} - {%- endif %} -

- - {%- macro card_name_cell(card) -%} - -
{{ card.institution_name }}
-
{{ card.product_name }}
-
- {%- endmacro -%} - - {%- macro purchase_apr_cell(card) -%} - {%- set adjective = purchase_apr_adjectives[ - card.purchase_apr_for_tier_rating - ] -%} - - {{- apr(card.purchase_apr_for_tier) -}} - - - {%- for i in range(card.purchase_apr_for_tier_rating + 1) %} - {{ svg_icon("dollar-round") }} - {% endfor -%} - - Pay {{ adjective }} interest - - {%- endmacro -%} - - {%- set card_columns = [ - {'heading': 'Credit card'}, - {'heading': 'Purchase APR'}, - {'heading': 'Account fee'}, - {'heading': 'Balance transfer APR'}, - {'heading': 'Offers rewards'}, - ] %} - {%- set card_rows = [] %} - {%- for card in results %} - {% do card_rows.append( [ - card_name_cell(card) | safe, - purchase_apr_cell(card) | safe, - (card.periodic_fee_type | join(', ')) if card.periodic_fee_type else 'None', - apr(card.transfer_apr_for_tier) if card.transfer_apr_for_tier is not none else apr_range(card.transfer_apr_min, card.transfer_apr_max), - (card.rewards | join(', ')) if card.rewards else 'None' - ] ) %} - {% endfor %} - {%- with value = { - 'data': { - 'columns': card_columns, - 'rows': card_rows - }, - 'options': ['directory_table'] - } %} - {% include 'v1/includes/organisms/tables/base.html' %} - {% endwith %} +
+ {% include "tccp/includes/card_list.html" %}
+
{% endblock content_main %} diff --git a/cfgov/tccp/jinja2/tccp/includes/card_list.html b/cfgov/tccp/jinja2/tccp/includes/card_list.html new file mode 100644 index 00000000000..f4195595ae0 --- /dev/null +++ b/cfgov/tccp/jinja2/tccp/includes/card_list.html @@ -0,0 +1,92 @@ +{% from 'tccp/includes/data_published.html' import data_published %} +{% from 'tccp/includes/fields.html' import apr, apr_range %} +{% import 'v1/includes/molecules/breadcrumbs.html' as breadcrumbs with context %} +{% import 'v1/includes/molecules/notification.html' as notification %} + +{% if count -%} +{{- notification.render( + 'success', + true, + count ~ ' result' ~ count | pluralize() +) -}} +{%- else -%} +{{- notification.render( + 'warning', + true, + 'There are no results for your search.' +) -}} +{%- endif %} + +{%- set purchase_apr_adjectives = ["less", "average", "more"] %} + +
+
Key
+
+ {% for adjective in purchase_apr_adjectives %} +
+
+ {%- for i in range(loop.index) -%} + {{ svg_icon("dollar-round") }} + {%- endfor -%} +
+
+ Pay {{ adjective ~ (" than average" if adjective != "average") }} interest +
+
+ {% endfor %} +
+
+ +{% if stats_all.first_report_date -%} +{{ data_published(stats_all.first_report_date) }} +{%- endif %} + +{%- macro card_name_cell(card) -%} + +
{{ card.institution_name }}
+
{{ card.product_name }}
+
+{%- endmacro -%} + +{%- macro purchase_apr_cell(card) -%} + {%- set adjective = purchase_apr_adjectives[ + card.purchase_apr_for_tier_rating + ] -%} + + {{- apr(card.purchase_apr_for_tier) -}} + + + {%- for i in range(card.purchase_apr_for_tier_rating + 1) %} + {{ svg_icon("dollar-round") }} + {% endfor -%} + + Pay {{ adjective }} interest + +{%- endmacro -%} + +{%- set card_columns = [ + {'heading': 'Credit card'}, + {'heading': 'Purchase APR'}, + {'heading': 'Account fee'}, + {'heading': 'Balance transfer APR'}, + {'heading': 'Offers rewards'}, +] %} +{%- set card_rows = [] %} +{%- for card in results %} + {% do card_rows.append( [ + card_name_cell(card) | safe, + purchase_apr_cell(card) | safe, + (card.periodic_fee_type | join(', ')) if card.periodic_fee_type else 'None', + apr(card.transfer_apr_for_tier) if card.transfer_apr_for_tier is not none else apr_range(card.transfer_apr_min, card.transfer_apr_max), + (card.rewards | join(', ')) if card.rewards else 'None' + ] ) %} +{% endfor %} +{%- with value = { + 'data': { + 'columns': card_columns, + 'rows': card_rows + }, + 'options': ['directory_table'] +} %} + {% include 'v1/includes/organisms/tables/base.html' %} +{% endwith %} diff --git a/cfgov/tccp/jinja2/tccp/includes/filter_form.html b/cfgov/tccp/jinja2/tccp/includes/filter_form.html index 3fa381bd4ec..5a0f1c0c7a4 100644 --- a/cfgov/tccp/jinja2/tccp/includes/filter_form.html +++ b/cfgov/tccp/jinja2/tccp/includes/filter_form.html @@ -61,7 +61,14 @@ {%- macro filter_form(form) -%} -
+
{{ render_form_fields(form) }} diff --git a/cfgov/tccp/jinja2/tccp/landing_page.html b/cfgov/tccp/jinja2/tccp/landing_page.html index 45a3ce32430..ab4818b6ff3 100644 --- a/cfgov/tccp/jinja2/tccp/landing_page.html +++ b/cfgov/tccp/jinja2/tccp/landing_page.html @@ -28,10 +28,32 @@

How is this comparison tool different than others you may have used?

- You’ll find a larger pool of cards, likely including banks you’ve never - heard of that have great rates. We collect this data every 6 months and - never require instutitions to pay to have their results shown, allowing - you to see all of the options available to you. +

    +
  • + {{ svg_icon("credit-card-round") }} + + larger pool of cards, likely including banks you’ve never heard of with great rates + +
  • +
  • + {{ svg_icon("court-round") }} + issuers required by law to submit data to us +
  • +
  • + {{ svg_icon("disabled-round") }} + + no paid advertising + +
  • +
  • + {{ svg_icon("dollar-round") }} + + card interest is rated, to help you compare + +
  • +

diff --git a/cfgov/tccp/tests/test_views.py b/cfgov/tccp/tests/test_views.py index 742b629c121..52230461a7d 100644 --- a/cfgov/tccp/tests/test_views.py +++ b/cfgov/tccp/tests/test_views.py @@ -4,6 +4,8 @@ from django.shortcuts import reverse from django.test import RequestFactory, TestCase +from django_htmx.middleware import HtmxMiddleware + from tccp.models import CardSurveyData from tccp.views import CardListView, LandingPageView @@ -63,16 +65,24 @@ def setUpTestData(cls): _quantity=3, ) - def make_request(self, querystring=""): - view = CardListView.as_view() - request = RequestFactory().get(f"/{querystring}") + def make_request(self, querystring="", **kwargs): + view = HtmxMiddleware(CardListView.as_view()) + request = RequestFactory().get(f"/{querystring}", **kwargs) return view(request) def test_no_querystring_filters_by_good_tier(self): response = self.make_request() + self.assertContains(response, "Consumer Financial Protection Bureau") + self.assertContains(response, "There are no results for your search.") + + def test_htmx_includes_only_results(self): + response = self.make_request(**{"HTTP_HX-Request": "true"}) + self.assertNotContains( + response, "Consumer Financial Protection Bureau" + ) self.assertContains(response, "There are no results for your search.") - def test_filter_by_no_credit_score(self): + def test_filter_by_credit_score(self): response = self.make_request( "?credit_tier=Credit+score+of+720+or+greater" ) diff --git a/cfgov/tccp/views.py b/cfgov/tccp/views.py index fc243d4ba7e..8273ec4af0d 100644 --- a/cfgov/tccp/views.py +++ b/cfgov/tccp/views.py @@ -69,7 +69,6 @@ class CardListView(FlaggedViewMixin, ListAPIView): serializer_class = CardSurveyDataSerializer filter_backends = [CardSurveyDataFilterBackend] filterset_class = CardSurveyDataFilterSet - template_name = "tccp/cards.html" heading = "Customize for your situation" breadcrumb_items = LandingPageView.breadcrumb_items + [ { @@ -81,6 +80,12 @@ class CardListView(FlaggedViewMixin, ListAPIView): def get_queryset(self): return self.model.objects.all() + def get_template_names(self): + if self.request.htmx: + return ["tccp/includes/card_list.html"] + else: + return ["tccp/cards.html"] + def list(self, request, *args, **kwargs): render_format = request.accepted_renderer.format queryset = self.get_queryset() diff --git a/cfgov/unprocessed/apps/paying-for-college/js/disclosures/views/question-view.js b/cfgov/unprocessed/apps/paying-for-college/js/disclosures/views/question-view.js index 9ce528b0e45..f4dffa6b030 100755 --- a/cfgov/unprocessed/apps/paying-for-college/js/disclosures/views/question-view.js +++ b/cfgov/unprocessed/apps/paying-for-college/js/disclosures/views/question-view.js @@ -41,9 +41,6 @@ const questionView = { questionView.$optionsWrapper.addClass( 'get-options__settlement content_main', ); - questionView.$optionsWrapper.addClass( - 'get-options__settlement content_main', - ); questionView.$transferCredits.remove(); questionView.$exploreSchools.remove(); questionView.$takeAction.remove(); diff --git a/cfgov/unprocessed/apps/tccp/css/main.less b/cfgov/unprocessed/apps/tccp/css/main.less index 86149fa19fd..ce5a5b06618 100644 --- a/cfgov/unprocessed/apps/tccp/css/main.less +++ b/cfgov/unprocessed/apps/tccp/css/main.less @@ -6,6 +6,20 @@ } }); +// @TODO refactor and remove this override +// See https://github.com/cfpb/consumerfinance.gov/pull/8247 +.o-form_fieldset { + ul { + list-style: none; + padding-left: 0; + } + + input[type='checkbox'], + input[type='radio'] { + margin-right: 4px; + } +} + .m-btn-group { .u-btn-helper { width: 100%; @@ -39,8 +53,13 @@ list-style: square; padding-left: 1.125em; + .m-list_item { + margin-left: unit((15px / @base-font-size-px), em); + } + .m-list_item__has-icon { display: flex; + margin-left: 0; .cf-icon-svg { display: inline; @@ -54,6 +73,39 @@ } } +.tool-features { + .cf-icon-svg__credit-card-round { + color: @gold; + } + + .cf-icon-svg__court-round { + color: @gray; + } + + .cf-icon-svg__disabled-round { + color: @red; + } + + .cf-icon-svg__dollar-round { + color: @green; + } +} + +// "Show more results" functionality for JS users +// Only show the first 10 results +html.js .o-filterable-list-results__partial { + table { + tr:nth-child(10) { + border-bottom: 1px solid @table-border; + margin-bottom: unit((10px / @base-font-size-px), em); + } + + tr:nth-child(n + 11) { + display: none; + } + } +} + // Some cards list a lot of available locations and disrupt the table layout. // Cap the width of the availability cell on non-mobile screens to prevent this. @media only screen and (min-width: @bp-sm-min) { @@ -93,6 +145,7 @@ } } +// @TODO: Remove this pattern, we went in another direction .o-table__stack-on-small-hybrid { // We don't want responsive table styles applied to the `print` media type // so we're not using .respond-to-max(@bp-xs-max ) here. diff --git a/cfgov/unprocessed/apps/tccp/js/index.js b/cfgov/unprocessed/apps/tccp/js/index.js index 37940fffdc9..202cf8b0d1b 100644 --- a/cfgov/unprocessed/apps/tccp/js/index.js +++ b/cfgov/unprocessed/apps/tccp/js/index.js @@ -1,2 +1,30 @@ -// See https://htmx.org/ -import 'htmx.org'; +import htmx from 'htmx.org'; +import { attach } from '@cfpb/cfpb-atomic-component'; + +// See https://htmx.org/docs/#caching +htmx.config.getCacheBusterParam = true; + +/** + * Initialize some things. + */ +function init() { + attach('show-more', 'click', handleShowMore); +} + +/** + * Handle clicking of the results page "show more" link + * @param {Event} event - Click event. + */ +function handleShowMore(event) { + if (event instanceof Event) { + event.preventDefault(); + } + const results = document.querySelector('.o-filterable-list-results'); + results.classList.remove('o-filterable-list-results__partial'); + + event.target.classList.add('u-hidden'); +} + +window.addEventListener('load', () => { + init(); +}); diff --git a/cfgov/unprocessed/css/enhancements/typography.less b/cfgov/unprocessed/css/enhancements/typography.less index 9f8f2454519..f0c24ad01c0 100644 --- a/cfgov/unprocessed/css/enhancements/typography.less +++ b/cfgov/unprocessed/css/enhancements/typography.less @@ -22,3 +22,7 @@ dd { .eyebrow { .heading-5(); } + +.a-link_text { + overflow-wrap: break-word; +} diff --git a/cfgov/unprocessed/css/main.less b/cfgov/unprocessed/css/main.less index 5951bf2a2c8..efc9f4d7c21 100644 --- a/cfgov/unprocessed/css/main.less +++ b/cfgov/unprocessed/css/main.less @@ -85,7 +85,6 @@ @import (less) 'organisms/ask-search.less'; @import (less) 'organisms/audio-player.less'; @import (less) 'organisms/footer.less'; -@import (less) 'organisms/form.less'; @import (less) 'organisms/filterable-list-controls.less'; @import (less) 'organisms/full-width-text-group.less'; @import (less) 'organisms/header.less'; diff --git a/cfgov/unprocessed/css/organisms/form.less b/cfgov/unprocessed/css/organisms/form.less deleted file mode 100644 index 428c22d93fd..00000000000 --- a/cfgov/unprocessed/css/organisms/form.less +++ /dev/null @@ -1,11 +0,0 @@ -.o-form_fieldset { - ul { - list-style: none; - padding-left: 0; - } - - input[type='checkbox'], - input[type='radio'] { - margin-right: 4px; - } -} diff --git a/cfgov/v1/query.py b/cfgov/v1/query.py deleted file mode 100644 index e1d8e9e4ee4..00000000000 --- a/cfgov/v1/query.py +++ /dev/null @@ -1,150 +0,0 @@ -from django.db.models.expressions import RawSQL - -from wagtail.fields import StreamField -from wagtail.query import PageQuerySet - - -class StreamBlockPageQuerySet(PageQuerySet): - """Enable filtering and annotation on blocks in StreamFields on Pages. - - This class uses PostgreSQL’s built-in JSON support to create a temporary - table of blocks within a specific `StreamField`, and the uses that table - to filter a query for a specific target block within that `StreamField` or - to annotate a query with the value of the ***first*** of any target blocks. - - For example: - - >>> from v1.models import CFGOVPage - >>> from v1.query import StreamBlockPageQuerySet - >>> qs = StreamBlockPageQuerySet(CFGOVPage).block_in_field( - ... "related_links", "sidefoot" - ... ).annotate_block_in( - ... "related_links", "sidefoot" - ... ) - >>> qs.first().related_links_value - - `block_in_field(target_block, streamfield_name)` will filter the queryset - for any pages that contain the `target_block` name within a `StreamField` - with the `streamfield_name`. - - `annotate_block_in(target_block, streamfield_name)` will annotate the query - results with the JSON value of the first matching `target_block` within a - `StreamField` with the `streamfield_name`. - - If a `StreamField` with the given `streamfield_name` does not exist, a - `FieldDoesNotExist` exception wil be raised. - - If a field with the given `streamfield_name` exists, but is not a - `StreamField`, a `TypeError` will be raised. - """ - - def _temp_block_table_sql_for_streamfield(self, streamfield_name): - # Ensure the given streamfield_name is a StreamField on this queryset's - # model. - self._check_field(streamfield_name) - - # The following SQL will construct a temporary table intended to be - # used as part of a larger query that will contain the page id, - # block index, and block JSON object for each block within the JSON - # array contained within the given StreamField. This uses PostgreSQL's - # native JSON support for this construction. - # - # Note: this uses .format() instead of params to pass in the database - # database_table, primary_key_field, and streamfield_name, because the - # values for database_table and primary_key_field come from the model - # attached to this QuerySet object itself, and the streamfield_name is - # validated via the _check_field() method. - # - # Passing them through params creates quote formatting that doesn't - # work with this query. - return """ - WITH RECURSIVE blocks AS ( - SELECT page_id, index::text, block FROM ( - SELECT - {primary_key_field} AS page_id, - {streamfield_name}::jsonb AS data FROM {database_table} - ) x - LEFT JOIN LATERAL jsonb_array_elements(x.data) - WITH ORDINALITY AS a (block, index) - ON true - UNION ALL - SELECT - page_id, - index || '.' || COALESCE(obj_key, (arr_key - 1)::text), - COALESCE(arr_block, obj_block) - FROM blocks - LEFT JOIN LATERAL - jsonb_array_elements( - CASE jsonb_typeof(block) - WHEN 'array' THEN block - END - ) - WITH ORDINALITY as a(arr_block, arr_key) - ON jsonb_typeof(block) = 'array' - LEFT JOIN LATERAL - jsonb_each( - CASE jsonb_typeof(block) - WHEN 'object' THEN block - END - ) - AS o(obj_key, obj_block) - ON jsonb_typeof(block) = 'object' - WHERE arr_key IS NOT NULL OR obj_key IS NOT NULL - ) - """.strip().format( - database_table=self.model._meta.db_table, - primary_key_field=self.model._meta.pk.column, - streamfield_name=streamfield_name, - ) - - def _check_field(self, streamfield_name): - # This will raise a FieldDoesNotExist if a field with the given name - # doesn't exist on the model. - field = self.model._meta.get_field(streamfield_name) - - # If it's not a StreamField, raise a TypeError - if not isinstance(field, StreamField): - raise TypeError(f"{streamfield_name} is not a StreamField") - - def block_in_field(self, target_block, streamfield_name): - """Filter the query for any target_block in streamfield_name""" - base_sql = self._temp_block_table_sql_for_streamfield(streamfield_name) - filter_sql = ( - base_sql - + """ - SELECT - page_id as {primary_key_field} - FROM blocks - WHERE block ->> 'type' = %s - """.strip().format( - primary_key_field=self.model._meta.pk.column, - ) - ) - # Bandit will flag any use of RawSQL, thus the nosec on the next line. - # target_block is passed as a parameter to RawSQL, adhering to Django's - # documentation for avoiding SQL injection attacks. - return self.filter(id__in=RawSQL(filter_sql, (target_block,))) # nosec - - def annotate_block_in(self, target_block, streamfield_name): - """Annotate the first target_block value's from streamfield_name""" - base_sql = self._temp_block_table_sql_for_streamfield(streamfield_name) - annotation_sql = ( - base_sql - + """ - SELECT - block ->> 'value' - FROM blocks - WHERE block ->> 'type' = %s - LIMIT 1 - """.strip() - ) - # Bandit will flag any use of RawSQL, thus the nosec in this return. - # target_block is passed as a parameter to RawSQL, adhering to Django's - # documentation for avoiding SQL injection attacks. - return self.annotate( - **{ - f"{target_block}_value": RawSQL( # nosec - annotation_sql, (target_block,) - ) - } - ) diff --git a/cfgov/v1/tests/test_query.py b/cfgov/v1/tests/test_query.py deleted file mode 100644 index cb161b6d90b..00000000000 --- a/cfgov/v1/tests/test_query.py +++ /dev/null @@ -1,111 +0,0 @@ -import json - -from django.core.exceptions import FieldDoesNotExist -from django.test import TestCase - -from wagtail.models import Page, Site - -from v1.models import SublandingPage -from v1.query import StreamBlockPageQuerySet - - -class StreamBlockPageQuerySetTestCase(TestCase): - def setUp(self): - root_page = Site.objects.get(is_default_site=True).root_page - test_page = SublandingPage( - title="Test page", - live=True, - sidebar_breakout=json.dumps( - [ - {"type": "slug", "value": "Test slug"}, - ] - ), - ) - root_page.add_child(instance=test_page) - other_page = SublandingPage( - title="Other page", - live=True, - sidebar_breakout=json.dumps( - [ - {"type": "paragraph", "value": "rich text"}, - {"type": "paragraph", "value": "another rich text"}, - ] - ), - ) - root_page.add_child(instance=other_page) - - def test_block_in_field(self): - """Ensure that block_in_field finds an appropriate number of expected - results.""" - queryset = StreamBlockPageQuerySet(SublandingPage) - self.assertEqual(queryset.count(), 2) - - heading_queryset = queryset.block_in_field("slug", "sidebar_breakout") - self.assertEqual(heading_queryset.count(), 1) - - image_queryset = queryset.block_in_field("image", "sidebar_breakout") - self.assertEqual(image_queryset.count(), 0) - - def test_block_in_field_wrong_fields(self): - """If the field doesn't exist or isn't a StreamField, - block_in_field should raise FieldDoesNotExist or TypeError - respectively.""" - queryset = StreamBlockPageQuerySet(Page) - - # If we ask for a block in a field the model doesn't have, it should - # raise a FieldDoesNotExist - with self.assertRaises(FieldDoesNotExist): - queryset.block_in_field("heading", "field_that_doesnt_exist") - - # If we ask for a block in a field that isn't a StreamField, it should - # raise a TypeError - with self.assertRaises(TypeError): - queryset.block_in_field("heading", "title") - - def test_annotate_block_in(self): - """Ensure we get an annotation for a target block""" - queryset = StreamBlockPageQuerySet(SublandingPage) - - annotated_queryset = queryset.block_in_field( - "slug", "sidebar_breakout" - ).annotate_block_in("slug", "sidebar_breakout") - self.assertEqual(annotated_queryset.count(), 1) - self.assertEqual(annotated_queryset[0].slug_value, "Test slug") - - def test_annotate_block_in_multiple_blocks(self): - """If we have multiple target_blocks in a StreamField, ensure we only - get an annotation for the first one.""" - queryset = StreamBlockPageQuerySet(SublandingPage) - - annotated_queryset = queryset.block_in_field( - "paragraph", "sidebar_breakout" - ).annotate_block_in("paragraph", "sidebar_breakout") - self.assertEqual(annotated_queryset.count(), 1) - self.assertEqual(annotated_queryset[0].paragraph_value, "rich text") - - def test_annotate_block_in_when_block_doesnt_exist(self): - """If asked to annotate with a block that doesn't exist in the - streamfield in the queryset, the annotation should be None""" - queryset = StreamBlockPageQuerySet(SublandingPage) - - annotated_queryset = queryset.annotate_block_in( - "foo", "sidebar_breakout" - ) - self.assertTrue(annotated_queryset.count() > 0) - self.assertEqual(annotated_queryset[0].foo_value, None) - - def test_annotate_block_in_wrong_fields(self): - """If the field doesn't exist or isn't a StreamField, - annotate_block_in should raise FieldDoesNotExist or TypeError - respectively.""" - queryset = StreamBlockPageQuerySet(Page) - - # If we ask for a block in a field the model doesn't have, it should - # raise a FieldDoesNotExist - with self.assertRaises(FieldDoesNotExist): - queryset.annotate_block_in("heading", "field_that_doesnt_exist") - - # If we ask for a block in a field that isn't a StreamField, it should - # raise a TypeError - with self.assertRaises(TypeError): - queryset.annotate_block_in("heading", "title") diff --git a/requirements/libraries.txt b/requirements/libraries.txt index 714e705ed43..72524d12603 100644 --- a/requirements/libraries.txt +++ b/requirements/libraries.txt @@ -12,6 +12,7 @@ django-extensions==3.2.3 django-flags==5.0.13 django-formtools==2.5.1 django-health-check==3.18.1 +django-htmx==1.17.3 django-localflavor==4.0 django-mptt==0.14.0 django-storages==1.14.2 diff --git a/test/unit_tests/apps/paying-for-college/disclosures/fixtures/overview.js b/test/unit_tests/apps/paying-for-college/disclosures/fixtures/overview.js index 7a64af6a1c8..ea8bd53ed89 100644 --- a/test/unit_tests/apps/paying-for-college/disclosures/fixtures/overview.js +++ b/test/unit_tests/apps/paying-for-college/disclosures/fixtures/overview.js @@ -2370,62 +2370,62 @@ export default `

-