From a2c3a6805eea81523ee58e59e73dcf637f66fb88 Mon Sep 17 00:00:00 2001 From: Anthony Hashemi Date: Fri, 20 Oct 2023 17:22:07 +0100 Subject: [PATCH 1/8] Integrate opensearch with poc search view --- app/__init__.py | 5 + app/main/aws/__init__.py | 0 app/main/aws/parameter.py | 27 +++ app/main/routes.py | 31 +-- app/main/search/__init__.py | 0 app/main/search/open_search.py | 101 +++++++++ app/templates/main/poc-search.html | 10 +- config.py | 1 + poetry.lock | 326 +++++++++++++++++++---------- pyproject.toml | 3 + requirements.txt | 3 + 11 files changed, 369 insertions(+), 138 deletions(-) create mode 100644 app/main/aws/__init__.py create mode 100644 app/main/aws/parameter.py create mode 100644 app/main/search/__init__.py create mode 100644 app/main/search/open_search.py diff --git a/app/__init__.py b/app/__init__.py index 7a3dc883..db4184e7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,4 @@ +import boto3 from flask import Flask from flask_assets import Bundle, Environment from flask_compress import Compress @@ -25,6 +26,10 @@ def null_to_dash(value): def create_app(config_class=Config): app = Flask(__name__, static_url_path="/assets") app.config.from_object(config_class) + # use only for local testing + DEFAULT_AWS_PROFILE = app.config["DEFAULT_AWS_PROFILE"] + if app.config["DEFAULT_AWS_PROFILE"]: + boto3.setup_default_session(profile_name=DEFAULT_AWS_PROFILE) app.jinja_env.lstrip_blocks = True app.jinja_env.trim_blocks = True app.jinja_loader = ChoiceLoader( diff --git a/app/main/aws/__init__.py b/app/main/aws/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/main/aws/parameter.py b/app/main/aws/parameter.py new file mode 100644 index 00000000..c9468c27 --- /dev/null +++ b/app/main/aws/parameter.py @@ -0,0 +1,27 @@ +import boto3 +from botocore.exceptions import ClientError +import logging + + +def get_aws_environment_prefix() -> str: + environment_name = get_parameter_store_key_value("ENVIRONMENT_NAME") + print(environment_name) + return "/" + environment_name + "/" + + +def get_parameter_store_key_value(key: str) -> str: + """ + Get string value of `key` in Parameter Store. + :param key: Name of key whose value will be returned. + :return: String value of requested Parameter Store key. + """ + ssm_client = boto3.client("ssm") + parameter_value = "" + try: + parameter_value = ssm_client.get_parameter(Name=key)["Parameter"]["Value"] + logging.info("Parameter value retrieved successfully") + except ClientError as e: + logging.error( + "Failed to get parameter value, Error : %s", e.response["Error"]["Code"] + ) + return parameter_value diff --git a/app/main/routes.py b/app/main/routes.py index 4c206004..93377ade 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -8,6 +8,8 @@ session, ) from flask_wtf.csrf import CSRFError + +from app.main.search import open_search from .forms import SearchForm from werkzeug.exceptions import HTTPException import os @@ -15,8 +17,6 @@ from app.main import bp from app.main.forms import CookiesForm -# from app.data.data import consignment_response, consignment_files_response - from keycloak import KeycloakOpenID KEYCLOAK_BASE_URI = os.getenv("KEYCLOAK_BASE_URI") @@ -118,19 +118,15 @@ def poc_search(): form = SearchForm() results = [] query = request.form.get("query", "").lower() + if query: - search_terms = query.split() - for term in search_terms: - results.extend( - [ - record - for record in sample_records - if term in record["title"].lower() - or term in record["description"].lower() - or term in record["status"].lower() - ] - ) + open_search_response = ( + open_search.generate_open_search_client_and_make_poc_search(query) + ) + results = open_search_response["hits"]["hits"] + num_records_found = len(results) + return render_template( "poc-search.html", form=form, @@ -149,15 +145,6 @@ def quick_access(): return render_template("quick-access.html") -# @bp.route("/record", methods=["GET"]) -# def record(): -# return render_template( -# "record.html", -# consignment=consignment_response, -# consignment_files=consignment_files_response, -# ) - - @bp.route("/all-departments", methods=["GET"]) def departments(): return render_template("departments.html") diff --git a/app/main/search/__init__.py b/app/main/search/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/main/search/open_search.py b/app/main/search/open_search.py new file mode 100644 index 00000000..055d9912 --- /dev/null +++ b/app/main/search/open_search.py @@ -0,0 +1,101 @@ +import logging +from typing import Any, List, Tuple + +import boto3 +from opensearchpy import AWSV4SignerAuth, ImproperlyConfigured, OpenSearch + +from app.main.aws import parameter + +AWS_ENVIRONMENT_PREFIX = parameter.get_aws_environment_prefix() + + +def generate_open_search_client_and_make_poc_search(query: str) -> Any: + open_search_client = generate_open_search_client_from_aws_params() + open_search_index = parameter.get_parameter_store_key_value( + AWS_ENVIRONMENT_PREFIX + "AWS_OPEN_SEARCH_INDEX" + ) + fields = [ + "legal_status", + "description", + "closure_type", + "Internal-Sender_Identifier", + "id", + "Contact_Email", + "Source_Organization", + "Consignment_Series.keyword", + "Consignment_Series", + "Contact_Name", + ] + open_search_response = make_multi_match_fuzzy_search( + open_search_client, query, open_search_index, fields + ) + return open_search_response + + +def make_multi_match_fuzzy_search( + open_search: OpenSearch, search_text: str, index: str, fields: List[str] +): + open_search_query = { + "query": { + "multi_match": { + "query": search_text, + "fields": fields, + "fuzziness": "AUTO", + "type": "best_fields", + } + } + } + search_results = open_search.search(body=open_search_query, index=index) + return search_results + + +def generate_open_search_client_from_aws_params() -> OpenSearch: + host = parameter.get_parameter_store_key_value( + AWS_ENVIRONMENT_PREFIX + "AWS_OPEN_SEARCH_HOST" + ) + http_auth = _get_open_search_http_auth() + + open_search_client = OpenSearch( + hosts=[{"host": host, "port": 443}], + http_auth=http_auth, + use_ssl=True, + verify_certs=True, + http_compress=True, # enables gzip compression for request bodies + ssl_assert_hostname=False, + ssl_show_warn=True, + ) + try: + open_search_client.ping() + except ImproperlyConfigured as e: + logging.error("OpenSearch client improperly configured: " + str(e)) + + logging.info("OpenSearch client has been connected successfully") + return open_search_client + + +def _get_open_search_http_auth( + auth_method: str = "username_password", +) -> Tuple[str, str] | AWSV4SignerAuth: + if auth_method == "username_password": + return _get_open_search_username_password_auth() + return _get_open_search_iam_auth() + + +def _get_open_search_username_password_auth() -> Tuple[str, str]: + username = parameter.get_parameter_store_key_value( + AWS_ENVIRONMENT_PREFIX + "AWS_OPEN_SEARCH_USERNAME" + ) + password = parameter.get_parameter_store_key_value( + AWS_ENVIRONMENT_PREFIX + "AWS_OPEN_SEARCH_PASSWORD" + ) + return (username, password) + + +def _get_open_search_iam_auth() -> AWSV4SignerAuth: + credentials = boto3.Session().get_credentials() + aws_region = parameter.get_parameter_store_key_value( + AWS_ENVIRONMENT_PREFIX + "AWS_REGION" + ) + service = "es" + aws_auth = AWSV4SignerAuth(credentials, aws_region, service) + return aws_auth diff --git a/app/templates/main/poc-search.html b/app/templates/main/poc-search.html index 61390577..1ec4ccda 100644 --- a/app/templates/main/poc-search.html +++ b/app/templates/main/poc-search.html @@ -54,11 +54,11 @@

{{ num_records_found}} records found

{% for record in results %} - {{ record.title }} - {{ record.description }} - {{ record.last_modified }} - {{ record.status }} - {{ record.closure_period_years }} + {{ record._source.file_name }} + {{ record._source.description }} + {{ record._source.date_last_modified }} + {{ record._source.legal_status }} + {{ record._source.closure_period }} {% endfor %} diff --git a/config.py b/config.py index 7aae0aeb..c6b4ac4d 100644 --- a/config.py +++ b/config.py @@ -14,3 +14,4 @@ class Config(object): SERVICE_URL = os.environ.get("SERVICE_URL", "") SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SECURE = True + DEFAULT_AWS_PROFILE = os.getenv("DEFAULT_AWS_PROFILE") diff --git a/poetry.lock b/poetry.lock index 4bdf9056..af5971a8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -107,6 +107,44 @@ files = [ {file = "blinker-1.6.3.tar.gz", hash = "sha256:152090d27c1c5c722ee7e48504b02d76502811ce02e1523553b4cf8c8b3d3a8d"}, ] +[[package]] +name = "boto3" +version = "1.28.64" +description = "The AWS SDK for Python" +optional = false +python-versions = ">= 3.7" +files = [ + {file = "boto3-1.28.64-py3-none-any.whl", hash = "sha256:a99150a30c038c73e89662836820a8cce914afab5ea377942a37c484b85f4438"}, + {file = "boto3-1.28.64.tar.gz", hash = "sha256:a5cf93b202568e9d378afdc84be55a6dedf11d30156289fe829e23e6d7dccabb"}, +] + +[package.dependencies] +botocore = ">=1.31.64,<1.32.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.7.0,<0.8.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.31.64" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">= 3.7" +files = [ + {file = "botocore-1.31.64-py3-none-any.whl", hash = "sha256:7b709310343a5b430ec9025b2e17c0bac6b16c05f1ac1d9521dece3f10c71bac"}, + {file = "botocore-1.31.64.tar.gz", hash = "sha256:d8eb4b724ac437343359b318d73de0cfae0fecb24095827e56135b0ad6b44caf"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.1", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.16.26)"] + [[package]] name = "brotli" version = "1.1.0" @@ -337,101 +375,101 @@ pycparser = "*" [[package]] name = "charset-normalizer" -version = "3.3.0" +version = "3.3.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.3.0.tar.gz", hash = "sha256:63563193aec44bce707e0c5ca64ff69fa72ed7cf34ce6e11d5127555756fd2f6"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:effe5406c9bd748a871dbcaf3ac69167c38d72db8c9baf3ff954c344f31c4cbe"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4162918ef3098851fcd8a628bf9b6a98d10c380725df9e04caf5ca6dd48c847a"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0570d21da019941634a531444364f2482e8db0b3425fcd5ac0c36565a64142c8"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5707a746c6083a3a74b46b3a631d78d129edab06195a92a8ece755aac25a3f3d"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:278c296c6f96fa686d74eb449ea1697f3c03dc28b75f873b65b5201806346a69"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a4b71f4d1765639372a3b32d2638197f5cd5221b19531f9245fcc9ee62d38f56"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5969baeaea61c97efa706b9b107dcba02784b1601c74ac84f2a532ea079403e"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3f93dab657839dfa61025056606600a11d0b696d79386f974e459a3fbc568ec"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:db756e48f9c5c607b5e33dd36b1d5872d0422e960145b08ab0ec7fd420e9d649"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:232ac332403e37e4a03d209a3f92ed9071f7d3dbda70e2a5e9cff1c4ba9f0678"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e5c1502d4ace69a179305abb3f0bb6141cbe4714bc9b31d427329a95acfc8bdd"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:2502dd2a736c879c0f0d3e2161e74d9907231e25d35794584b1ca5284e43f596"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23e8565ab7ff33218530bc817922fae827420f143479b753104ab801145b1d5b"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-win32.whl", hash = "sha256:1872d01ac8c618a8da634e232f24793883d6e456a66593135aeafe3784b0848d"}, - {file = "charset_normalizer-3.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:557b21a44ceac6c6b9773bc65aa1b4cc3e248a5ad2f5b914b91579a32e22204d"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d7eff0f27edc5afa9e405f7165f85a6d782d308f3b6b9d96016c010597958e63"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a685067d05e46641d5d1623d7c7fdf15a357546cbb2f71b0ebde91b175ffc3e"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d3d5b7db9ed8a2b11a774db2bbea7ba1884430a205dbd54a32d61d7c2a190fa"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2935ffc78db9645cb2086c2f8f4cfd23d9b73cc0dc80334bc30aac6f03f68f8c"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fe359b2e3a7729010060fbca442ca225280c16e923b37db0e955ac2a2b72a05"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380c4bde80bce25c6e4f77b19386f5ec9db230df9f2f2ac1e5ad7af2caa70459"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0d1e3732768fecb052d90d62b220af62ead5748ac51ef61e7b32c266cac9293"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b2919306936ac6efb3aed1fbf81039f7087ddadb3160882a57ee2ff74fd2382"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f8888e31e3a85943743f8fc15e71536bda1c81d5aa36d014a3c0c44481d7db6e"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:82eb849f085624f6a607538ee7b83a6d8126df6d2f7d3b319cb837b289123078"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7b8b8bf1189b3ba9b8de5c8db4d541b406611a71a955bbbd7385bbc45fcb786c"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5adf257bd58c1b8632046bbe43ee38c04e1038e9d37de9c57a94d6bd6ce5da34"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c350354efb159b8767a6244c166f66e67506e06c8924ed74669b2c70bc8735b1"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-win32.whl", hash = "sha256:02af06682e3590ab952599fbadac535ede5d60d78848e555aa58d0c0abbde786"}, - {file = "charset_normalizer-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:86d1f65ac145e2c9ed71d8ffb1905e9bba3a91ae29ba55b4c46ae6fc31d7c0d4"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3b447982ad46348c02cb90d230b75ac34e9886273df3a93eec0539308a6296d7"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:abf0d9f45ea5fb95051c8bfe43cb40cda383772f7e5023a83cc481ca2604d74e"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b09719a17a2301178fac4470d54b1680b18a5048b481cb8890e1ef820cb80455"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3d9b48ee6e3967b7901c052b670c7dda6deb812c309439adaffdec55c6d7b78"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:edfe077ab09442d4ef3c52cb1f9dab89bff02f4524afc0acf2d46be17dc479f5"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3debd1150027933210c2fc321527c2299118aa929c2f5a0a80ab6953e3bd1908"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86f63face3a527284f7bb8a9d4f78988e3c06823f7bea2bd6f0e0e9298ca0403"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24817cb02cbef7cd499f7c9a2735286b4782bd47a5b3516a0e84c50eab44b98e"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c71f16da1ed8949774ef79f4a0260d28b83b3a50c6576f8f4f0288d109777989"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9cf3126b85822c4e53aa28c7ec9869b924d6fcfb76e77a45c44b83d91afd74f9"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:b3b2316b25644b23b54a6f6401074cebcecd1244c0b8e80111c9a3f1c8e83d65"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:03680bb39035fbcffe828eae9c3f8afc0428c91d38e7d61aa992ef7a59fb120e"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cc152c5dd831641e995764f9f0b6589519f6f5123258ccaca8c6d34572fefa8"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-win32.whl", hash = "sha256:b8f3307af845803fb0b060ab76cf6dd3a13adc15b6b451f54281d25911eb92df"}, - {file = "charset_normalizer-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:8eaf82f0eccd1505cf39a45a6bd0a8cf1c70dcfc30dba338207a969d91b965c0"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dc45229747b67ffc441b3de2f3ae5e62877a282ea828a5bdb67883c4ee4a8810"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f4a0033ce9a76e391542c182f0d48d084855b5fcba5010f707c8e8c34663d77"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ada214c6fa40f8d800e575de6b91a40d0548139e5dc457d2ebb61470abf50186"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1121de0e9d6e6ca08289583d7491e7fcb18a439305b34a30b20d8215922d43c"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1063da2c85b95f2d1a430f1c33b55c9c17ffaf5e612e10aeaad641c55a9e2b9d"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70f1d09c0d7748b73290b29219e854b3207aea922f839437870d8cc2168e31cc"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:250c9eb0f4600361dd80d46112213dff2286231d92d3e52af1e5a6083d10cad9"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:750b446b2ffce1739e8578576092179160f6d26bd5e23eb1789c4d64d5af7dc7"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:fc52b79d83a3fe3a360902d3f5d79073a993597d48114c29485e9431092905d8"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:588245972aca710b5b68802c8cad9edaa98589b1b42ad2b53accd6910dad3545"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e39c7eb31e3f5b1f88caff88bcff1b7f8334975b46f6ac6e9fc725d829bc35d4"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-win32.whl", hash = "sha256:abecce40dfebbfa6abf8e324e1860092eeca6f7375c8c4e655a8afb61af58f2c"}, - {file = "charset_normalizer-3.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a91a981f185721542a0b7c92e9054b7ab4fea0508a795846bc5b0abf8118d4"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:67b8cc9574bb518ec76dc8e705d4c39ae78bb96237cb533edac149352c1f39fe"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac71b2977fb90c35d41c9453116e283fac47bb9096ad917b8819ca8b943abecd"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3ae38d325b512f63f8da31f826e6cb6c367336f95e418137286ba362925c877e"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:542da1178c1c6af8873e143910e2269add130a299c9106eef2594e15dae5e482"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30a85aed0b864ac88309b7d94be09f6046c834ef60762a8833b660139cfbad13"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aae32c93e0f64469f74ccc730a7cb21c7610af3a775157e50bbd38f816536b38"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b26ddf78d57f1d143bdf32e820fd8935d36abe8a25eb9ec0b5a71c82eb3895"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f5d10bae5d78e4551b7be7a9b29643a95aded9d0f602aa2ba584f0388e7a557"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:249c6470a2b60935bafd1d1d13cd613f8cd8388d53461c67397ee6a0f5dce741"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c5a74c359b2d47d26cdbbc7845e9662d6b08a1e915eb015d044729e92e7050b7"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:b5bcf60a228acae568e9911f410f9d9e0d43197d030ae5799e20dca8df588287"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:187d18082694a29005ba2944c882344b6748d5be69e3a89bf3cc9d878e548d5a"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:81bf654678e575403736b85ba3a7867e31c2c30a69bc57fe88e3ace52fb17b89"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-win32.whl", hash = "sha256:85a32721ddde63c9df9ebb0d2045b9691d9750cb139c161c80e500d210f5e26e"}, - {file = "charset_normalizer-3.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:468d2a840567b13a590e67dd276c570f8de00ed767ecc611994c301d0f8c014f"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e0fc42822278451bc13a2e8626cf2218ba570f27856b536e00cfa53099724828"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:09c77f964f351a7369cc343911e0df63e762e42bac24cd7d18525961c81754f4"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:12ebea541c44fdc88ccb794a13fe861cc5e35d64ed689513a5c03d05b53b7c82"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:805dfea4ca10411a5296bcc75638017215a93ffb584c9e344731eef0dcfb026a"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96c2b49eb6a72c0e4991d62406e365d87067ca14c1a729a870d22354e6f68115"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaf7b34c5bc56b38c931a54f7952f1ff0ae77a2e82496583b247f7c969eb1479"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:619d1c96099be5823db34fe89e2582b336b5b074a7f47f819d6b3a57ff7bdb86"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a0ac5e7015a5920cfce654c06618ec40c33e12801711da6b4258af59a8eff00a"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:93aa7eef6ee71c629b51ef873991d6911b906d7312c6e8e99790c0f33c576f89"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7966951325782121e67c81299a031f4c115615e68046f79b85856b86ebffc4cd"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:02673e456dc5ab13659f85196c534dc596d4ef260e4d86e856c3b2773ce09843"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:c2af80fb58f0f24b3f3adcb9148e6203fa67dd3f61c4af146ecad033024dde43"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:153e7b6e724761741e0974fc4dcd406d35ba70b92bfe3fedcb497226c93b9da7"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-win32.whl", hash = "sha256:d47ecf253780c90ee181d4d871cd655a789da937454045b17b5798da9393901a"}, - {file = "charset_normalizer-3.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:d97d85fa63f315a8bdaba2af9a6a686e0eceab77b3089af45133252618e70884"}, - {file = "charset_normalizer-3.3.0-py3-none-any.whl", hash = "sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2"}, + {file = "charset-normalizer-3.3.1.tar.gz", hash = "sha256:d9137a876020661972ca6eec0766d81aef8a5627df628b664b234b73396e727e"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8aee051c89e13565c6bd366813c386939f8e928af93c29fda4af86d25b73d8f8"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:352a88c3df0d1fa886562384b86f9a9e27563d4704ee0e9d56ec6fcd270ea690"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:223b4d54561c01048f657fa6ce41461d5ad8ff128b9678cfe8b2ecd951e3f8a2"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f861d94c2a450b974b86093c6c027888627b8082f1299dfd5a4bae8e2292821"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1171ef1fc5ab4693c5d151ae0fdad7f7349920eabbaca6271f95969fa0756c2d"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28f512b9a33235545fbbdac6a330a510b63be278a50071a336afc1b78781b147"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0e842112fe3f1a4ffcf64b06dc4c61a88441c2f02f373367f7b4c1aa9be2ad5"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3f9bc2ce123637a60ebe819f9fccc614da1bcc05798bbbaf2dd4ec91f3e08846"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f194cce575e59ffe442c10a360182a986535fd90b57f7debfaa5c845c409ecc3"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9a74041ba0bfa9bc9b9bb2cd3238a6ab3b7618e759b41bd15b5f6ad958d17605"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:b578cbe580e3b41ad17b1c428f382c814b32a6ce90f2d8e39e2e635d49e498d1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6db3cfb9b4fcecb4390db154e75b49578c87a3b9979b40cdf90d7e4b945656e1"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:debb633f3f7856f95ad957d9b9c781f8e2c6303ef21724ec94bea2ce2fcbd056"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win32.whl", hash = "sha256:87071618d3d8ec8b186d53cb6e66955ef2a0e4fa63ccd3709c0c90ac5a43520f"}, + {file = "charset_normalizer-3.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:e372d7dfd154009142631de2d316adad3cc1c36c32a38b16a4751ba78da2a397"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae4070f741f8d809075ef697877fd350ecf0b7c5837ed68738607ee0a2c572cf"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58e875eb7016fd014c0eea46c6fa92b87b62c0cb31b9feae25cbbe62c919f54d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbd95e300367aa0827496fe75a1766d198d34385a58f97683fe6e07f89ca3e3c"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de0b4caa1c8a21394e8ce971997614a17648f94e1cd0640fbd6b4d14cab13a72"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:985c7965f62f6f32bf432e2681173db41336a9c2611693247069288bcb0c7f8b"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a15c1fe6d26e83fd2e5972425a772cca158eae58b05d4a25a4e474c221053e2d"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae55d592b02c4349525b6ed8f74c692509e5adffa842e582c0f861751701a673"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:be4d9c2770044a59715eb57c1144dedea7c5d5ae80c68fb9959515037cde2008"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:851cf693fb3aaef71031237cd68699dded198657ec1e76a76eb8be58c03a5d1f"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:31bbaba7218904d2eabecf4feec0d07469284e952a27400f23b6628439439fa7"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:871d045d6ccc181fd863a3cd66ee8e395523ebfbc57f85f91f035f50cee8e3d4"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:501adc5eb6cd5f40a6f77fbd90e5ab915c8fd6e8c614af2db5561e16c600d6f3"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f5fb672c396d826ca16a022ac04c9dce74e00a1c344f6ad1a0fdc1ba1f332213"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win32.whl", hash = "sha256:bb06098d019766ca16fc915ecaa455c1f1cd594204e7f840cd6258237b5079a8"}, + {file = "charset_normalizer-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:8af5a8917b8af42295e86b64903156b4f110a30dca5f3b5aedea123fbd638bff"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7ae8e5142dcc7a49168f4055255dbcced01dc1714a90a21f87448dc8d90617d1"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5b70bab78accbc672f50e878a5b73ca692f45f5b5e25c8066d748c09405e6a55"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5ceca5876032362ae73b83347be8b5dbd2d1faf3358deb38c9c88776779b2e2f"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d95638ff3613849f473afc33f65c401a89f3b9528d0d213c7037c398a51296"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9edbe6a5bf8b56a4a84533ba2b2f489d0046e755c29616ef8830f9e7d9cf5728"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6a02a3c7950cafaadcd46a226ad9e12fc9744652cc69f9e5534f98b47f3bbcf"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10b8dd31e10f32410751b3430996f9807fc4d1587ca69772e2aa940a82ab571a"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edc0202099ea1d82844316604e17d2b175044f9bcb6b398aab781eba957224bd"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b891a2f68e09c5ef989007fac11476ed33c5c9994449a4e2c3386529d703dc8b"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:71ef3b9be10070360f289aea4838c784f8b851be3ba58cf796262b57775c2f14"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:55602981b2dbf8184c098bc10287e8c245e351cd4fdcad050bd7199d5a8bf514"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:46fb9970aa5eeca547d7aa0de5d4b124a288b42eaefac677bde805013c95725c"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:520b7a142d2524f999447b3a0cf95115df81c4f33003c51a6ab637cbda9d0bf4"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win32.whl", hash = "sha256:8ec8ef42c6cd5856a7613dcd1eaf21e5573b2185263d87d27c8edcae33b62a61"}, + {file = "charset_normalizer-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:baec8148d6b8bd5cee1ae138ba658c71f5b03e0d69d5907703e3e1df96db5e41"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:63a6f59e2d01310f754c270e4a257426fe5a591dc487f1983b3bbe793cf6bac6"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d6bfc32a68bc0933819cfdfe45f9abc3cae3877e1d90aac7259d57e6e0f85b1"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f3100d86dcd03c03f7e9c3fdb23d92e32abbca07e7c13ebd7ddfbcb06f5991f"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39b70a6f88eebe239fa775190796d55a33cfb6d36b9ffdd37843f7c4c1b5dc67"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e12f8ee80aa35e746230a2af83e81bd6b52daa92a8afaef4fea4a2ce9b9f4fa"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b6cefa579e1237ce198619b76eaa148b71894fb0d6bcf9024460f9bf30fd228"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:61f1e3fb621f5420523abb71f5771a204b33c21d31e7d9d86881b2cffe92c47c"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4f6e2a839f83a6a76854d12dbebde50e4b1afa63e27761549d006fa53e9aa80e"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:1ec937546cad86d0dce5396748bf392bb7b62a9eeb8c66efac60e947697f0e58"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:82ca51ff0fc5b641a2d4e1cc8c5ff108699b7a56d7f3ad6f6da9dbb6f0145b48"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:633968254f8d421e70f91c6ebe71ed0ab140220469cf87a9857e21c16687c034"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win32.whl", hash = "sha256:c0c72d34e7de5604df0fde3644cc079feee5e55464967d10b24b1de268deceb9"}, + {file = "charset_normalizer-3.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:63accd11149c0f9a99e3bc095bbdb5a464862d77a7e309ad5938fbc8721235ae"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5a3580a4fdc4ac05f9e53c57f965e3594b2f99796231380adb2baaab96e22761"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2465aa50c9299d615d757c1c888bc6fef384b7c4aec81c05a0172b4400f98557"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb7cd68814308aade9d0c93c5bd2ade9f9441666f8ba5aa9c2d4b389cb5e2a45"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e43805ccafa0a91831f9cd5443aa34528c0c3f2cc48c4cb3d9a7721053874b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:854cc74367180beb327ab9d00f964f6d91da06450b0855cbbb09187bcdb02de5"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c15070ebf11b8b7fd1bfff7217e9324963c82dbdf6182ff7050519e350e7ad9f"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4c99f98fc3a1835af8179dcc9013f93594d0670e2fa80c83aa36346ee763d2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fb765362688821404ad6cf86772fc54993ec11577cd5a92ac44b4c2ba52155b"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dced27917823df984fe0c80a5c4ad75cf58df0fbfae890bc08004cd3888922a2"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a66bcdf19c1a523e41b8e9d53d0cedbfbac2e93c649a2e9502cb26c014d0980c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ecd26be9f112c4f96718290c10f4caea6cc798459a3a76636b817a0ed7874e42"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:3f70fd716855cd3b855316b226a1ac8bdb3caf4f7ea96edcccc6f484217c9597"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:17a866d61259c7de1bdadef418a37755050ddb4b922df8b356503234fff7932c"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win32.whl", hash = "sha256:548eefad783ed787b38cb6f9a574bd8664468cc76d1538215d510a3cd41406cb"}, + {file = "charset_normalizer-3.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:45f053a0ece92c734d874861ffe6e3cc92150e32136dd59ab1fb070575189c97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bc791ec3fd0c4309a753f95bb6c749ef0d8ea3aea91f07ee1cf06b7b02118f2f"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0c8c61fb505c7dad1d251c284e712d4e0372cef3b067f7ddf82a7fa82e1e9a93"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2c092be3885a1b7899cd85ce24acedc1034199d6fca1483fa2c3a35c86e43041"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2000c54c395d9e5e44c99dc7c20a64dc371f777faf8bae4919ad3e99ce5253e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cb50a0335382aac15c31b61d8531bc9bb657cfd848b1d7158009472189f3d62"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c30187840d36d0ba2893bc3271a36a517a717f9fd383a98e2697ee890a37c273"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe81b35c33772e56f4b6cf62cf4aedc1762ef7162a31e6ac7fe5e40d0149eb67"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0bf89afcbcf4d1bb2652f6580e5e55a840fdf87384f6063c4a4f0c95e378656"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:06cf46bdff72f58645434d467bf5228080801298fbba19fe268a01b4534467f5"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:3c66df3f41abee950d6638adc7eac4730a306b022570f71dd0bd6ba53503ab57"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd805513198304026bd379d1d516afbf6c3c13f4382134a2c526b8b854da1c2e"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:9505dc359edb6a330efcd2be825fdb73ee3e628d9010597aa1aee5aa63442e97"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:31445f38053476a0c4e6d12b047b08ced81e2c7c712e5a1ad97bc913256f91b2"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win32.whl", hash = "sha256:bd28b31730f0e982ace8663d108e01199098432a30a4c410d06fe08fdb9e93f4"}, + {file = "charset_normalizer-3.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:555fe186da0068d3354cdf4bbcbc609b0ecae4d04c921cc13e209eece7720727"}, + {file = "charset_normalizer-3.3.1-py3-none-any.whl", hash = "sha256:800561453acdecedaac137bf09cd719c7a440b6800ec182f077bb8e7025fb708"}, ] [[package]] @@ -800,13 +838,13 @@ email = ["email-validator"] [[package]] name = "gitdb" -version = "4.0.10" +version = "4.0.11" description = "Git Object Database" optional = false python-versions = ">=3.7" files = [ - {file = "gitdb-4.0.10-py3-none-any.whl", hash = "sha256:c286cf298426064079ed96a9e4a9d39e7f3e9bf15ba60701e95f5492f28415c7"}, - {file = "gitdb-4.0.10.tar.gz", hash = "sha256:6eb990b69df4e15bad899ea868dc46572c3f75339735663b81de79b06f17eb9a"}, + {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"}, + {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"}, ] [package.dependencies] @@ -814,13 +852,13 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.38" +version = "3.1.40" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" files = [ - {file = "GitPython-3.1.38-py3-none-any.whl", hash = "sha256:9e98b672ffcb081c2c8d5aa630d4251544fb040fb158863054242f24a2a2ba30"}, - {file = "GitPython-3.1.38.tar.gz", hash = "sha256:4d683e8957c8998b58ddb937e3e6cd167215a180e1ffd4da769ab81c620a89fe"}, + {file = "GitPython-3.1.40-py3-none-any.whl", hash = "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a"}, + {file = "GitPython-3.1.40.tar.gz", hash = "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4"}, ] [package.dependencies] @@ -1039,6 +1077,17 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jmespath" +version = "1.0.1" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980"}, + {file = "jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe"}, +] + [[package]] name = "jsmin" version = "3.0.1" @@ -1194,6 +1243,30 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "opensearch-py" +version = "2.3.2" +description = "Python client for OpenSearch" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" +files = [ + {file = "opensearch-py-2.3.2.tar.gz", hash = "sha256:96e470b55107fd5bfd873722dc9808c333360eacfa174341f5cc2d021aa30448"}, + {file = "opensearch_py-2.3.2-py2.py3-none-any.whl", hash = "sha256:b1d6607380c8f19d90c142470939d051f0bac96069ce0ac25970b3c39c431f8b"}, +] + +[package.dependencies] +certifi = ">=2022.12.07" +python-dateutil = "*" +requests = ">=2.4.0,<3.0.0" +six = "*" +urllib3 = ">=1.26.9" + +[package.extras] +async = ["aiohttp (>=3,<4)"] +develop = ["black", "botocore", "coverage (<7.0.0)", "jinja2", "mock", "myst-parser", "pytest (>=3.0.0)", "pytest-cov", "pytest-mock (<4.0.0)", "pytz", "pyyaml", "requests (>=2.0.0,<3.0.0)", "sphinx", "sphinx-copybutton", "sphinx-rtd-theme"] +docs = ["myst-parser", "sphinx", "sphinx-copybutton", "sphinx-rtd-theme"] +kerberos = ["requests-kerberos"] + [[package]] name = "ordered-set" version = "4.1.0" @@ -1257,13 +1330,13 @@ flake8 = ">=5.0.0" [[package]] name = "pip" -version = "23.3" +version = "23.3.1" description = "The PyPA recommended tool for installing Python packages." optional = false python-versions = ">=3.7" files = [ - {file = "pip-23.3-py3-none-any.whl", hash = "sha256:bc38bb52bc286514f8f7cb3a1ba5ed100b76aaef29b521d48574329331c5ae7b"}, - {file = "pip-23.3.tar.gz", hash = "sha256:bb7d4f69f488432e4e96394612f43ab43dd478d073ef7422604a570f7157561e"}, + {file = "pip-23.3.1-py3-none-any.whl", hash = "sha256:55eb67bb6171d37447e82213be585b75fe2b12b359e993773aca4de9247a052b"}, + {file = "pip-23.3.1.tar.gz", hash = "sha256:1fcaa041308d01f14575f6d0d2ea4b75a3e2871fe4f9c694976f908768e14174"}, ] [[package]] @@ -1439,13 +1512,13 @@ files = [ [[package]] name = "pytest" -version = "7.4.2" +version = "7.4.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, - {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, + {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, + {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, ] [package.dependencies] @@ -1507,6 +1580,20 @@ pytest = ">=6.2.4,<8.0.0" pytest-base-url = ">=1.0.0,<3.0.0" python-slugify = ">=6.0.0,<9.0.0" +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "python-dotenv" version = "1.0.0" @@ -1715,20 +1802,20 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruamel-yaml" -version = "0.17.35" +version = "0.18.2" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" optional = false python-versions = ">=3" files = [ - {file = "ruamel.yaml-0.17.35-py3-none-any.whl", hash = "sha256:b105e3e6fc15b41fdb201ba1b95162ae566a4ef792b9f884c46b4ccc5513a87a"}, - {file = "ruamel.yaml-0.17.35.tar.gz", hash = "sha256:801046a9caacb1b43acc118969b49b96b65e8847f29029563b29ac61d02db61b"}, + {file = "ruamel.yaml-0.18.2-py3-none-any.whl", hash = "sha256:92076ac8a83dbf44ca661dbed3c935229c8cbc2f10b05959dd3bd5292d8353d3"}, + {file = "ruamel.yaml-0.18.2.tar.gz", hash = "sha256:9bce33f7a814cea4c29a9c62fe872d2363d6220b767891d956eacea8fa5e6fe8"}, ] [package.dependencies] "ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.13\""} [package.extras] -docs = ["ryd"] +docs = ["mercurial (>5.7)", "ryd"] jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] [[package]] @@ -1776,6 +1863,23 @@ files = [ {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"}, ] +[[package]] +name = "s3transfer" +version = "0.7.0" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">= 3.7" +files = [ + {file = "s3transfer-0.7.0-py3-none-any.whl", hash = "sha256:10d6923c6359175f264811ef4bf6161a3156ce8e350e705396a7557d6293c33a"}, + {file = "s3transfer-0.7.0.tar.gz", hash = "sha256:fd3889a66f5fe17299fe75b82eae6cf722554edca744ca5d5fe308b104883d2e"}, +] + +[package.dependencies] +botocore = ">=1.12.36,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] + [[package]] name = "safety" version = "2.3.4" @@ -1903,13 +2007,13 @@ files = [ [[package]] name = "werkzeug" -version = "3.0.0" +version = "3.0.1" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.8" files = [ - {file = "werkzeug-3.0.0-py3-none-any.whl", hash = "sha256:cbb2600f7eabe51dbc0502f58be0b3e1b96b893b05695ea2b35b43d4de2d9962"}, - {file = "werkzeug-3.0.0.tar.gz", hash = "sha256:3ffff4dcc32db52ef3cc94dff3000a3c2846890f3a5a51800a27b909c5e770f0"}, + {file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"}, + {file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"}, ] [package.dependencies] @@ -2036,4 +2140,4 @@ email = ["email-validator"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "638541e4899a745e28f3e989a3a2de7957bb398d5b1413c21d18a041cfaf1537" +content-hash = "d7a5767212fe05b2947cab42c144bdd32bbecf0a55efb851a41a571f85907422" diff --git a/pyproject.toml b/pyproject.toml index 17839592..169cea05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,9 @@ redis = "5.0.1" python-keycloak = "3.3.0" pytest-playwright = "0.4.3" flask-pytest = "^0.0.5" +boto3 = "1.28.64" +botocore = "1.31.64" +opensearch-py = "2.3.2" [tool.poetry.group.dev.dependencies] bandit = "1.7.5" diff --git a/requirements.txt b/requirements.txt index a9a7217c..31e9b099 100644 --- a/requirements.txt +++ b/requirements.txt @@ -102,3 +102,6 @@ wtforms==3.0.1 # govuk-frontend-wtf python-keycloak==3.3.0 pytest-playwright==0.4.3 +boto3==1.28.64 +botocore==1.31.64 +opensearch-py==2.3.2 From 53c422fba19d5c8d535a26841a0548f7caf3ba74 Mon Sep 17 00:00:00 2001 From: Anthony Hashemi Date: Mon, 23 Oct 2023 18:09:49 +0100 Subject: [PATCH 2/8] Add tests for poc-search-view --- app/main/routes.py | 25 ------- app/templates/main/poc-search.html | 11 +-- app/tests/test_search.py | 104 ++++++++++++++++++++++++++++- poetry.lock | 63 ++++++++++++++++- pyproject.toml | 2 + 5 files changed, 168 insertions(+), 37 deletions(-) diff --git a/app/main/routes.py b/app/main/routes.py index 93377ade..35287460 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -33,31 +33,6 @@ ) -sample_records = [ - { - "title": "1.2_record1.pdf", - "description": "⚊", - "last_modified": "2023-01-15", - "status": "Open", - "closure_period_years": "⚊", - }, - { - "title": "1.1_record2.doc", - "description": "⚊", - "last_modified": "2023-02-20", - "status": "Closed", - "closure_period_years": 50, - }, - { - "title": "record_3.jpg", - "description": "⚊", - "last_modified": "2023-09-23", - "status": "Closed", - "closure_period_years": 20, - }, -] - - @bp.route("/", methods=["GET"]) def index(): return render_template("index.html") diff --git a/app/templates/main/poc-search.html b/app/templates/main/poc-search.html index 1ec4ccda..1ff920c8 100644 --- a/app/templates/main/poc-search.html +++ b/app/templates/main/poc-search.html @@ -32,17 +32,9 @@

Search for digital records< {% if num_records_found > 0 %}
-

{{ num_records_found}} records found

+

{{ num_records_found }} records found

- - - - - - - - @@ -59,7 +51,6 @@

{{ num_records_found}} records found

- {% endfor %} diff --git a/app/tests/test_search.py b/app/tests/test_search.py index 3dac95ea..c28883c6 100644 --- a/app/tests/test_search.py +++ b/app/tests/test_search.py @@ -1,6 +1,7 @@ +from unittest.mock import patch +from bs4 import BeautifulSoup from flask.testing import FlaskClient - def test_poc_search_get(client: FlaskClient): """ Given a user accessing the search page @@ -13,3 +14,104 @@ def test_poc_search_get(client: FlaskClient): assert b"Search design PoC" in response.data assert b"Search for digital records" in response.data assert b"Search" in response.data + + +def test_poc_search_no_query(client: FlaskClient): + """ + Given a user accessing the search page + When they make a POST request without a query + Then they should not see any records found. + """ + form_data = {"foo": "bar"} + response = client.post("/poc-search-view", data=form_data, follow_redirects=True) + + assert response.status_code == 200 + assert b"records found" not in response.data + + +@patch("app.main.routes.open_search.generate_open_search_client_and_make_poc_search") +def test_poc_search_with_no_results(mock_open_search, client: FlaskClient): + """ + Given a user with a search query + When they make a request on the search page, and no results are found + Then they should see no records found. + """ + mock_open_search.return_value = {"hits": {"hits": []}} + + form_data = {"query": "test_query"} + response = client.post("/poc-search-view", data=form_data, follow_redirects=True) + + assert response.status_code == 200 + assert b"records found" not in response.data + + +@patch("app.main.routes.open_search.generate_open_search_client_and_make_poc_search") +def test_poc_search_results_displayed(mock_open_search, client: FlaskClient): + """ + Given a user with a search query which should return n results + When they make a request on the search page + Then a table is populated with the n results with metadata fields. + """ + mock_open_search.return_value = { + "hits": { + "hits": [ + { + "_source": { + "file_name": "Mocked Result 1", + "description": "Description 1", + "date_last_modified": "2023-02-27T12:28:08", + "legal_status": "Public Record(s)", + "closure_period": 20, + } + }, + { + "_source": { + "file_name": "Mocked Result 2", + "description": "Description 2", + "date_last_modified": "2023-02-27T12:28:13", + "legal_status": "Public Record(s)", + "closure_period": 5, + } + }, + ] + } + } + + form_data = {"query": "test_query"} + response = client.post("/poc-search-view", data=form_data, follow_redirects=True) + + assert response.status_code == 200 + assert b"2 records found" in response.data + + soup = BeautifulSoup(response.data, "html.parser") + table = soup.find("table", class_="govuk-table") + rows = table.find_all("tr", class_="govuk-table__row") + header_row = rows[0] + results_rows = rows[1:] + + headers = header_row.find_all("th") + + expected_results_table = [ + ["Title", "Description", "Last modified", "Status", "Closure period (years)"], + [ + "Mocked Result 1", + "Description 1", + "2023-02-27T12:28:08", + "Public Record(s)", + "20", + ], + [ + "Mocked Result 2", + "Description 2", + "2023-02-27T12:28:13", + "Public Record(s)", + "5", + ], + ] + + assert [header.text for header in headers] == expected_results_table[0] + for row_index, row in enumerate(results_rows): + assert [result.text for result in row.find_all("td")] == expected_results_table[ + row_index + 1 + ] + diff --git a/poetry.lock b/poetry.lock index af5971a8..f731b855 100644 --- a/poetry.lock +++ b/poetry.lock @@ -52,6 +52,24 @@ test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", toml = ["tomli (>=1.1.0)"] yaml = ["PyYAML"] +[[package]] +name = "beautifulsoup4" +version = "4.12.2" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a"}, + {file = "beautifulsoup4-4.12.2.tar.gz", hash = "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "black" version = "23.9.1" @@ -276,6 +294,19 @@ files = [ [package.dependencies] cffi = ">=1.0.0" +[[package]] +name = "bs4" +version = "0.0.1" +description = "Dummy package for Beautiful Soup" +optional = false +python-versions = "*" +files = [ + {file = "bs4-0.0.1.tar.gz", hash = "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a"}, +] + +[package.dependencies] +beautifulsoup4 = "*" + [[package]] name = "build" version = "1.0.3" @@ -1563,6 +1594,25 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +[[package]] +name = "pytest-flask" +version = "1.3.0" +description = "A set of py.test fixtures to test Flask applications." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e"}, + {file = "pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253"}, +] + +[package.dependencies] +Flask = "*" +pytest = ">=5.2" +Werkzeug = "*" + +[package.extras] +docs = ["Sphinx", "sphinx-rtd-theme"] + [[package]] name = "pytest-playwright" version = "0.4.3" @@ -1941,6 +1991,17 @@ files = [ {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"}, ] +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, + {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, +] + [[package]] name = "stevedore" version = "5.1.0" @@ -2140,4 +2201,4 @@ email = ["email-validator"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d7a5767212fe05b2947cab42c144bdd32bbecf0a55efb851a41a571f85907422" +content-hash = "2cf2d73ffc44a7cb797904133bd55fb58b4850809bf8a1c6f5ffd4a54e48da0f" diff --git a/pyproject.toml b/pyproject.toml index 169cea05..25361b3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ flask-pytest = "^0.0.5" boto3 = "1.28.64" botocore = "1.31.64" opensearch-py = "2.3.2" +bs4 = "^0.0.1" [tool.poetry.group.dev.dependencies] bandit = "1.7.5" @@ -40,6 +41,7 @@ pip-tools = "7.3.0" pur = "7.3.1" pytest-cov = "4.1.0" safety = "2.3.4" +pytest-flask = "^1.2.0" [build-system] requires = ["poetry-core"] From 3196280d3668457d77d4b8fd79bf0e8abfb14876 Mon Sep 17 00:00:00 2001 From: Anthony Hashemi Date: Mon, 23 Oct 2023 20:11:06 +0100 Subject: [PATCH 3/8] Add a playwight e2e test for poc_search view --- e2e_tests/test_poc_search.py | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 e2e_tests/test_poc_search.py diff --git a/e2e_tests/test_poc_search.py b/e2e_tests/test_poc_search.py new file mode 100644 index 00000000..81aff4fc --- /dev/null +++ b/e2e_tests/test_poc_search.py @@ -0,0 +1,61 @@ +from playwright.sync_api import Page, expect + + +def test_poc_search_end_to_end(page: Page): + """ + Given a user on the search page + When they interact with the search form and submit a query + Then the table should contain the expected headers and entries. + """ + page.goto("/poc-search-view") + + expect(page.get_by_text("Search design PoC")).to_be_visible() + expect(page.locator("text=Search for digital records")).to_be_visible() + + # Interact with the search form and submit a query + page.fill("#searchInput", "Test description") + expect(page.locator("#searchInput")).to_have_value("Test description") + page.get_by_role("button").get_by_text("Search").click() + + expect(page.locator("#searchInput")).not_to_have_value("Test description") + + table = page.locator("table") + # Use JavaScript to extract the text of header elements (th) within the table + header_texts = table.evaluate( + '(table) => Array.from(table.querySelectorAll("th")).map(th => th.textContent)' + ) + # List of expected header values + expected_headers = [ + "Title", + "Description", + "Last modified", + "Status", + "Closure period (years)", + ] + for expected_header in expected_headers: + assert expected_header in header_texts + + # Use JavaScript to extract the text of table cell elements (td) within the table + cell_texts = table.evaluate( + '(table) => Array.from(table.querySelectorAll("td")).map(td => td.textContent)' + ) + # List of expected entry values + expected_entries = [ + "", + "", + "", + "", + "", + "file-a1.txt", + "Test description", + "2023-02-27T12:28:08", + "Public Record(s)", + "50", + "file-a2.txt", + "Test description 2", + "2023-02-27T12:28:13", + "Public Record(s)", + "100", + ] + for expected_entry in expected_entries: + assert expected_entry in cell_texts From c62dd782a3eda3a6110f58c59ae31a4fc2802266 Mon Sep 17 00:00:00 2001 From: Anthony Hashemi Date: Thu, 26 Oct 2023 16:14:28 +0100 Subject: [PATCH 4/8] Test underlying search logic and refactor --- app/main/aws/open_search.py | 61 +++++++++++++ app/main/routes.py | 4 +- app/main/search/open_search.py | 101 ---------------------- app/main/search/search_logic.py | 48 +++++++++++ app/tests/test_aws_open_search.py | 71 +++++++++++++++ app/tests/test_search.py | 6 +- app/tests/test_search_logic.py | 78 +++++++++++++++++ poetry.lock | 138 +++++++++++++++++++++++++++++- pyproject.toml | 2 + 9 files changed, 402 insertions(+), 107 deletions(-) create mode 100644 app/main/aws/open_search.py delete mode 100644 app/main/search/open_search.py create mode 100644 app/main/search/search_logic.py create mode 100644 app/tests/test_aws_open_search.py create mode 100644 app/tests/test_search_logic.py diff --git a/app/main/aws/open_search.py b/app/main/aws/open_search.py new file mode 100644 index 00000000..6b497ad1 --- /dev/null +++ b/app/main/aws/open_search.py @@ -0,0 +1,61 @@ +from typing import Tuple + +import boto3 +from opensearchpy import AWSV4SignerAuth, OpenSearch + +from app.main.aws.parameter import ( + get_aws_environment_prefix, + get_parameter_store_key_value, +) + + +def get_open_search_index_from_aws_params() -> str: + return get_parameter_store_key_value( + get_aws_environment_prefix() + "AWS_OPEN_SEARCH_INDEX" + ) + + +def generate_open_search_client_from_aws_params() -> OpenSearch: + host = get_parameter_store_key_value( + get_aws_environment_prefix() + "AWS_OPEN_SEARCH_HOST" + ) + http_auth = _get_open_search_http_auth() + + open_search_client = OpenSearch( + hosts=[{"host": host, "port": 443}], + http_auth=http_auth, + use_ssl=True, + verify_certs=True, + http_compress=True, + ssl_assert_hostname=False, + ssl_show_warn=True, + ) + return open_search_client + + +def _get_open_search_http_auth( + auth_method: str = "username_password", +) -> Tuple[str, str] | AWSV4SignerAuth: + if auth_method == "username_password": + return _get_open_search_username_password_auth() + return _get_open_search_iam_auth() + + +def _get_open_search_username_password_auth() -> Tuple[str, str]: + username = get_parameter_store_key_value( + get_aws_environment_prefix() + "AWS_OPEN_SEARCH_USERNAME" + ) + password = get_parameter_store_key_value( + get_aws_environment_prefix() + "AWS_OPEN_SEARCH_PASSWORD" + ) + return (username, password) + + +def _get_open_search_iam_auth() -> AWSV4SignerAuth: + credentials = boto3.Session().get_credentials() + aws_region = get_parameter_store_key_value( + get_aws_environment_prefix() + "AWS_REGION" + ) + service = "es" + aws_auth = AWSV4SignerAuth(credentials, aws_region, service) + return aws_auth diff --git a/app/main/routes.py b/app/main/routes.py index 35287460..3baf2ecb 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -9,7 +9,7 @@ ) from flask_wtf.csrf import CSRFError -from app.main.search import open_search +from app.main.search import search_logic from .forms import SearchForm from werkzeug.exceptions import HTTPException import os @@ -96,7 +96,7 @@ def poc_search(): if query: open_search_response = ( - open_search.generate_open_search_client_and_make_poc_search(query) + search_logic.generate_open_search_client_and_make_poc_search(query) ) results = open_search_response["hits"]["hits"] diff --git a/app/main/search/open_search.py b/app/main/search/open_search.py deleted file mode 100644 index 055d9912..00000000 --- a/app/main/search/open_search.py +++ /dev/null @@ -1,101 +0,0 @@ -import logging -from typing import Any, List, Tuple - -import boto3 -from opensearchpy import AWSV4SignerAuth, ImproperlyConfigured, OpenSearch - -from app.main.aws import parameter - -AWS_ENVIRONMENT_PREFIX = parameter.get_aws_environment_prefix() - - -def generate_open_search_client_and_make_poc_search(query: str) -> Any: - open_search_client = generate_open_search_client_from_aws_params() - open_search_index = parameter.get_parameter_store_key_value( - AWS_ENVIRONMENT_PREFIX + "AWS_OPEN_SEARCH_INDEX" - ) - fields = [ - "legal_status", - "description", - "closure_type", - "Internal-Sender_Identifier", - "id", - "Contact_Email", - "Source_Organization", - "Consignment_Series.keyword", - "Consignment_Series", - "Contact_Name", - ] - open_search_response = make_multi_match_fuzzy_search( - open_search_client, query, open_search_index, fields - ) - return open_search_response - - -def make_multi_match_fuzzy_search( - open_search: OpenSearch, search_text: str, index: str, fields: List[str] -): - open_search_query = { - "query": { - "multi_match": { - "query": search_text, - "fields": fields, - "fuzziness": "AUTO", - "type": "best_fields", - } - } - } - search_results = open_search.search(body=open_search_query, index=index) - return search_results - - -def generate_open_search_client_from_aws_params() -> OpenSearch: - host = parameter.get_parameter_store_key_value( - AWS_ENVIRONMENT_PREFIX + "AWS_OPEN_SEARCH_HOST" - ) - http_auth = _get_open_search_http_auth() - - open_search_client = OpenSearch( - hosts=[{"host": host, "port": 443}], - http_auth=http_auth, - use_ssl=True, - verify_certs=True, - http_compress=True, # enables gzip compression for request bodies - ssl_assert_hostname=False, - ssl_show_warn=True, - ) - try: - open_search_client.ping() - except ImproperlyConfigured as e: - logging.error("OpenSearch client improperly configured: " + str(e)) - - logging.info("OpenSearch client has been connected successfully") - return open_search_client - - -def _get_open_search_http_auth( - auth_method: str = "username_password", -) -> Tuple[str, str] | AWSV4SignerAuth: - if auth_method == "username_password": - return _get_open_search_username_password_auth() - return _get_open_search_iam_auth() - - -def _get_open_search_username_password_auth() -> Tuple[str, str]: - username = parameter.get_parameter_store_key_value( - AWS_ENVIRONMENT_PREFIX + "AWS_OPEN_SEARCH_USERNAME" - ) - password = parameter.get_parameter_store_key_value( - AWS_ENVIRONMENT_PREFIX + "AWS_OPEN_SEARCH_PASSWORD" - ) - return (username, password) - - -def _get_open_search_iam_auth() -> AWSV4SignerAuth: - credentials = boto3.Session().get_credentials() - aws_region = parameter.get_parameter_store_key_value( - AWS_ENVIRONMENT_PREFIX + "AWS_REGION" - ) - service = "es" - aws_auth = AWSV4SignerAuth(credentials, aws_region, service) - return aws_auth diff --git a/app/main/search/search_logic.py b/app/main/search/search_logic.py new file mode 100644 index 00000000..859d9a8d --- /dev/null +++ b/app/main/search/search_logic.py @@ -0,0 +1,48 @@ +import logging +from typing import Any + +from opensearchpy import ImproperlyConfigured +from app.main.aws.open_search import ( + generate_open_search_client_from_aws_params, + get_open_search_index_from_aws_params, +) + + +def generate_open_search_client_and_make_poc_search(query: str) -> Any: + fields = [ + "legal_status", + "description", + "closure_type", + "Internal-Sender_Identifier", + "id", + "Contact_Email", + "Source_Organization", + "Consignment_Series.keyword", + "Consignment_Series", + "Contact_Name", + ] + open_search_client = generate_open_search_client_from_aws_params() + + try: + open_search_client.ping() + except ImproperlyConfigured as e: + logging.error("OpenSearch client improperly configured: " + str(e)) + raise e + + logging.info("OpenSearch client has been connected successfully") + + open_search_index = get_open_search_index_from_aws_params() + open_search_query = { + "query": { + "multi_match": { + "query": query, + "fields": fields, + "fuzziness": "AUTO", + "type": "best_fields", + } + } + } + search_results = open_search_client.search( + body=open_search_query, index=open_search_index + ) + return search_results diff --git a/app/tests/test_aws_open_search.py b/app/tests/test_aws_open_search.py new file mode 100644 index 00000000..7c0ce681 --- /dev/null +++ b/app/tests/test_aws_open_search.py @@ -0,0 +1,71 @@ +from unittest.mock import patch +from moto import mock_ssm +import boto3 + +from app.main.aws.open_search import ( + get_open_search_index_from_aws_params, + generate_open_search_client_from_aws_params, +) + + +@mock_ssm +def test_get_open_search_index_from_aws_params(): + ssm_client = boto3.client("ssm", region_name="eu-west-2") + ssm_client.put_parameter( + Name="ENVIRONMENT_NAME", + Value="test_env", + Type="String", + Overwrite=True, + ) + ssm_client.put_parameter( + Name="/test_env/AWS_OPEN_SEARCH_INDEX", + Value="test_index", + Type="String", + Overwrite=True, + ) + + assert get_open_search_index_from_aws_params() == "test_index" + + +@mock_ssm +@patch("app.main.aws.open_search.OpenSearch") +def test_generate_open_search_client_from_aws_params(mock_open_search): + ssm_client = boto3.client("ssm", region_name="eu-west-2") + ssm_client.put_parameter( + Name="ENVIRONMENT_NAME", + Value="test_env", + Type="String", + Overwrite=True, + ) + ssm_client.put_parameter( + Name="/test_env/AWS_OPEN_SEARCH_HOST", + Value="mock_opensearch_host", + Type="String", + Overwrite=True, + ) + ssm_client.put_parameter( + Name="/test_env/AWS_OPEN_SEARCH_USERNAME", + Value="mock_username", + Type="String", + Overwrite=True, + ) + ssm_client.put_parameter( + Name="/test_env/AWS_OPEN_SEARCH_PASSWORD", + Value="mock_password", + Type="String", + Overwrite=True, + ) + + assert ( + generate_open_search_client_from_aws_params() == mock_open_search.return_value + ) + + mock_open_search.assert_called_once_with( + hosts=[{"host": "mock_opensearch_host", "port": 443}], + http_auth=("mock_username", "mock_password"), + use_ssl=True, + verify_certs=True, + http_compress=True, + ssl_assert_hostname=False, + ssl_show_warn=True, + ) diff --git a/app/tests/test_search.py b/app/tests/test_search.py index c28883c6..f033f889 100644 --- a/app/tests/test_search.py +++ b/app/tests/test_search.py @@ -2,6 +2,7 @@ from bs4 import BeautifulSoup from flask.testing import FlaskClient + def test_poc_search_get(client: FlaskClient): """ Given a user accessing the search page @@ -29,7 +30,7 @@ def test_poc_search_no_query(client: FlaskClient): assert b"records found" not in response.data -@patch("app.main.routes.open_search.generate_open_search_client_and_make_poc_search") +@patch("app.main.routes.search_logic.generate_open_search_client_and_make_poc_search") def test_poc_search_with_no_results(mock_open_search, client: FlaskClient): """ Given a user with a search query @@ -45,7 +46,7 @@ def test_poc_search_with_no_results(mock_open_search, client: FlaskClient): assert b"records found" not in response.data -@patch("app.main.routes.open_search.generate_open_search_client_and_make_poc_search") +@patch("app.main.routes.search_logic.generate_open_search_client_and_make_poc_search") def test_poc_search_results_displayed(mock_open_search, client: FlaskClient): """ Given a user with a search query which should return n results @@ -114,4 +115,3 @@ def test_poc_search_results_displayed(mock_open_search, client: FlaskClient): assert [result.text for result in row.find_all("td")] == expected_results_table[ row_index + 1 ] - diff --git a/app/tests/test_search_logic.py b/app/tests/test_search_logic.py new file mode 100644 index 00000000..b87a940d --- /dev/null +++ b/app/tests/test_search_logic.py @@ -0,0 +1,78 @@ +from unittest.mock import Mock, patch + +from opensearchpy import ImproperlyConfigured +import pytest + +from app.main.search.search_logic import generate_open_search_client_and_make_poc_search + + +@patch("app.main.search.search_logic.generate_open_search_client_from_aws_params") +@patch("app.main.search.search_logic.get_open_search_index_from_aws_params") +def test_generate_open_search_client_and_make_poc_search( + mock_get_open_search_index_from_aws_params, + mock_generate_open_search_client_from_aws_params, +): + query = "foo bar" + mock_get_open_search_index_from_aws_params.return_value = "test_index" + + mock_open_search_client = Mock() + mock_open_search_client.search.return_value = ["result 1", "result 2"] + mock_generate_open_search_client_from_aws_params.return_value = ( + mock_open_search_client + ) + + results = generate_open_search_client_and_make_poc_search(query) + + mock_open_search_client.ping.assert_called_once() + + mock_open_search_client.search.assert_called_once_with( + body={ + "query": { + "multi_match": { + "query": "foo bar", + "fields": [ + "legal_status", + "description", + "closure_type", + "Internal-Sender_Identifier", + "id", + "Contact_Email", + "Source_Organization", + "Consignment_Series.keyword", + "Consignment_Series", + "Contact_Name", + ], + "fuzziness": "AUTO", + "type": "best_fields", + } + } + }, + index="test_index", + ) + assert results == ["result 1", "result 2"] + + +@patch("app.main.search.search_logic.generate_open_search_client_from_aws_params") +@patch("app.main.search.search_logic.get_open_search_index_from_aws_params") +def test_generate_open_search_client_and_make_poc_search_raises_connection_error( + mock_get_open_search_index_from_aws_params, + mock_generate_open_search_client_from_aws_params, +): + query = "foo bar" + mock_get_open_search_index_from_aws_params.return_value = "test_index" + + mock_open_search_client = Mock() + mock_open_search_client.search.return_value = ["result 1", "result 2"] + + def raise_improperly_configured_exception(): + raise ImproperlyConfigured() + + mock_open_search_client.ping.side_effect = raise_improperly_configured_exception + mock_generate_open_search_client_from_aws_params.return_value = ( + mock_open_search_client + ) + + with pytest.raises(ImproperlyConfigured): + generate_open_search_client_and_make_poc_search(query) + mock_open_search_client.ping.assert_called_once() + mock_open_search_client.search.assert_not_called() diff --git a/poetry.lock b/poetry.lock index f731b855..cb1aded6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -592,6 +592,51 @@ files = [ [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "41.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:da6a0ff8f1016ccc7477e6339e1d50ce5f59b88905585f77193ebd5068f1e797"}, + {file = "cryptography-41.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b948e09fe5fb18517d99994184854ebd50b57248736fd4c720ad540560174ec5"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d38e6031e113b7421db1de0c1b1f7739564a88f1684c6b89234fbf6c11b75147"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e270c04f4d9b5671ebcc792b3ba5d4488bf7c42c3c241a3748e2599776f29696"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ec3b055ff8f1dce8e6ef28f626e0972981475173d7973d63f271b29c8a2897da"}, + {file = "cryptography-41.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7d208c21e47940369accfc9e85f0de7693d9a5d843c2509b3846b2db170dfd20"}, + {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8254962e6ba1f4d2090c44daf50a547cd5f0bf446dc658a8e5f8156cae0d8548"}, + {file = "cryptography-41.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:a48e74dad1fb349f3dc1d449ed88e0017d792997a7ad2ec9587ed17405667e6d"}, + {file = "cryptography-41.0.5-cp37-abi3-win32.whl", hash = "sha256:d3977f0e276f6f5bf245c403156673db103283266601405376f075c849a0b936"}, + {file = "cryptography-41.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:73801ac9736741f220e20435f84ecec75ed70eda90f781a148f1bad546963d81"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3be3ca726e1572517d2bef99a818378bbcf7d7799d5372a46c79c29eb8d166c1"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e886098619d3815e0ad5790c973afeee2c0e6e04b4da90b88e6bd06e2a0b1b72"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:573eb7128cbca75f9157dcde974781209463ce56b5804983e11a1c462f0f4e88"}, + {file = "cryptography-41.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0c327cac00f082013c7c9fb6c46b7cc9fa3c288ca702c74773968173bda421bf"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:227ec057cd32a41c6651701abc0328135e472ed450f47c2766f23267b792a88e"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:22892cc830d8b2c89ea60148227631bb96a7da0c1b722f2aac8824b1b7c0b6b8"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5a70187954ba7292c7876734183e810b728b4f3965fbe571421cb2434d279179"}, + {file = "cryptography-41.0.5-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:88417bff20162f635f24f849ab182b092697922088b477a7abd6664ddd82291d"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c707f7afd813478e2019ae32a7c49cd932dd60ab2d2a93e796f68236b7e1fbf1"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:580afc7b7216deeb87a098ef0674d6ee34ab55993140838b14c9b83312b37b86"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1e91467c65fe64a82c689dc6cf58151158993b13eb7a7f3f4b7f395636723"}, + {file = "cryptography-41.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0d2a6a598847c46e3e321a7aef8af1436f11c27f1254933746304ff014664d84"}, + {file = "cryptography-41.0.5.tar.gz", hash = "sha256:392cb88b597247177172e02da6b7a63deeff1937fa6fec3bbf902ebd75d97ec7"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "cssmin" version = "0.2.0" @@ -1263,6 +1308,55 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "moto" +version = "4.2.6" +description = "" +optional = false +python-versions = ">=3.7" +files = [ + {file = "moto-4.2.6-py2.py3-none-any.whl", hash = "sha256:20cd41f89b7fe363ef49b9ead787c9a1f3d560f4d0711b3767e7416694de1127"}, + {file = "moto-4.2.6.tar.gz", hash = "sha256:ce0a55d7e756c59a5a4392c7097aa5ca53e00aa2dd3f7000093356be15e7aef9"}, +] + +[package.dependencies] +boto3 = ">=1.9.201" +botocore = ">=1.12.201" +cryptography = ">=3.3.1" +Jinja2 = ">=2.10.1" +python-dateutil = ">=2.1,<3.0.0" +requests = ">=2.5" +responses = ">=0.13.0" +werkzeug = ">=0.5,<2.2.0 || >2.2.0,<2.2.1 || >2.2.1" +xmltodict = "*" + +[package.extras] +all = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "multipart", "openapi-spec-validator (>=0.2.8)", "py-partiql-parser (==0.4.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] +apigateway = ["PyYAML (>=5.1)", "ecdsa (!=0.15)", "openapi-spec-validator (>=0.2.8)", "python-jose[cryptography] (>=3.1.0,<4.0.0)"] +apigatewayv2 = ["PyYAML (>=5.1)"] +appsync = ["graphql-core"] +awslambda = ["docker (>=3.0.0)"] +batch = ["docker (>=3.0.0)"] +cloudformation = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.2.8)", "py-partiql-parser (==0.4.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] +cognitoidp = ["ecdsa (!=0.15)", "python-jose[cryptography] (>=3.1.0,<4.0.0)"] +ds = ["sshpubkeys (>=3.1.0)"] +dynamodb = ["docker (>=3.0.0)", "py-partiql-parser (==0.4.0)"] +dynamodbstreams = ["docker (>=3.0.0)", "py-partiql-parser (==0.4.0)"] +ebs = ["sshpubkeys (>=3.1.0)"] +ec2 = ["sshpubkeys (>=3.1.0)"] +efs = ["sshpubkeys (>=3.1.0)"] +eks = ["sshpubkeys (>=3.1.0)"] +glue = ["pyparsing (>=3.0.7)"] +iotdata = ["jsondiff (>=1.1.2)"] +proxy = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=2.5.1)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "multipart", "openapi-spec-validator (>=0.2.8)", "py-partiql-parser (==0.4.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] +resourcegroupstaggingapi = ["PyYAML (>=5.1)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.2.8)", "py-partiql-parser (==0.4.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "sshpubkeys (>=3.1.0)"] +route53resolver = ["sshpubkeys (>=3.1.0)"] +s3 = ["PyYAML (>=5.1)", "py-partiql-parser (==0.4.0)"] +s3crc32c = ["PyYAML (>=5.1)", "crc32c", "py-partiql-parser (==0.4.0)"] +server = ["PyYAML (>=5.1)", "aws-xray-sdk (>=0.93,!=0.96)", "cfn-lint (>=0.40.0)", "docker (>=3.0.0)", "ecdsa (!=0.15)", "flask (!=2.2.0,!=2.2.1)", "flask-cors", "graphql-core", "jsondiff (>=1.1.2)", "openapi-spec-validator (>=0.2.8)", "py-partiql-parser (==0.4.0)", "pyparsing (>=3.0.7)", "python-jose[cryptography] (>=3.1.0,<4.0.0)", "setuptools", "sshpubkeys (>=3.1.0)"] +ssm = ["PyYAML (>=5.1)"] +xray = ["aws-xray-sdk (>=0.93,!=0.96)", "setuptools"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -1818,6 +1912,26 @@ files = [ [package.dependencies] requests = ">=2.0.1,<3.0.0" +[[package]] +name = "responses" +version = "0.23.3" +description = "A utility library for mocking out the `requests` Python library." +optional = false +python-versions = ">=3.7" +files = [ + {file = "responses-0.23.3-py3-none-any.whl", hash = "sha256:e6fbcf5d82172fecc0aa1860fd91e58cbfd96cee5e96da5b63fa6eb3caa10dd3"}, + {file = "responses-0.23.3.tar.gz", hash = "sha256:205029e1cb334c21cb4ec64fc7599be48b859a0fd381a42443cdd600bfe8b16a"}, +] + +[package.dependencies] +pyyaml = "*" +requests = ">=2.30.0,<3.0" +types-PyYAML = "*" +urllib3 = ">=1.25.10,<3.0" + +[package.extras] +tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-requests"] + [[package]] name = "rich" version = "13.6.0" @@ -2027,6 +2141,17 @@ files = [ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.12" +description = "Typing stubs for PyYAML" +optional = false +python-versions = "*" +files = [ + {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, + {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, +] + [[package]] name = "typing-extensions" version = "4.8.0" @@ -2198,7 +2323,18 @@ markupsafe = "*" [package.extras] email = ["email-validator"] +[[package]] +name = "xmltodict" +version = "0.13.0" +description = "Makes working with XML feel like you are working with JSON" +optional = false +python-versions = ">=3.4" +files = [ + {file = "xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852"}, + {file = "xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56"}, +] + [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "2cf2d73ffc44a7cb797904133bd55fb58b4850809bf8a1c6f5ffd4a54e48da0f" +content-hash = "55ff3d73b7696dfca21e5cd66ba76b9bc4e2cd97a14cebfca46c3c3c0248f414" diff --git a/pyproject.toml b/pyproject.toml index 25361b3a..5761571e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ boto3 = "1.28.64" botocore = "1.31.64" opensearch-py = "2.3.2" bs4 = "^0.0.1" +pytest-cov = "^4.1.0" +moto = "^4.2.6" [tool.poetry.group.dev.dependencies] bandit = "1.7.5" From 70fa88600ed59d6b40dbc15ca8388265cfcf27f9 Mon Sep 17 00:00:00 2001 From: Anthony Hashemi Date: Fri, 27 Oct 2023 12:50:41 +0100 Subject: [PATCH 5/8] Make test flask app client disable Talisman's force_https setting that was causing redirects in pytest tests of the api --- app/__init__.py | 7 +++++-- app/tests/conftest.py | 5 ++--- app/tests/test_search.py | 8 ++++---- testing_config.py | 6 ++++++ 4 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 testing_config.py diff --git a/app/__init__.py b/app/__init__.py index db4184e7..76c0d585 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -44,20 +44,23 @@ def create_app(config_class=Config): ] ) + app.jinja_env.filters["null_to_dash"] = null_to_dash + # Set content security policy csp = { "default-src": "'self'", "script-src": ["'self'"], } - app.jinja_env.filters["null_to_dash"] = null_to_dash + # Disable https if app is run in testing mode + force_https = False if app.config["TESTING"] else True # Initialise app extensions assets.init_app(app) compress.init_app(app) csrf.init_app(app) limiter.init_app(app) - talisman.init_app(app, content_security_policy=csp) + talisman.init_app(app, content_security_policy=csp, force_https=force_https) WTFormsHelpers(app) # Create static asset bundles diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 6a1767e9..80bbc952 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -1,12 +1,11 @@ import pytest from app import create_app +from testing_config import TestingConfig @pytest.fixture def app(): - app = create_app() - app.config["TESTING"] = True - app.config["WTF_CSRF_ENABLED"] = False + app = create_app(config_class=TestingConfig) yield app diff --git a/app/tests/test_search.py b/app/tests/test_search.py index f033f889..2e0f8f20 100644 --- a/app/tests/test_search.py +++ b/app/tests/test_search.py @@ -9,7 +9,7 @@ def test_poc_search_get(client: FlaskClient): When they make a GET request Then they should see the search form and page content. """ - response = client.get("/poc-search-view", follow_redirects=True) + response = client.get("/poc-search-view") assert response.status_code == 200 assert b"Search design PoC" in response.data @@ -24,7 +24,7 @@ def test_poc_search_no_query(client: FlaskClient): Then they should not see any records found. """ form_data = {"foo": "bar"} - response = client.post("/poc-search-view", data=form_data, follow_redirects=True) + response = client.post("/poc-search-view", data=form_data) assert response.status_code == 200 assert b"records found" not in response.data @@ -40,7 +40,7 @@ def test_poc_search_with_no_results(mock_open_search, client: FlaskClient): mock_open_search.return_value = {"hits": {"hits": []}} form_data = {"query": "test_query"} - response = client.post("/poc-search-view", data=form_data, follow_redirects=True) + response = client.post("/poc-search-view", data=form_data) assert response.status_code == 200 assert b"records found" not in response.data @@ -79,7 +79,7 @@ def test_poc_search_results_displayed(mock_open_search, client: FlaskClient): } form_data = {"query": "test_query"} - response = client.post("/poc-search-view", data=form_data, follow_redirects=True) + response = client.post("/poc-search-view", data=form_data) assert response.status_code == 200 assert b"2 records found" in response.data diff --git a/testing_config.py b/testing_config.py new file mode 100644 index 00000000..be97d999 --- /dev/null +++ b/testing_config.py @@ -0,0 +1,6 @@ +from config import Config + + +class TestingConfig(Config): + TESTING = True + WTF_CSRF_ENABLED = False From e137a457db0b4f13e8cde3193b473427a968c181 Mon Sep 17 00:00:00 2001 From: Anthony Hashemi Date: Fri, 27 Oct 2023 13:43:09 +0100 Subject: [PATCH 6/8] Fix aws ssm accessing tests by setting default region in CI by settign env var in command --- .github/workflows/unit_tests.yml | 2 +- app/tests/test_aws_open_search.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index d126b7e3..f271b479 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -28,4 +28,4 @@ jobs: run: poetry install - name: Run Pytest tests - run: poetry run pytest --cov=app/main --cov-report term-missing -vvv app/tests/ + run: AWS_DEFAULT_REGION=eu-west-2 poetry run pytest --cov=app/main --cov-report term-missing -vvv app/tests/ diff --git a/app/tests/test_aws_open_search.py b/app/tests/test_aws_open_search.py index 7c0ce681..5ccead21 100644 --- a/app/tests/test_aws_open_search.py +++ b/app/tests/test_aws_open_search.py @@ -10,7 +10,7 @@ @mock_ssm def test_get_open_search_index_from_aws_params(): - ssm_client = boto3.client("ssm", region_name="eu-west-2") + ssm_client = boto3.client("ssm") ssm_client.put_parameter( Name="ENVIRONMENT_NAME", Value="test_env", @@ -30,7 +30,7 @@ def test_get_open_search_index_from_aws_params(): @mock_ssm @patch("app.main.aws.open_search.OpenSearch") def test_generate_open_search_client_from_aws_params(mock_open_search): - ssm_client = boto3.client("ssm", region_name="eu-west-2") + ssm_client = boto3.client("ssm") ssm_client.put_parameter( Name="ENVIRONMENT_NAME", Value="test_env", From 94861985cc5bc9d0bebf704f1face89823c2de4a Mon Sep 17 00:00:00 2001 From: Anthony Hashemi Date: Fri, 27 Oct 2023 13:44:19 +0100 Subject: [PATCH 7/8] Tidy up create_app --- app/__init__.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 76c0d585..728e69a9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -26,12 +26,16 @@ def null_to_dash(value): def create_app(config_class=Config): app = Flask(__name__, static_url_path="/assets") app.config.from_object(config_class) - # use only for local testing - DEFAULT_AWS_PROFILE = app.config["DEFAULT_AWS_PROFILE"] + + force_https = False if app.config["TESTING"] else True + + # use only for local development if app.config["DEFAULT_AWS_PROFILE"]: - boto3.setup_default_session(profile_name=DEFAULT_AWS_PROFILE) + boto3.setup_default_session(profile_name=app.config["DEFAULT_AWS_PROFILE"]) + app.jinja_env.lstrip_blocks = True app.jinja_env.trim_blocks = True + app.jinja_env.filters["null_to_dash"] = null_to_dash app.jinja_loader = ChoiceLoader( [ PackageLoader("app"), @@ -44,17 +48,12 @@ def create_app(config_class=Config): ] ) - app.jinja_env.filters["null_to_dash"] = null_to_dash - # Set content security policy csp = { "default-src": "'self'", "script-src": ["'self'"], } - # Disable https if app is run in testing mode - force_https = False if app.config["TESTING"] else True - # Initialise app extensions assets.init_app(app) compress.init_app(app) From b9eae334048e090038d7d3662bcb516c04708256 Mon Sep 17 00:00:00 2001 From: Anthony Hashemi Date: Fri, 27 Oct 2023 13:59:52 +0100 Subject: [PATCH 8/8] Fix black precommit by generalising language to python3 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 580be862..e766a03b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,4 +26,4 @@ repos: rev: 23.9.1 hooks: - id: black - language_version: python3.11 + language_version: python3
Title Description {{ record._source.date_last_modified }} {{ record._source.legal_status }} {{ record._source.closure_period }}