From fa3f0644e7d49a01c8679902b42fbba28b24decd Mon Sep 17 00:00:00 2001 From: Dale Cannon <118175145+dalecannon@users.noreply.github.com> Date: Wed, 20 Mar 2024 15:57:13 +0000 Subject: [PATCH 1/4] TP2000-1241 Implement new homepage design (#1173) * Update header and footer * Add new homepage * Add search bar to homepage * Add tests * Filter in ERRORED workbaskets * Tweak homepage card padding * Add Resources view * Style workbasket action links as buttons * Remove unused permission boolean * Change worbasket action buttons styling * Save workbasket icon in svg file * Close img tag * Add docstring to get_search_result * Add missing alt attribute on workbasket icon img * Amend doctstring * Keep search icon inline if form error message is displayed * Add placeholder cards and text for Test & Train session * Add notification icon placeholder * Tweak UI for Test & Train session * Revert test & train placeholder code * Fix homepage view unit test --- common/forms.py | 113 +++----- common/jinja2/common/homepage.jinja | 165 +++++++++++ common/jinja2/common/maintenance.jinja | 21 +- common/jinja2/common/resources.jinja | 93 +++++++ common/jinja2/common/search_page.jinja | 2 +- common/jinja2/common/workbasket_action.jinja | 18 -- common/jinja2/layouts/layout.jinja | 166 ++++++----- common/static/common/images/search-icon.svg | 4 + .../static/common/images/workbasket-icon.svg | 4 + common/static/common/scss/_badges.scss | 13 + common/static/common/scss/_components.scss | 73 ++++- common/static/common/scss/_environment.scss | 24 -- common/static/common/scss/application.scss | 1 - common/tests/test_views.py | 257 +++++++++++------- common/urls.py | 1 + common/views.py | 210 ++++++++++++-- pii-secret-exclude.txt | 1 + workbaskets/tests/test_views.py | 5 +- 18 files changed, 849 insertions(+), 322 deletions(-) create mode 100644 common/jinja2/common/homepage.jinja create mode 100644 common/jinja2/common/resources.jinja delete mode 100644 common/jinja2/common/workbasket_action.jinja create mode 100644 common/static/common/images/search-icon.svg create mode 100644 common/static/common/images/workbasket-icon.svg delete mode 100644 common/static/common/scss/_environment.scss diff --git a/common/forms.py b/common/forms.py index b2240f482..4cf740eed 100644 --- a/common/forms.py +++ b/common/forms.py @@ -5,22 +5,20 @@ from crispy_forms_gds.fields import DateInputField from crispy_forms_gds.helper import FormHelper -from crispy_forms_gds.layout import HTML from crispy_forms_gds.layout import Div from crispy_forms_gds.layout import Field -from crispy_forms_gds.layout import Fieldset from crispy_forms_gds.layout import Layout from crispy_forms_gds.layout import Size from crispy_forms_gds.layout import Submit from django import forms from django.contrib.postgres.forms.ranges import DateRangeField from django.core.exceptions import ValidationError -from django.db.models import TextChoices from django.forms.renderers import get_default_renderer from django.forms.utils import ErrorList from common.util import TaricDateRange from common.util import get_model_indefinite_article +from common.validators import AlphanumericValidator from common.widgets import FormSetFieldWidget from common.widgets import MultipleFileInput from common.widgets import RadioNestedWidget @@ -235,85 +233,6 @@ def get_bound_field(self, form, field_name): return super().get_bound_field(form, field_name) -class WorkbasketActions(TextChoices): - CREATE = "CREATE", "Create a new workbasket" - EDIT = "EDIT", "Edit workbaskets" - - -class DITTariffManagerActions(TextChoices): - PACKAGE_WORKBASKETS = "PACKAGE_WORKBASKETS", "Package workbaskets" - - -class HMRCCDSManagerActions(TextChoices): - PROCESS_ENVELOPES = "PROCESS_ENVELOPES", "Process envelopes" - - -class CommonUserActions(TextChoices): - SEARCH = "SEARCH", "Search the tariff" - # Change this to be dependent on permissions later - - -class ImportUserActions(TextChoices): - IMPORT = "IMPORT", "Import EU TARIC files" - - -class WorkbasketManagerActions(TextChoices): - WORKBASKET_LIST_ALL = "WORKBASKET_LIST_ALL", "Search for workbaskets" - - -class HomeForm(forms.Form): - def __init__(self, *args, **kwargs): - self.user = kwargs.pop("user") - super().__init__(*args, **kwargs) - - choices = [] - - if self.user.has_perm("workbaskets.add_workbasket"): - choices += WorkbasketActions.choices - - if self.user.has_perm("publishing.manage_packaging_queue"): - choices += DITTariffManagerActions.choices - - if self.user.has_perm("publishing.consume_from_packaging_queue"): - choices += HMRCCDSManagerActions.choices - - choices += CommonUserActions.choices - - if self.user.has_perm("common.add_trackedmodel") or self.user.has_perm( - "common.change_trackedmodel", - ): - choices += ImportUserActions.choices - - if self.user.has_perm("workbaskets.view_workbasket"): - choices += WorkbasketManagerActions.choices - - self.fields["workbasket_action"] = forms.ChoiceField( - label="", - choices=choices, - widget=forms.RadioSelect, - required=True, - ) - - self.helper = FormHelper(self) - self.helper.layout = Layout( - Fieldset( - HTML.h3("What would you like to do?"), - HTML.details( - "What is a workbasket?", - "A workbasket is used to collect all the changes you make to the UK's import and export tariff data. " - "Workbaskets group these changes so they can be checked by the Customs Declaration Service (CDS) before going live.", - ), - "workbasket_action", - ), - Submit( - "submit", - "Continue", - data_module="govuk-button", - data_prevent_double_click="true", - ), - ) - - class DescriptionHelpBox(Div): template = "components/description_help.jinja" @@ -814,3 +733,33 @@ def clean(self, data, initial=None): else: files = cleaned_data(data, initial) return files + + +class HomeSearchForm(forms.Form): + search_term = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={"placeholder": "Search by tariff element name or ID"}, + ), + validators=[AlphanumericValidator], + max_length=18, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper(self) + self.helper.label_size = Size.SMALL + self.helper.legend_size = Size.SMALL + self.helper.layout = Layout( + Div( + Field.text("search_term"), + Submit( + "submit", + "Search", + data_module="govuk-button", + data_prevent_double_click="true", + ), + css_id="homepage-search-form", + ), + ) diff --git a/common/jinja2/common/homepage.jinja b/common/jinja2/common/homepage.jinja new file mode 100644 index 000000000..dd1c2da6f --- /dev/null +++ b/common/jinja2/common/homepage.jinja @@ -0,0 +1,165 @@ +{% extends "layouts/form.jinja" %} + +{% set page_title = "Home" %} + +{% block content %} +

Search the Tariff Application Platform

+ This service lets you manage the UK's import and export tariff data + + {% block form %} +
+ +
+ {% endblock %} + +
+ {% if can_view_workbasket %} +
+

What would you like to do?

+

+ Create a new workbasket, edit an existing workbasket, package or search for workbaskets. +

+ +
+ {% if can_add_workbasket %} + + + + {% endif %} + + {% if can_manage_packaging %} + + {% endif %} + + +
+
+ +
+

Currently working on

+ {% if not assigned_workbaskets %} +

You are not currently assigned to a workbasket.

+ {% else %} + + {% endif %} +
+ {% endif %} + + {% if can_import_taric %} +
+

EU TARIC files

+

+ View EU import list and import new TARIC files +

+
+ {% endif %} + + {% if can_consume_packaging %} +
+

Envelopes

+

+ Process envelopes +

+
+ {% endif %} + +
+

Resources

+

+ We have a range of resources you can use with the Tariff Application Platform. +

+ +
+
+ +
+ +
+

How can we support you?

+ +
+

Get help

+

+ Find documentation, guidance, training and updates in the + + TAP help centre. + +

+
+ +
+

Get in touch

+

Contact us if you have a question or suggestion.

+
+
+{% endblock %} diff --git a/common/jinja2/common/maintenance.jinja b/common/jinja2/common/maintenance.jinja index 3aafad122..a0b8a5e46 100644 --- a/common/jinja2/common/maintenance.jinja +++ b/common/jinja2/common/maintenance.jinja @@ -3,13 +3,24 @@ {% set page_title = "Sorry, the service is unavailable" %} {% set workbasket_html %}{% endset %} +{% set logout_login_link %}{% endset %} +{% set can_view_workbasket %}{% endset %} +{% set can_import_taric %}{% endset %} +{% set can_manage_packaging %}{% endset %} +{% set can_consume_packaging %}{% endset %} {% block header %} -{{ govukHeader({ - "homepageUrl": "https://gov.uk/", - "serviceName": service_name, - "serviceUrl": "/", -}) }} + {% endblock %} {% block breadcrumb %}{% endblock %} diff --git a/common/jinja2/common/resources.jinja b/common/jinja2/common/resources.jinja new file mode 100644 index 000000000..972cb25f0 --- /dev/null +++ b/common/jinja2/common/resources.jinja @@ -0,0 +1,93 @@ +{% extends "layouts/layout.jinja" %} + +{% set page_title = "Resources" %} + +{% macro resource(heading, description, url, open_new_tab="") %} +
+
+

{{ heading }}

+
+
+
+
+

{{ description }}

+
+
+ View +
+
+
+{% endmacro %} + +{% block breadcrumb %} +
+
    +
  1. + Home +
  2. +
  3. + {{ page_title }} +
  4. +
+
+{% endblock %} + +{% block content %} +
+
+

{{ page_title }}

+

Tariff Application Platform offers access to a range of resources.

+
+
+ +
+
+ + {{ + resource( + heading="Application information", + description="", + url=url("app-info"), + ) + }} + + {{ + resource( + heading="Importer V1", + description="", + url=url("import_batch-ui-list"), + ) + }} + + {{ resource( + heading="Importer V2", + description="", + url=url("taric_parser_import_ui_list"), + )}} + + {{ + resource( + heading="TAP reports", + description="", + url=url("reports:index"), + ) + }} + + {{ + resource( + heading="Tariff data manual", + description="", + url="https://uktrade.github.io/tariff-data-manual/#home", + open_new_tab="True", + ) + }} + +
+
+{% endblock %} diff --git a/common/jinja2/common/search_page.jinja b/common/jinja2/common/search_page.jinja index 3111d607d..534f4e0cb 100644 --- a/common/jinja2/common/search_page.jinja +++ b/common/jinja2/common/search_page.jinja @@ -1,7 +1,7 @@ {% extends "layouts/layout.jinja" %} {% from "components/breadcrumbs/macro.njk" import govukBreadcrumbs %} -{% set page_title = "Search the Tariff Management Tool" %} +{% set page_title = "Search the Tariff Application Platform" %} {% set page_subtitle = "What would you like to do?" %} {% block breadcrumb %} diff --git a/common/jinja2/common/workbasket_action.jinja b/common/jinja2/common/workbasket_action.jinja deleted file mode 100644 index 78a15d3bb..000000000 --- a/common/jinja2/common/workbasket_action.jinja +++ /dev/null @@ -1,18 +0,0 @@ -{% extends "layouts/form.jinja" %} - -{% set page_title = "Tariff Management Tool" %} - -{% block content %} -

Tariff management tool

- This service lets you manage UK trade tariffs - - {% block form %} -
-
- {% call django_form() %} - {{ crispy(form) }} - {% endcall %} -
-
- {% endblock %} -{% endblock %} diff --git a/common/jinja2/layouts/layout.jinja b/common/jinja2/layouts/layout.jinja index bcbf54251..8f0d41ec1 100644 --- a/common/jinja2/layouts/layout.jinja +++ b/common/jinja2/layouts/layout.jinja @@ -4,116 +4,144 @@ {% extends "layouts/_generic.jinja" %} -{% set service_name = "Manage Trade Tariffs" %} +{% set service_name = "Tariff Application Platform" %} +{% set phase_banner_tag = "Alpha" %} {% block pageTitle %} {{ page_title }} | {{ service_name }} {% endblock %} -{% set workbasket_svg %} - {# /PS-IGNORE #} - {# /PS-IGNORE #} - {# /PS-IGNORE #} - -{% endset %} - -{% block header %} - {% set workbasket_html %} {% if request.user.current_workbasket %} + workbasket icon Workbasket {{ request.user.current_workbasket.id }} {{ workbasket_svg }} + class="govuk-header__link workbasket-link govuk-header__navigation-item" + >Workbasket ID {{ request.user.current_workbasket.id }} {% endif %} {% endset %} {% set logout_login_link %} {% if request.user.is_authenticated %} -
+ {{ csrf_input }} + >Sign out
{% else %} - Sign in + Sign in {% endif %} {% endset %} -{{ govukHeader({ - "homepageUrl": "https://gov.uk/", - "serviceName": service_name, - "serviceUrl": "/", - "navigation": [ - { "html": workbasket_html } if workbasket_html else "", - { - "html": logout_login_link, - } if not settings.SSO_ENABLED else "", - ], - "useTudorCrown": true, -}) }} +{% set can_view_workbasket = request.user.has_perm("workbaskets.view_workbasket") %} +{% set can_import_taric = request.user.has_perm("common.add_trackedmodel") %} +{% set can_manage_packaging = request.user.has_perm("publishing.manage_packaging_queue") %} +{% set can_consume_packaging = request.user.has_perm("publishing.consume_from_packaging_queue") %} + +{% block header %} + {% endblock %} {% block beforeContent %} - {{ govukPhaseBanner({ - "tag": { - "text": "alpha" - }, - "text": "This is a new service – your feedback will help us to improve it." - }) }} {% block breadcrumb %} - {% if request.path != "/" %} - {{ breadcrumbs(request, [{"text": page_title}]) }} - {% endif %} + {% if request.path != "/" %} + {{ breadcrumbs(request, [{"text": page_title}]) }} + {% endif %} {% endblock %} - - {% if env != "production"%} -
-
{{ env }}
-
- {% endif %} {% endblock %} {% block footer %} {{ govukFooter({ "meta": { "items": [ - { - "href": "https://workspace.trade.gov.uk/working-at-dbt/policies-and-guidance/policies/tariff-application-privacy-policy/", - "text": "Privacy policy" - }, { "href": url("accessibility-statement"), "text": "Accessibility statement" }, { - "href": "https://uktrade.github.io/tariff-data-manual/#home", - "text": "Tariff data manual" - }, - { - "href": url("app-info"), - "text": "Application information" - }, - { - "href": url("import_batch-ui-list"), - "text": "Importer V1" - }, - { - "href": url("taric_parser_import_ui_list"), - "text": "Importer V2" + "href": "https://workspace.trade.gov.uk/working-at-dbt/policies-and-guidance/policies/tariff-application-privacy-policy/", + "text": "Privacy policy" }, { - "href": url("reports:index"), - "text": "TAP reports" + "href": "https://data-services-help.trade.gov.uk/tariff-application-platform/", + "text": "Help centre" }, ] } diff --git a/common/static/common/images/search-icon.svg b/common/static/common/images/search-icon.svg new file mode 100644 index 000000000..7b202b2e0 --- /dev/null +++ b/common/static/common/images/search-icon.svg @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/common/static/common/images/workbasket-icon.svg b/common/static/common/images/workbasket-icon.svg new file mode 100644 index 000000000..67e30ae4b --- /dev/null +++ b/common/static/common/images/workbasket-icon.svg @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/common/static/common/scss/_badges.scss b/common/static/common/scss/_badges.scss index 6ce776ddd..fdcece3f2 100644 --- a/common/static/common/scss/_badges.scss +++ b/common/static/common/scss/_badges.scss @@ -56,3 +56,16 @@ color: govuk-shade(govuk-colour("green"), 20); background: govuk-tint(govuk-colour("green"), 80); } + +.rule-violation-badge { + display: inline-block; + font-weight: bold; + color: govuk-colour("white"); + background-color: $govuk-error-colour; + min-height: 16px; + min-width: 16px; + line-height: 1; + text-align: center; + border-radius: 50%; + padding: 4px; +} \ No newline at end of file diff --git a/common/static/common/scss/_components.scss b/common/static/common/scss/_components.scss index a0c445891..acbd03f0d 100644 --- a/common/static/common/scss/_components.scss +++ b/common/static/common/scss/_components.scss @@ -43,7 +43,7 @@ } } - button#sign-out { + .logout-login { color: govuk-colour("white"); font-weight: bold; text-decoration: none; @@ -90,3 +90,74 @@ } } + +.homepage-card { + box-sizing: border-box; + border: 1px solid $govuk-border-colour; + padding: govuk-spacing(3) govuk-spacing(3) 0; + margin: govuk-spacing(3); + width: calc(100% - govuk-spacing(6)); + float: left; +} + +@media screen and (min-width: 1063px) { + .homepage-card { + width: calc(50% - govuk-spacing(6)); + } +} + +#homepage-tap-resources { + columns: 2; +} + +.homepage-workbasket-details { + display: inline-block; +} + +#current-workbasket { + img { + vertical-align: text-bottom; + } + a, form { + display: inline-block; + } +} + +@media screen and (min-width: 770px) { + #current-workbasket { + float: right; + } +} + +#homepage-search-form { + display: flex; + flex-flow: row nowrap; + align-items: flex-end; + + .govuk-input { + height: 50px; + font-size: 21px; + } + + .govuk-form-group { + width: 100%; + } + + .govuk-label { + @extend .govuk-visually-hidden + } + + .govuk-button { + background: no-repeat url("/common/static/common/images/search-icon.svg"); + background-color: govuk-colour("black"); + background-position: center; + color: transparent; + margin-left: 0; + width: 50px; + height: 48px; + } +} + +.homepage-workbasket-action { + width: 100%; +} diff --git a/common/static/common/scss/_environment.scss b/common/static/common/scss/_environment.scss deleted file mode 100644 index f24043de9..000000000 --- a/common/static/common/scss/_environment.scss +++ /dev/null @@ -1,24 +0,0 @@ -.environment-banner { - position: fixed; - top: 0; - right: 0; - width: 0; - height: 0; - border-left: 150px solid transparent; - border-top: 150px solid green; - color: white; - text-align: center; - font-size: 18px; - - .text-wrapper { - position: absolute; - font-weight: 900; - text-transform: uppercase; - transform: translate(-90%, -325%) rotate(45deg); - color: white; - background-color: transparent; - padding: 4px; - white-space: nowrap; - } -} - diff --git a/common/static/common/scss/application.scss b/common/static/common/scss/application.scss index 3ac96053c..4bda561ef 100644 --- a/common/static/common/scss/application.scss +++ b/common/static/common/scss/application.scss @@ -34,4 +34,3 @@ $govuk-image-url-function: frontend-image-url; @import "quota-definitions"; @import "commodities"; @import "utils"; -@import "environment"; diff --git a/common/tests/test_views.py b/common/tests/test_views.py index efb6ed520..0e07bc0ed 100644 --- a/common/tests/test_views.py +++ b/common/tests/test_views.py @@ -1,5 +1,3 @@ -import re - import pytest from bs4 import BeautifulSoup from django.conf import settings @@ -7,43 +5,20 @@ from django.test import modify_settings from django.test import override_settings from django.urls import reverse +from django.urls import reverse_lazy +from checks.tests.factories import TrackedModelCheckFactory from common.tests import factories from common.util import xml_fromstring from common.views import HealthCheckResponse from common.views import handler403 from common.views import handler500 +from tasks.models import UserAssignment +from workbaskets.validators import WorkflowStatus pytestmark = pytest.mark.django_db -@pytest.mark.parametrize( - ("action", "permission"), - [ - ("Create a new workbasket", "add_workbasket"), - ("Edit workbaskets", "add_workbasket"), - ("Package workbaskets", "manage_packaging_queue"), - ("Process envelopes", "consume_from_packaging_queue"), - ("Search the tariff", ""), - ("Import EU TARIC files", "add_trackedmodel"), - ("Search for workbaskets", "view_workbasket"), - ], -) -def test_home_form_actions_match_permissions(action, permission, client): - """Tests that the workbasket action form on the home page displays the - appropriate radio options for the user's permissions.""" - user = factories.UserFactory.create() - if permission: - user.user_permissions.add(Permission.objects.get(codename=permission)) - client.force_login(user) - - response = client.get(reverse("home")) - assert response.status_code == 200 - - page = BeautifulSoup(response.content.decode(response.charset), "html.parser") - assert page.find("label", class_="govuk-radios__label", string=re.compile(action)) - - def test_index_displays_logout_buttons_correctly_SSO_off_logged_in(valid_user_client): settings.SSO_ENABLED = False response = valid_user_client.get(reverse("home")) @@ -73,65 +48,6 @@ def test_index_displays_login_buttons_correctly_SSO_on(valid_user_client): assert not page.find_all("a", {"href": "/login"}) -@pytest.mark.parametrize( - ("data", "response_url"), - ( - ( - { - "workbasket_action": "CREATE", - }, - "workbaskets:workbasket-ui-create", - ), - ( - { - "workbasket_action": "EDIT", - }, - "workbaskets:workbasket-ui-list", - ), - ( - { - "workbasket_action": "PACKAGE_WORKBASKETS", - }, - "publishing:packaged-workbasket-queue-ui-list", - ), - ( - { - "workbasket_action": "PROCESS_ENVELOPES", - }, - "publishing:envelope-queue-ui-list", - ), - ( - { - "workbasket_action": "SEARCH", - }, - "search-page", - ), - ( - { - "workbasket_action": "IMPORT", - }, - "commodity_importer-ui-list", - ), - ( - { - "workbasket_action": "WORKBASKET_LIST_ALL", - }, - "workbaskets:workbasket-ui-list-all", - ), - ), -) -def test_workbasket_action_form_response_redirects_user( - valid_user, - client, - data, - response_url, -): - client.force_login(valid_user) - response = client.post(reverse("home"), data) - assert response.status_code == 302 - assert response.url == reverse(response_url) - - @pytest.mark.parametrize( "response, status_code, status", [ @@ -191,12 +107,10 @@ def test_index_displays_footer_links(valid_user_client): page = BeautifulSoup(str(response.content), "html.parser") a_tags = page.select("footer a") - assert len(a_tags) == 7 - assert "Privacy policy" in a_tags[0].text - assert ( - a_tags[0].attrs["href"] - == "https://workspace.trade.gov.uk/working-at-dbt/policies-and-guidance/policies/tariff-application-privacy-policy/" - ) + assert len(a_tags) == 3 + assert "Accessibility statement" in a_tags[0].text + assert "Privacy policy" in a_tags[1].text + assert "Help centre" in a_tags[2].text def test_search_page_displays_links(valid_user_client): @@ -261,3 +175,158 @@ def test_maintenance_mode_page_content(valid_user_client): response = valid_user_client.get(reverse("maintenance")) assert response.status_code == 200 assert "Sorry, the service is unavailable" in str(response.content) + + +@pytest.mark.parametrize( + ("card", "permission"), + [ + ("What would you like to do?", "view_workbasket"), + ("Currently working on", "view_workbasket"), + ("EU TARIC files", "add_trackedmodel"), + ("Envelopes", "consume_from_packaging_queue"), + ("Resources", ""), + ("Get help", ""), + ("Get in touch", ""), + ], +) +def test_homepage_cards_match_user_permissions(card, permission, client): + user = factories.UserFactory.create() + client.force_login(user) + + if permission: + response = client.get(reverse("home")) + assert not card in str(response.content) + user.user_permissions.add(Permission.objects.get(codename=permission)) + + response = client.get(reverse("home")) + assert card in str(response.content) + + +def test_homepage_cards_contain_expected_links(superuser_client): + expected_urls = { + "Create a new workbasket": "workbaskets:workbasket-ui-create", + "Edit a workbasket": "workbaskets:workbasket-ui-list", + "Package a workbasket": "publishing:packaged-workbasket-queue-ui-list", + "Search for workbaskets": "workbaskets:workbasket-ui-list-all", + "View EU import list": "commodity_importer-ui-list", + "Process envelopes": "publishing:envelope-queue-ui-list", + "Application information": "app-info", + "Importer V1": "import_batch-ui-list", + "Importer V2": "taric_parser_import_ui_list", + "TAP reports": "reports:index", + } + response = superuser_client.get(reverse("home")) + page = BeautifulSoup(response.content.decode(response.charset), "html.parser") + links = [a["href"] for a in page.find_all("a")] + + for url in expected_urls.values(): + assert reverse(url) in links + + +@pytest.mark.parametrize( + ("status"), + [ + (WorkflowStatus.EDITING), + (WorkflowStatus.ERRORED), + ], +) +def test_homepage_card_currently_working_on(status, valid_user, valid_user_client): + workbasket = factories.WorkBasketFactory.create(status=status) + review_assignment = factories.UserAssignmentFactory.create( + user=valid_user, + assignment_type=UserAssignment.AssignmentType.WORKBASKET_REVIEWER, + task__workbasket=workbasket, + ) + rule_violation = TrackedModelCheckFactory.create( + transaction_check__transaction=workbasket.new_transaction(), + successful=False, + ) + + response = valid_user_client.get(reverse("home")) + page = BeautifulSoup(response.content.decode(response.charset), "html.parser") + card = page.find("h3", string="Currently working on") + assigned_workbasket = card.find_next("a") + details = card.find_next("span") + + assert f"Workbasket ID {workbasket.pk}" in assigned_workbasket.text + assert "Reviewing" and "rule violation" in details.text + + +@pytest.mark.parametrize( + ("factory", "id", "kwargs"), + [ + (factories.AdditionalCodeFactory, None, {}), + (factories.CertificateFactory, "code", {}), + (factories.FootnoteFactory, None, {}), + (factories.GeographicalAreaFactory, "area_id", {}), + (factories.GoodsNomenclatureFactory, "item_id", {}), + (factories.MeasureFactory, "sid", {"sid": 123456}), + (factories.QuotaOrderNumberFactory, "order_number", {}), + (factories.RegulationFactory, "regulation_id", {}), + ], +) +def test_homepage_search_by_element_id_returns_result( + factory, + id, + kwargs, + valid_user_client, +): + model = factory(**kwargs) + form_data = { + "search_term": getattr(model, id) if id else model.__str__(), + } + response = valid_user_client.post(reverse("home"), form_data) + assert response.status_code == 302 + assert response.url == model.get_url() + + +@pytest.mark.parametrize( + ("name", "expected_url"), + [ + ("additional codes", "additional_code-ui-list"), + ("certificates", "certificate-ui-list"), + ("footnotes", "footnote-ui-list"), + ("geographical areas", "geo_area-ui-list"), + ("commodities", "commodity-ui-list"), + ("measures", "measure-ui-search"), + ("quotas", "quota-ui-list"), + ("regulations", "regulation-ui-list"), + ], +) +def test_homepage_search_by_element_name_returns_result( + name, + expected_url, + valid_user_client, +): + form_data = { + "search_term": name, + } + response = valid_user_client.post(reverse("home"), form_data) + assert response.status_code == 302 + assert response.url == reverse(expected_url) + + +def test_homepage_search_no_result(valid_user_client): + factories.FootnoteFactory.create() + response = valid_user_client.post(reverse("home"), {"search_term": "empty"}) + assert response.status_code == 302 + assert response.url == reverse("search-page") + + +@pytest.mark.parametrize( + ("heading", "expected_url"), + [ + ("Application information", reverse_lazy("app-info")), + ("Importer V1", reverse_lazy("import_batch-ui-list")), + ("Importer V2", reverse_lazy("taric_parser_import_ui_list")), + ("TAP reports", reverse_lazy("reports:index")), + ("Tariff data manual", "https://uktrade.github.io/tariff-data-manual/#home"), + ], +) +def test_resources_view_displays_resources(heading, expected_url, valid_user_client): + response = valid_user_client.get(reverse("resources")) + assert response.status_code == 200 + + page = BeautifulSoup(response.content.decode(response.charset), "html.parser") + assert page.find("h3", string=heading) + assert page.find("a", href=expected_url) diff --git a/common/urls.py b/common/urls.py index 5e7361195..5d60becf6 100644 --- a/common/urls.py +++ b/common/urls.py @@ -17,6 +17,7 @@ urlpatterns = [ path("", views.HomeView.as_view(), name="home"), path("search/", views.SearchPageView.as_view(), name="search-page"), + path("resources/", views.ResourcesView.as_view(), name="resources"), path("healthcheck", views.healthcheck, name="healthcheck"), path("app-info", views.AppInfoView.as_view(), name="app-info"), path( diff --git a/common/views.py b/common/views.py index f8e7bd12c..12515bc93 100644 --- a/common/views.py +++ b/common/views.py @@ -19,6 +19,7 @@ from django.db import connection from django.db import transaction from django.db.models import Model +from django.db.models import Q from django.db.models import QuerySet from django.http import Http404 from django.http import HttpResponse @@ -26,56 +27,219 @@ from django.template.response import TemplateResponse from django.urls import reverse from django.utils.timezone import make_aware -from django.views import generic +from django.views.generic import DetailView from django.views.generic import FormView from django.views.generic import TemplateView -from django.views.generic.base import View from django.views.generic.edit import FormMixin from django_filters.views import FilterView from redis.exceptions import TimeoutError as RedisTimeoutError -from common import forms +from additional_codes.models import AdditionalCode +from certificates.models import Certificate +from commodities.models import GoodsNomenclature from common.business_rules import BusinessRule from common.business_rules import BusinessRuleViolation from common.celery import app +from common.forms import HomeSearchForm from common.models import TrackedModel from common.models import Transaction from common.pagination import build_pagination_list from common.validators import UpdateType +from footnotes.models import Footnote +from geo_areas.models import GeographicalArea +from measures.models import Measure from publishing.models import PackagedWorkBasket +from quotas.models import QuotaOrderNumber +from regulations.models import Regulation +from tasks.models import UserAssignment from workbaskets.models import WorkBasket +from workbaskets.models import WorkflowStatus from workbaskets.views.mixins import WithCurrentWorkBasket -class HomeView(FormView, View): - template_name = "common/workbasket_action.jinja" - form_class = forms.HomeForm +class HomeView(LoginRequiredMixin, FormView): + template_name = "common/homepage.jinja" + form_class = HomeSearchForm - REDIRECT_MAPPING = { - "EDIT": "workbaskets:workbasket-ui-list", - "CREATE": "workbaskets:workbasket-ui-create", - "PACKAGE_WORKBASKETS": "publishing:packaged-workbasket-queue-ui-list", - "PROCESS_ENVELOPES": "publishing:envelope-queue-ui-list", - "SEARCH": "search-page", - "IMPORT": "commodity_importer-ui-list", - "WORKBASKET_LIST_ALL": "workbaskets:workbasket-ui-list-all", - } + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + assignments = ( + UserAssignment.objects.filter(user=self.request.user) + .assigned() + .select_related("task__workbasket") + .filter( + Q(task__workbasket__status=WorkflowStatus.EDITING) + | Q(task__workbasket__status=WorkflowStatus.ERRORED), + ) + ) + assigned_workbaskets = [] + for assignment in assignments: + workbasket = assignment.task.workbasket + assignment_type = ( + "Assigned" + if assignment.assignment_type + == UserAssignment.AssignmentType.WORKBASKET_WORKER + else "Reviewing" + ) + rule_violations_count = workbasket.tracked_model_check_errors.count() + assigned_workbaskets.append( + { + "id": workbasket.id, + "rule_violations_count": rule_violations_count, + "assignment_type": assignment_type, + }, + ) + + context.update( + { + "assigned_workbaskets": assigned_workbaskets, + "can_add_workbasket": self.request.user.has_perm( + "workbaskets.add_workbasket", + ), + "can_view_reports": self.request.user.has_perm( + "reports.view_report_index", + ), + }, + ) + return context + + def get_search_result(self, search_term: str) -> Optional[str]: + """ + Returns the outcome of a search for a given `search_term`. + + The search term is expected to be either a tariff element name or a tariff + element ID, with a length between 2 and 18 characters. + + For a tariff element name, we attempt to find a matching key in `list_view_map` dict, + returning the corresponding 'Find and edit' view URL of the matching element. + + For a tariff element ID, we perform case-insensitive DB lookups for tariff elements whose ID + takes the form of the search term, returning the detail view URL of the matching element. + + If no match can be found for a given search term, then `None` is returned. + """ + # Check if the search term matches an element name + list_view_map = { + "additional codes": "additional_code-ui-list", + "certificates": "certificate-ui-list", + "footnotes": "footnote-ui-list", + "geographical areas": "geo_area-ui-list", + "commodities": "commodity-ui-list", + "measures": "measure-ui-search", + "quotas": "quota-ui-list", + "regulations": "regulation-ui-list", + } + match = list_view_map.get(search_term, None) + if match: + return match + + # Otherwise attempt to match the search term to an element ID + search_len = len(search_term) + if search_len == 2: + match = ( + GeographicalArea.objects.filter(area_id__iexact=search_term) + .current() + .last() + ) + return match.get_url() if match else None + + elif search_len == 4: + match = ( + AdditionalCode.objects.filter( + type__sid__iexact=search_term[0], + code__iexact=search_term[1:], + ) + .current() + .last() + ) + if match: + return match.get_url() - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["user"] = self.request.user - return kwargs + match = ( + Certificate.objects.filter( + certificate_type__sid__iexact=search_term[0], + sid__iexact=search_term[1:], + ) + .current() + .last() + ) + if match: + return match.get_url() + + match = ( + GeographicalArea.objects.filter(area_id__iexact=search_term) + .current() + .last() + ) + return match.get_url() if match else None + + elif search_len == 5: + match = ( + Footnote.objects.filter( + footnote_type__footnote_type_id__iexact=search_term[:2], + footnote_id=search_term[2:], + ) + .current() + .last() + ) + return match.get_url() if match else None + + elif search_term.isnumeric(): + if search_len == 6 and search_term[0] == "0": + match = ( + QuotaOrderNumber.objects.filter(order_number=search_term) + .current() + .last() + ) + return match.get_url() if match else None + if search_len == 10: + match = ( + GoodsNomenclature.objects.filter(item_id=search_term) + .current() + .last() + ) + if match: + return match.get_url() + else: + match = Measure.objects.filter(sid=search_term).current().last() + return match.get_url() if match else None + + elif search_len == 8: + match = ( + Regulation.objects.filter(regulation_id__iexact=search_term) + .current() + .last() + ) + return match.get_url() if match else None + + # No match has been found for the search term + else: + return None def form_valid(self, form): - return redirect( - reverse(self.REDIRECT_MAPPING[form.cleaned_data["workbasket_action"]]), - ) + search_term = form.cleaned_data["search_term"] + if not search_term: + return self.get_success_url(reverse("search-page")) + + result = self.get_search_result(search_term) + if result: + return self.get_success_url(result) + else: + return self.get_success_url(reverse("search-page")) + + def get_success_url(self, url): + return redirect(url) class SearchPageView(TemplateView): template_name = "common/search_page.jinja" +class ResourcesView(TemplateView): + template_name = "common/resources.jinja" + + class HealthCheckResponse(HttpResponse): """ Formatted HTTP response for healthcheck. @@ -316,7 +480,7 @@ def get_object(self, queryset: Optional[QuerySet] = None) -> Model: class TrackedModelDetailView( WithCurrentWorkBasket, TrackedModelDetailMixin, - generic.DetailView, + DetailView, ): """Base view class for displaying a single TrackedModel.""" diff --git a/pii-secret-exclude.txt b/pii-secret-exclude.txt index 4fdcdec14..0c2d0b618 100644 --- a/pii-secret-exclude.txt +++ b/pii-secret-exclude.txt @@ -27,3 +27,4 @@ Dockerfile common/jinja2/common/accessibility.jinja common/static/common/js/components/QuotaOriginFormset/tests/__snapshots__/index.test.js.snap common/migrations/0001_initial.py +common/jinja2/common/homepage.jinja diff --git a/workbaskets/tests/test_views.py b/workbaskets/tests/test_views.py index 89b086edf..7e84ccefb 100644 --- a/workbaskets/tests/test_views.py +++ b/workbaskets/tests/test_views.py @@ -1811,10 +1811,7 @@ def test_application_access_after_workbasket_delete( # user workbasket deletion. assert response.status_code == 200 - assert ( - f"Workbasket {workbasket_pk}" - in page.select("header nav a.workbasket-link")[0].text - ) + assert f"Workbasket ID {workbasket_pk}" in page.select("a.workbasket-link")[0].text user_empty_workbasket.delete() From 020065aeb046320122cea03222adba1a2c508c0e Mon Sep 17 00:00:00 2001 From: Dale Cannon <118175145+dalecannon@users.noreply.github.com> Date: Thu, 21 Mar 2024 11:32:04 +0000 Subject: [PATCH 2/4] TP2000-1255 Correct homepage card permission (#1182) * Correct homepage card permission --- common/jinja2/common/homepage.jinja | 4 ++++ common/tests/test_views.py | 2 +- common/views.py | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/common/jinja2/common/homepage.jinja b/common/jinja2/common/homepage.jinja index dd1c2da6f..b9292f30e 100644 --- a/common/jinja2/common/homepage.jinja +++ b/common/jinja2/common/homepage.jinja @@ -32,7 +32,9 @@ class="govuk-button homepage-workbasket-action" >Create a
workbasket + {% endif %} + {% if can_edit_workbasket %}
+ {% endif %} + {% if can_edit_workbasket %}

Currently working on

{% if not assigned_workbaskets %} diff --git a/common/tests/test_views.py b/common/tests/test_views.py index 0e07bc0ed..5eabe484a 100644 --- a/common/tests/test_views.py +++ b/common/tests/test_views.py @@ -181,7 +181,7 @@ def test_maintenance_mode_page_content(valid_user_client): ("card", "permission"), [ ("What would you like to do?", "view_workbasket"), - ("Currently working on", "view_workbasket"), + ("Currently working on", "change_workbasket"), ("EU TARIC files", "add_trackedmodel"), ("Envelopes", "consume_from_packaging_queue"), ("Resources", ""), diff --git a/common/views.py b/common/views.py index 12515bc93..e1d10bf12 100644 --- a/common/views.py +++ b/common/views.py @@ -97,6 +97,9 @@ def get_context_data(self, **kwargs): "can_add_workbasket": self.request.user.has_perm( "workbaskets.add_workbasket", ), + "can_edit_workbasket": self.request.user.has_perm( + "workbaskets.change_workbasket", + ), "can_view_reports": self.request.user.has_perm( "reports.view_report_index", ), From 91275a463e5cc27821473c2fcf8bfc513ba30b9c Mon Sep 17 00:00:00 2001 From: Panos Paralakis Date: Thu, 21 Mar 2024 11:47:00 +0000 Subject: [PATCH 3/4] Add codeowners file (#1183) --- CODEOWNERS | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..6ba4af0d2 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,5 @@ +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, +# @tamato will be requested for +# review when someone opens a pull request. +* @uktrade/tamato From 7ca6977b8268d579782df38b5e6fd928963c0095 Mon Sep 17 00:00:00 2001 From: Paul Pepper <85895113+paulpepper-trade@users.noreply.github.com> Date: Fri, 22 Mar 2024 10:31:49 +0000 Subject: [PATCH 4/4] Alter field from DecimalField to FloatField. (#1186) Co-authored-by: "Dale Cannon" --- ..._alter_trackedmodelcheck_processing_time.py | 18 ++++++++++++++++++ checks/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 checks/migrations/0008_alter_trackedmodelcheck_processing_time.py diff --git a/checks/migrations/0008_alter_trackedmodelcheck_processing_time.py b/checks/migrations/0008_alter_trackedmodelcheck_processing_time.py new file mode 100644 index 000000000..6dcb9acf5 --- /dev/null +++ b/checks/migrations/0008_alter_trackedmodelcheck_processing_time.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.10 on 2024-03-21 18:05 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("checks", "0007_transactioncheck_timestamps"), + ] + + operations = [ + migrations.AlterField( + model_name="trackedmodelcheck", + name="processing_time", + field=models.FloatField(null=True), + ), + ] diff --git a/checks/models.py b/checks/models.py index 0bec9a901..2904084b4 100644 --- a/checks/models.py +++ b/checks/models.py @@ -143,7 +143,7 @@ class TrackedModelCheck(TimestampedMixin): message = fields.TextField(null=True) """The text content returned by the check, if any.""" - processing_time = fields.DecimalField(null=True, decimal_places=4, max_digits=6) + processing_time = fields.FloatField(null=True) """Added processing_time used to record the time taken to process a rule in seconds."""