diff --git a/.InstallPackages b/.InstallPackages new file mode 100644 index 0000000000..bc3c5b89d9 --- /dev/null +++ b/.InstallPackages @@ -0,0 +1,13 @@ +libpq-dev +build-essential +libcairo2 +libpango-1.0-0 +libpangocairo-1.0-0 +libgdk-pixbuf2.0-0 +libffi-dev +shared-mime-info +swig +imagemagick +poppler-utils +libsqlite3-dev +postgresql-client-14 diff --git a/.circleci/config.yml b/.circleci/config.yml index 634f9a517f..9fb8d7ae80 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -520,7 +520,7 @@ jobs: - git_checkout - attach_workspace: at: ~/lite-api/tmp - - run: pip install coverage diff_cover + - run: pip install coverage==7.6.4 diff_cover - run: coverage combine tmp - run: coverage xml - run: coverage html diff --git a/.copilot/config.yml b/.copilot/config.yml new file mode 100644 index 0000000000..6a6bbf3bb7 --- /dev/null +++ b/.copilot/config.yml @@ -0,0 +1,6 @@ +repository: lite/lite-backend +builder: + name: paketobuildpacks/builder-jammy-full + version: 0.3.339 +packs: + - acodeninja/install diff --git a/.copilot/image_build_run.sh b/.copilot/image_build_run.sh new file mode 100755 index 0000000000..df82332cd8 --- /dev/null +++ b/.copilot/image_build_run.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +set -e diff --git a/.copilot/phases/build.sh b/.copilot/phases/build.sh new file mode 100644 index 0000000000..df82332cd8 --- /dev/null +++ b/.copilot/phases/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +set -e diff --git a/.copilot/phases/install.sh b/.copilot/phases/install.sh new file mode 100644 index 0000000000..df82332cd8 --- /dev/null +++ b/.copilot/phases/install.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +set -e diff --git a/.copilot/phases/post_build.sh b/.copilot/phases/post_build.sh new file mode 100644 index 0000000000..df82332cd8 --- /dev/null +++ b/.copilot/phases/post_build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +set -e diff --git a/.copilot/phases/pre_build.sh b/.copilot/phases/pre_build.sh new file mode 100644 index 0000000000..34bd8bc76e --- /dev/null +++ b/.copilot/phases/pre_build.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +# Exit early if something goes wrong +set -e + +git_clone_base_url="https://codestar-connections.eu-west-2.amazonaws.com/git-http/$AWS_ACCOUNT_ID/eu-west-2/$CODESTAR_CONNECTION_ID/uktrade" + +git config --global credential.helper '!aws codecommit credential-helper $@' +git config --global credential.UseHttpPath true + +cat < ./.gitmodules +[submodule "lite-content"] +path = lite_content +url = $git_clone_base_url/lite-content.git +branch = master +[submodule "lite_routing"] +path = lite_routing +url = $git_clone_base_url/lite-routing.git +branch = main +[submodule "django_db_anonymiser"] +path = django_db_anonymiser +url = $git_clone_base_url/django-db-anonymiser.git +EOF + +git submodule update --init --recursive diff --git a/.coveragerc b/.coveragerc index 1dd0a09a0f..064ccf5daa 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,6 +8,7 @@ omit = ./*/migrations/* ./api/conf/schema_generator_urls.py ./api/conf/settings.py + ./api/conf/gconfig-dbt-platform.py ./api/conf/wsgi.py ./static/management/* *test* diff --git a/.gitignore b/.gitignore index b52ccb6279..1d484007a7 100644 --- a/.gitignore +++ b/.gitignore @@ -171,7 +171,6 @@ bank-holidays.csv /test_helpers/test_endpoints/results/*.csv *.p12 -.python-version # pii file .pii-secret-hook diff --git a/Pipfile.lock b/Pipfile.lock index 7fd58c6d8b..54822275a0 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -25,6 +25,14 @@ "markers": "python_version >= '3.6'", "version": "==5.2.0" }, + "appnope": { + "hashes": [ + "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", + "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c" + ], + "markers": "sys_platform == 'darwin'", + "version": "==0.1.4" + }, "asgiref": { "hashes": [ "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", @@ -33,6 +41,13 @@ "markers": "python_version >= '3.8'", "version": "==3.8.1" }, + "asn1crypto": { + "hashes": [ + "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", + "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67" + ], + "version": "==1.5.1" + }, "async-timeout": { "hashes": [ "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", @@ -72,6 +87,39 @@ "markers": "python_version >= '3.7'", "version": "==2.2.1" }, + "bcrypt": { + "hashes": [ + "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb", + "sha256:0da52759f7f30e83f1e30a888d9163a81353ef224d82dc58eb5bb52efcabc399", + "sha256:1bb429fedbe0249465cdd85a58e8376f31bb315e484f16e68ca4c786dcc04291", + "sha256:1d84cf6d877918620b687b8fd1bf7781d11e8a0998f576c7aa939776b512b98d", + "sha256:1ee38e858bf5d0287c39b7a1fc59eec64bbf880c7d504d3a06a96c16e14058e7", + "sha256:1ff39b78a52cf03fdf902635e4c81e544714861ba3f0efc56558979dd4f09170", + "sha256:27fe0f57bb5573104b5a6de5e4153c60814c711b29364c10a75a54bb6d7ff48d", + "sha256:3413bd60460f76097ee2e0a493ccebe4a7601918219c02f503984f0a7ee0aebe", + "sha256:3698393a1b1f1fd5714524193849d0c6d524d33523acca37cd28f02899285060", + "sha256:373db9abe198e8e2c70d12b479464e0d5092cc122b20ec504097b5f2297ed184", + "sha256:39e1d30c7233cfc54f5c3f2c825156fe044efdd3e0b9d309512cc514a263ec2a", + "sha256:3bbbfb2734f0e4f37c5136130405332640a1e46e6b23e000eeff2ba8d005da68", + "sha256:3d3a6d28cb2305b43feac298774b997e372e56c7c7afd90a12b3dc49b189151c", + "sha256:5a1e8aa9b28ae28020a3ac4b053117fb51c57a010b9f969603ed885f23841458", + "sha256:61ed14326ee023917ecd093ee6ef422a72f3aec6f07e21ea5f10622b735538a9", + "sha256:655ea221910bcac76ea08aaa76df427ef8625f92e55a8ee44fbf7753dbabb328", + "sha256:762a2c5fb35f89606a9fde5e51392dad0cd1ab7ae64149a8b935fe8d79dd5ed7", + "sha256:77800b7147c9dc905db1cba26abe31e504d8247ac73580b4aa179f98e6608f34", + "sha256:8ac68872c82f1add6a20bd489870c71b00ebacd2e9134a8aa3f98a0052ab4b0e", + "sha256:8d7bb9c42801035e61c109c345a28ed7e84426ae4865511eb82e913df18f58c2", + "sha256:8f6ede91359e5df88d1f5c1ef47428a4420136f3ce97763e31b86dd8280fbdf5", + "sha256:9c1c4ad86351339c5f320ca372dfba6cb6beb25e8efc659bedd918d921956bae", + "sha256:c02d944ca89d9b1922ceb8a46460dd17df1ba37ab66feac4870f6862a1533c00", + "sha256:c52aac18ea1f4a4f65963ea4f9530c306b56ccd0c6f8c8da0c06976e34a6e841", + "sha256:cb2a8ec2bc07d3553ccebf0746bbf3d19426d1c6d1adbd4fa48925f66af7b9e8", + "sha256:cf69eaf5185fd58f268f805b505ce31f9b9fc2d64b376642164e9244540c1221", + "sha256:f4f4acf526fcd1c34e7ce851147deedd4e26e6402369304220250598b26448db" + ], + "markers": "python_version >= '3.7'", + "version": "==4.2.0" + }, "billiard": { "hashes": [ "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f", @@ -97,6 +145,13 @@ "markers": "python_version >= '3.7'", "version": "==1.29.165" }, + "cached-property": { + "hashes": [ + "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130", + "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0" + ], + "version": "==1.5.2" + }, "cairocffi": { "hashes": [ "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", @@ -726,6 +781,14 @@ "markers": "python_version >= '3.7'", "version": "==2.5.3" }, + "endesive": { + "hashes": [ + "sha256:20e279e1527e5259a0788285ac0397310ee1f3a51fc1b07d7a9261a4355e1cdb", + "sha256:e55fe62093be0f928d8513c821307d10749ecdf3712c7bb7db4e49916de6d466" + ], + "index": "pypi", + "version": "==1.5.12" + }, "et-xmlfile": { "hashes": [ "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", @@ -1084,6 +1147,150 @@ "markers": "python_version >= '3.6' and python_version < '4'", "version": "==0.1.2" }, + "lxml": { + "hashes": [ + "sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e", + "sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229", + "sha256:052d99051e77a4f3e8482c65014cf6372e61b0a6f4fe9edb98503bb5364cfee3", + "sha256:07da23d7ee08577760f0a71d67a861019103e4812c87e2fab26b039054594cc5", + "sha256:094cb601ba9f55296774c2d57ad68730daa0b13dc260e1f941b4d13678239e70", + "sha256:0a7056921edbdd7560746f4221dca89bb7a3fe457d3d74267995253f46343f15", + "sha256:0c120f43553ec759f8de1fee2f4794452b0946773299d44c36bfe18e83caf002", + "sha256:0d7b36afa46c97875303a94e8f3ad932bf78bace9e18e603f2085b652422edcd", + "sha256:0fdf3a3059611f7585a78ee10399a15566356116a4288380921a4b598d807a22", + "sha256:109fa6fede314cc50eed29e6e56c540075e63d922455346f11e4d7a036d2b8cf", + "sha256:146173654d79eb1fc97498b4280c1d3e1e5d58c398fa530905c9ea50ea849b22", + "sha256:1473427aff3d66a3fa2199004c3e601e6c4500ab86696edffdbc84954c72d832", + "sha256:1483fd3358963cc5c1c9b122c80606a3a79ee0875bcac0204149fa09d6ff2727", + "sha256:168f2dfcfdedf611eb285efac1516c8454c8c99caf271dccda8943576b67552e", + "sha256:17e8d968d04a37c50ad9c456a286b525d78c4a1c15dd53aa46c1d8e06bf6fa30", + "sha256:18feb4b93302091b1541221196a2155aa296c363fd233814fa11e181adebc52f", + "sha256:1afe0a8c353746e610bd9031a630a95bcfb1a720684c3f2b36c4710a0a96528f", + "sha256:1d04f064bebdfef9240478f7a779e8c5dc32b8b7b0b2fc6a62e39b928d428e51", + "sha256:1fdc9fae8dd4c763e8a31e7630afef517eab9f5d5d31a278df087f307bf601f4", + "sha256:1ffc23010330c2ab67fac02781df60998ca8fe759e8efde6f8b756a20599c5de", + "sha256:20094fc3f21ea0a8669dc4c61ed7fa8263bd37d97d93b90f28fc613371e7a875", + "sha256:213261f168c5e1d9b7535a67e68b1f59f92398dd17a56d934550837143f79c42", + "sha256:218c1b2e17a710e363855594230f44060e2025b05c80d1f0661258142b2add2e", + "sha256:23e0553b8055600b3bf4a00b255ec5c92e1e4aebf8c2c09334f8368e8bd174d6", + "sha256:25f1b69d41656b05885aa185f5fdf822cb01a586d1b32739633679699f220391", + "sha256:2b3778cb38212f52fac9fe913017deea2fdf4eb1a4f8e4cfc6b009a13a6d3fcc", + "sha256:2bc9fd5ca4729af796f9f59cd8ff160fe06a474da40aca03fcc79655ddee1a8b", + "sha256:2c226a06ecb8cdef28845ae976da407917542c5e6e75dcac7cc33eb04aaeb237", + "sha256:2c3406b63232fc7e9b8783ab0b765d7c59e7c59ff96759d8ef9632fca27c7ee4", + "sha256:2c86bf781b12ba417f64f3422cfc302523ac9cd1d8ae8c0f92a1c66e56ef2e86", + "sha256:2d9b8d9177afaef80c53c0a9e30fa252ff3036fb1c6494d427c066a4ce6a282f", + "sha256:2dec2d1130a9cda5b904696cec33b2cfb451304ba9081eeda7f90f724097300a", + "sha256:2dfab5fa6a28a0b60a20638dc48e6343c02ea9933e3279ccb132f555a62323d8", + "sha256:2ecdd78ab768f844c7a1d4a03595038c166b609f6395e25af9b0f3f26ae1230f", + "sha256:315f9542011b2c4e1d280e4a20ddcca1761993dda3afc7a73b01235f8641e903", + "sha256:36aef61a1678cb778097b4a6eeae96a69875d51d1e8f4d4b491ab3cfb54b5a03", + "sha256:384aacddf2e5813a36495233b64cb96b1949da72bef933918ba5c84e06af8f0e", + "sha256:3879cc6ce938ff4eb4900d901ed63555c778731a96365e53fadb36437a131a99", + "sha256:3c174dc350d3ec52deb77f2faf05c439331d6ed5e702fc247ccb4e6b62d884b7", + "sha256:3eb44520c4724c2e1a57c0af33a379eee41792595023f367ba3952a2d96c2aab", + "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d", + "sha256:41ce1f1e2c7755abfc7e759dc34d7d05fd221723ff822947132dc934d122fe22", + "sha256:423b121f7e6fa514ba0c7918e56955a1d4470ed35faa03e3d9f0e3baa4c7e492", + "sha256:44264ecae91b30e5633013fb66f6ddd05c006d3e0e884f75ce0b4755b3e3847b", + "sha256:482c2f67761868f0108b1743098640fbb2a28a8e15bf3f47ada9fa59d9fe08c3", + "sha256:4b0c7a688944891086ba192e21c5229dea54382f4836a209ff8d0a660fac06be", + "sha256:4c1fefd7e3d00921c44dc9ca80a775af49698bbfd92ea84498e56acffd4c5469", + "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f", + "sha256:501d0d7e26b4d261fca8132854d845e4988097611ba2531408ec91cf3fd9d20a", + "sha256:516f491c834eb320d6c843156440fe7fc0d50b33e44387fcec5b02f0bc118a4c", + "sha256:51806cfe0279e06ed8500ce19479d757db42a30fd509940b1701be9c86a5ff9a", + "sha256:562e7494778a69086f0312ec9689f6b6ac1c6b65670ed7d0267e49f57ffa08c4", + "sha256:56b9861a71575f5795bde89256e7467ece3d339c9b43141dbdd54544566b3b94", + "sha256:5b8f5db71b28b8c404956ddf79575ea77aa8b1538e8b2ef9ec877945b3f46442", + "sha256:5c2fb570d7823c2bbaf8b419ba6e5662137f8166e364a8b2b91051a1fb40ab8b", + "sha256:5c54afdcbb0182d06836cc3d1be921e540be3ebdf8b8a51ee3ef987537455f84", + "sha256:5d6a6972b93c426ace71e0be9a6f4b2cfae9b1baed2eed2006076a746692288c", + "sha256:609251a0ca4770e5a8768ff902aa02bf636339c5a93f9349b48eb1f606f7f3e9", + "sha256:62d172f358f33a26d6b41b28c170c63886742f5b6772a42b59b4f0fa10526cb1", + "sha256:62f7fdb0d1ed2065451f086519865b4c90aa19aed51081979ecd05a21eb4d1be", + "sha256:658f2aa69d31e09699705949b5fc4719cbecbd4a97f9656a232e7d6c7be1a367", + "sha256:65ab5685d56914b9a2a34d67dd5488b83213d680b0c5d10b47f81da5a16b0b0e", + "sha256:68934b242c51eb02907c5b81d138cb977b2129a0a75a8f8b60b01cb8586c7b21", + "sha256:68b87753c784d6acb8a25b05cb526c3406913c9d988d51f80adecc2b0775d6aa", + "sha256:69959bd3167b993e6e710b99051265654133a98f20cec1d9b493b931942e9c16", + "sha256:6a7095eeec6f89111d03dabfe5883a1fd54da319c94e0fb104ee8f23616b572d", + "sha256:6b038cc86b285e4f9fea2ba5ee76e89f21ed1ea898e287dc277a25884f3a7dfe", + "sha256:6ba0d3dcac281aad8a0e5b14c7ed6f9fa89c8612b47939fc94f80b16e2e9bc83", + "sha256:6e91cf736959057f7aac7adfc83481e03615a8e8dd5758aa1d95ea69e8931dba", + "sha256:6ee8c39582d2652dcd516d1b879451500f8db3fe3607ce45d7c5957ab2596040", + "sha256:6f651ebd0b21ec65dfca93aa629610a0dbc13dbc13554f19b0113da2e61a4763", + "sha256:71a8dd38fbd2f2319136d4ae855a7078c69c9a38ae06e0c17c73fd70fc6caad8", + "sha256:74068c601baff6ff021c70f0935b0c7bc528baa8ea210c202e03757c68c5a4ff", + "sha256:7437237c6a66b7ca341e868cda48be24b8701862757426852c9b3186de1da8a2", + "sha256:747a3d3e98e24597981ca0be0fd922aebd471fa99d0043a3842d00cdcad7ad6a", + "sha256:74bcb423462233bc5d6066e4e98b0264e7c1bed7541fff2f4e34fe6b21563c8b", + "sha256:78d9b952e07aed35fe2e1a7ad26e929595412db48535921c5013edc8aa4a35ce", + "sha256:7b1cd427cb0d5f7393c31b7496419da594fe600e6fdc4b105a54f82405e6626c", + "sha256:7d3d1ca42870cdb6d0d29939630dbe48fa511c203724820fc0fd507b2fb46577", + "sha256:7e2f58095acc211eb9d8b5771bf04df9ff37d6b87618d1cbf85f92399c98dae8", + "sha256:7f41026c1d64043a36fda21d64c5026762d53a77043e73e94b71f0521939cc71", + "sha256:81b4e48da4c69313192d8c8d4311e5d818b8be1afe68ee20f6385d0e96fc9512", + "sha256:86a6b24b19eaebc448dc56b87c4865527855145d851f9fc3891673ff97950540", + "sha256:874a216bf6afaf97c263b56371434e47e2c652d215788396f60477540298218f", + "sha256:89e043f1d9d341c52bf2af6d02e6adde62e0a46e6755d5eb60dc6e4f0b8aeca2", + "sha256:8c72e9563347c7395910de6a3100a4840a75a6f60e05af5e58566868d5eb2d6a", + "sha256:8dc2c0395bea8254d8daebc76dcf8eb3a95ec2a46fa6fae5eaccee366bfe02ce", + "sha256:8f0de2d390af441fe8b2c12626d103540b5d850d585b18fcada58d972b74a74e", + "sha256:92e67a0be1639c251d21e35fe74df6bcc40cba445c2cda7c4a967656733249e2", + "sha256:94d6c3782907b5e40e21cadf94b13b0842ac421192f26b84c45f13f3c9d5dc27", + "sha256:97acf1e1fd66ab53dacd2c35b319d7e548380c2e9e8c54525c6e76d21b1ae3b1", + "sha256:9ada35dd21dc6c039259596b358caab6b13f4db4d4a7f8665764d616daf9cc1d", + "sha256:9c52100e2c2dbb0649b90467935c4b0de5528833c76a35ea1a2691ec9f1ee7a1", + "sha256:9e41506fec7a7f9405b14aa2d5c8abbb4dbbd09d88f9496958b6d00cb4d45330", + "sha256:9e4b47ac0f5e749cfc618efdf4726269441014ae1d5583e047b452a32e221920", + "sha256:9fb81d2824dff4f2e297a276297e9031f46d2682cafc484f49de182aa5e5df99", + "sha256:a0eabd0a81625049c5df745209dc7fcef6e2aea7793e5f003ba363610aa0a3ff", + "sha256:a3d819eb6f9b8677f57f9664265d0a10dd6551d227afb4af2b9cd7bdc2ccbf18", + "sha256:a87de7dd873bf9a792bf1e58b1c3887b9264036629a5bf2d2e6579fe8e73edff", + "sha256:aa617107a410245b8660028a7483b68e7914304a6d4882b5ff3d2d3eb5948d8c", + "sha256:aac0bbd3e8dd2d9c45ceb82249e8bdd3ac99131a32b4d35c8af3cc9db1657179", + "sha256:ab6dd83b970dc97c2d10bc71aa925b84788c7c05de30241b9e96f9b6d9ea3080", + "sha256:ace2c2326a319a0bb8a8b0e5b570c764962e95818de9f259ce814ee666603f19", + "sha256:ae5fe5c4b525aa82b8076c1a59d642c17b6e8739ecf852522c6321852178119d", + "sha256:b11a5d918a6216e521c715b02749240fb07ae5a1fefd4b7bf12f833bc8b4fe70", + "sha256:b1c8c20847b9f34e98080da785bb2336ea982e7f913eed5809e5a3c872900f32", + "sha256:b369d3db3c22ed14c75ccd5af429086f166a19627e84a8fdade3f8f31426e52a", + "sha256:b710bc2b8292966b23a6a0121f7a6c51d45d2347edcc75f016ac123b8054d3f2", + "sha256:bd96517ef76c8654446fc3db9242d019a1bb5fe8b751ba414765d59f99210b79", + "sha256:c00f323cc00576df6165cc9d21a4c21285fa6b9989c5c39830c3903dc4303ef3", + "sha256:c162b216070f280fa7da844531169be0baf9ccb17263cf5a8bf876fcd3117fa5", + "sha256:c1a69e58a6bb2de65902051d57fde951febad631a20a64572677a1052690482f", + "sha256:c1f794c02903c2824fccce5b20c339a1a14b114e83b306ff11b597c5f71a1c8d", + "sha256:c24037349665434f375645fa9d1f5304800cec574d0310f618490c871fd902b3", + "sha256:c300306673aa0f3ed5ed9372b21867690a17dba38c68c44b287437c362ce486b", + "sha256:c56a1d43b2f9ee4786e4658c7903f05da35b923fb53c11025712562d5cc02753", + "sha256:c6379f35350b655fd817cd0d6cbeef7f265f3ae5fedb1caae2eb442bbeae9ab9", + "sha256:c802e1c2ed9f0c06a65bc4ed0189d000ada8049312cfeab6ca635e39c9608957", + "sha256:cb83f8a875b3d9b458cada4f880fa498646874ba4011dc974e071a0a84a1b033", + "sha256:cf120cce539453ae086eacc0130a324e7026113510efa83ab42ef3fcfccac7fb", + "sha256:dd36439be765e2dde7660212b5275641edbc813e7b24668831a5c8ac91180656", + "sha256:dd5350b55f9fecddc51385463a4f67a5da829bc741e38cf689f38ec9023f54ab", + "sha256:df5c7333167b9674aa8ae1d4008fa4bc17a313cc490b2cca27838bbdcc6bb15b", + "sha256:e63601ad5cd8f860aa99d109889b5ac34de571c7ee902d6812d5d9ddcc77fa7d", + "sha256:e92ce66cd919d18d14b3856906a61d3f6b6a8500e0794142338da644260595cd", + "sha256:e99f5507401436fdcc85036a2e7dc2e28d962550afe1cbfc07c40e454256a859", + "sha256:ea2e2f6f801696ad7de8aec061044d6c8c0dd4037608c7cab38a9a4d316bfb11", + "sha256:eafa2c8658f4e560b098fe9fc54539f86528651f61849b22111a9b107d18910c", + "sha256:ecd4ad8453ac17bc7ba3868371bffb46f628161ad0eefbd0a855d2c8c32dd81a", + "sha256:ee70d08fd60c9565ba8190f41a46a54096afa0eeb8f76bd66f2c25d3b1b83005", + "sha256:eec1bb8cdbba2925bedc887bc0609a80e599c75b12d87ae42ac23fd199445654", + "sha256:ef0c1fe22171dd7c7c27147f2e9c3e86f8bdf473fed75f16b0c2e84a5030ce80", + "sha256:f2901429da1e645ce548bf9171784c0f74f0718c3f6150ce166be39e4dd66c3e", + "sha256:f422a209d2455c56849442ae42f25dbaaba1c6c3f501d58761c619c7836642ec", + "sha256:f65e5120863c2b266dbcc927b306c5b78e502c71edf3295dfcb9501ec96e5fc7", + "sha256:f7d4a670107d75dfe5ad080bed6c341d18c4442f9378c9f58e5851e86eb79965", + "sha256:f914c03e6a31deb632e2daa881fe198461f4d06e57ac3d0e05bbcab8eae01945", + "sha256:fb66442c2546446944437df74379e9cf9e9db353e61301d1a0e26482f43f0dd8" + ], + "markers": "python_version >= '3.6'", + "version": "==5.3.0" + }, "markdown": { "hashes": [ "sha256:225c6123522495d4119a90b3a3ba31a1e87a70369e03f14799ea9c0d7183a3d6", @@ -1268,6 +1475,13 @@ "markers": "python_version >= '3.7'", "version": "==0.43b0" }, + "oscrypto": { + "hashes": [ + "sha256:2b2f1d2d42ec152ca90ccb5682f3e051fb55986e1b170ebde472b133713e7085", + "sha256:6f5fef59cb5b3708321db7cca56aed8ad7e662853351e7991fcf60ec606d47a4" + ], + "version": "==1.3.0" + }, "packaging": { "hashes": [ "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", @@ -1276,6 +1490,14 @@ "markers": "python_version >= '3.8'", "version": "==24.1" }, + "paramiko": { + "hashes": [ + "sha256:1fedf06b085359051cd7d0d270cebe19e755a8a921cc2ddbfa647fb0cd7d68f9", + "sha256:ad11e540da4f55cedda52931f1a3f812a8238a7af7f62a60de538cd80bb28124" + ], + "markers": "python_version >= '3.6'", + "version": "==3.5.0" + }, "parso": { "hashes": [ "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", @@ -1472,6 +1694,50 @@ "markers": "python_version >= '3.8'", "version": "==2.9.0" }, + "pykcs11": { + "hashes": [ + "sha256:02a06e5058becb74463ec1f99403d66ea7891d08172e6e3afeec3c402d4a70e6", + "sha256:02ad45104550c9fda1919ed9da8ac6cedb6b1f16e10c21d38e9c004b08014944", + "sha256:210b3d1a9f658fd756e4a9bf8e84de86eb3f06dd7edcaad6dced20c1e2108748", + "sha256:39ab4cef6c724b35688d7ea2ffc1e2a02921f5f9e3a29398d95746ff216f6e73", + "sha256:43d746b0677f43cc634b9f7c4dab2d9ba6b1a83b931e2598247062f8ff641e07", + "sha256:4f2b19d95ccc731c88c83cfa5017044ff3c01df3bcbad9756997af071fc29bfd", + "sha256:50c9e19b61603a23c9a8df2adaf1a06c7695c433ae183528d615c414f01641cb", + "sha256:74093d40b8377c8c3cddbf5ac8f0035c0c2594690a0bf65c8ad77ea566fa4cc5", + "sha256:94f379688b34ffe321da78518e0595e492cfef84f38b4f00def63758ef37f678", + "sha256:ab92c042c105f68e8995559702e776caed7e8998b1403fec168e09cdbfd2c3f3", + "sha256:ae14da1602ff7b75b6f3af1a7e9602e167b3e51317c999b858f0dc2e883f21dd", + "sha256:b3076cab2aa78709b36c069b47352fa36f7608723ab3543146a4529e3ddf7bdc", + "sha256:f2cb84c28436d2e9b4de34588bc3a13670e0cef6b011e2ad6751c6687373c749", + "sha256:fa1b3c226de080e7b64276b299f6763508ed1b8c84122011846f25349a911ddf" + ], + "index": "pypi", + "version": "==1.5.16" + }, + "pynacl": { + "hashes": [ + "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", + "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", + "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", + "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", + "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", + "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", + "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", + "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", + "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", + "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543" + ], + "markers": "python_version >= '3.6'", + "version": "==1.5.0" + }, + "pyopenssl": { + "hashes": [ + "sha256:4247f0dbe3748d560dcbb2ff3ea01af0f9a1a001ef5f7c4c647956ed8cbf0e95", + "sha256:967d5719b12b243588573f39b0c677637145c7a1ffedcd495a487e58177fbb8d" + ], + "markers": "python_version >= '3.7'", + "version": "==24.2.1" + }, "pypdf2": { "hashes": [ "sha256:20929fad10a3b4890862f65f3a46f563cfdf53132faae5193b54e18658467a60", diff --git a/Procfile b/Procfile index af1add373e..7209272127 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1,5 @@ web: SWIG_LIB=/home/vcap/deps/0/apt/usr/share/swig4.0 CFLAGS=-I/home/vcap/deps/1/python/include/python3.9.18m pip3 install endesive==1.5.9 && python manage.py migrate && gunicorn --worker-class gevent -c api/conf/gconfig.py -b 0.0.0.0:$PORT api.conf.wsgi +web-dbt-platform: python manage.py migrate && gunicorn -c api/conf/gconfig-dbt-platform.py -b 0.0.0.0:$PORT api.conf.wsgi +dump-and-anonymise: python manage.py dump_and_anonymise celeryworker: celery -A api.conf worker -l info celeryscheduler: celery -A api.conf beat diff --git a/README.md b/README.md index b11f44c49b..47a4befdda 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Gegenerate diagrams ## Running tests - `pipenv run pytest` +- `pipenv run pytest --reuse-db` to speed up tests ## Running code coverage diff --git a/api/applications/management/commands/update_party_address.py b/api/applications/management/commands/update_party_address.py index 0f76a978a9..8ac041decc 100644 --- a/api/applications/management/commands/update_party_address.py +++ b/api/applications/management/commands/update_party_address.py @@ -20,7 +20,7 @@ def handle(self, *args, **kwargs): for row in reader: party_id = row["party_id"] address = row["address"].replace("\\r\\n", "\r\n") - new_address = row["new_address"] + new_address = row["new_address"].replace("\\r\\n", "\r\n") additional_text = row["additional_text"] self.update_field_on_party(party_id, address, new_address, additional_text, audit_log) diff --git a/api/applications/management/commands/update_party_name.py b/api/applications/management/commands/update_party_name.py index 99b178dfc6..53af606612 100644 --- a/api/applications/management/commands/update_party_name.py +++ b/api/applications/management/commands/update_party_name.py @@ -21,7 +21,7 @@ def handle(self, *args, **kwargs): for row in reader: party_id = row["party_id"] name = row["name"].replace("\\r\\n", "\r\n") - new_name = row["new_name"] + new_name = row["new_name"].replace("\\r\\n", "\r\n") additional_text = row["additional_text"] self.update_field_on_party(party_id, name, new_name, additional_text) diff --git a/api/applications/tests/test_update_parties_address.py b/api/applications/tests/test_update_parties_address.py index e29a5e60ab..5d74dc1381 100644 --- a/api/applications/tests/test_update_parties_address.py +++ b/api/applications/tests/test_update_parties_address.py @@ -14,8 +14,9 @@ def setUp(self): def test_update_field_on_party_from_csv(self): - new_address = "56 Heathwood Road Broadstairs Kent" + new_address = "56 Heathwood Road\\r\\n Broadstairs Kent" old_address = self.standard_application.end_user.party.address + result = "56 Heathwood Road\r\n Broadstairs Kent" party_id = self.standard_application.end_user.party.id with NamedTemporaryFile(suffix=".csv", delete=True) as tmp_file: @@ -28,7 +29,7 @@ def test_update_field_on_party_from_csv(self): call_command("update_party_address", tmp_file.name) self.standard_application.refresh_from_db() - self.assertEqual(self.standard_application.end_user.party.address, new_address) + self.assertEqual(self.standard_application.end_user.party.address, result) audit = Audit.objects.get() @@ -38,7 +39,7 @@ def test_update_field_on_party_from_csv(self): self.assertEqual( audit.payload, { - "address": {"new": new_address, "old": old_address}, + "address": {"new": result, "old": old_address}, "additional_text": "added by John Smith as per LTD-XXX", }, ) diff --git a/api/applications/tests/test_update_party_name.py b/api/applications/tests/test_update_party_name.py index 4e050a300f..f1b35b426d 100644 --- a/api/applications/tests/test_update_party_name.py +++ b/api/applications/tests/test_update_party_name.py @@ -14,9 +14,10 @@ def setUp(self): def test_update_field_on_party_from_csv(self): - new_name = "Bangarang 3000" + new_name = "Bangarang 3000\\r\\n Skrilly" old_name = self.standard_application.end_user.party.name party_id = self.standard_application.end_user.party.id + result = "Bangarang 3000\r\n Skrilly" with NamedTemporaryFile(suffix=".csv", delete=True) as tmp_file: rows = [ @@ -28,7 +29,7 @@ def test_update_field_on_party_from_csv(self): call_command("update_party_name", tmp_file.name) self.standard_application.refresh_from_db() - self.assertEqual(self.standard_application.end_user.party.name, new_name) + self.assertEqual(self.standard_application.end_user.party.name, result) audit = Audit.objects.get() @@ -38,7 +39,7 @@ def test_update_field_on_party_from_csv(self): self.assertEqual( audit.payload, { - "name": {"new": new_name, "old": old_name}, + "name": {"new": result, "old": old_name}, "additional_text": "added by John Smith as per LTD-XXX", }, ) diff --git a/api/cases/enums.py b/api/cases/enums.py index 6376a92a49..40915d77b9 100644 --- a/api/cases/enums.py +++ b/api/cases/enums.py @@ -4,6 +4,9 @@ from lite_content.lite_api import strings +SIEL_LICENCE_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" +SIEL_REFUSAL_TEMPLATE_ID = "074d8a54-ee10-4dca-82ba-650460650342" + class CaseTypeReferenceEnum: OIEL = "oiel" @@ -408,3 +411,36 @@ class EnforcementXMLEntityTypes: (SITE, "site"), (ORGANISATION, "organisation"), ] + + +class LicenceDecisionType: + ISSUED = "issued" + REFUSED = "refused" + REVOKED = "revoked" + + choices = [ + (ISSUED, "issued"), + (REFUSED, "refused"), + (REVOKED, "revoked"), + ] + + decision_map = { + AdviceType.APPROVE: ISSUED, + AdviceType.REFUSE: REFUSED, + } + + @classmethod + def decisions(cls): + return [d[0] for d in cls.choices] + + @classmethod + def templates(cls): + return { + cls.ISSUED: SIEL_LICENCE_TEMPLATE_ID, + cls.REFUSED: SIEL_REFUSAL_TEMPLATE_ID, + cls.REVOKED: None, + } + + @classmethod + def advice_type_to_decision(cls, advice_type): + return cls.decision_map[advice_type] diff --git a/api/cases/generated_documents/tests/factories.py b/api/cases/generated_documents/tests/factories.py index 175e9fe276..c5872ef6b3 100644 --- a/api/cases/generated_documents/tests/factories.py +++ b/api/cases/generated_documents/tests/factories.py @@ -1,7 +1,9 @@ import factory +from api.cases.enums import AdviceType from api.cases.generated_documents.models import GeneratedCaseDocument from api.cases.tests.factories import CaseSIELFactory +from api.letter_templates.tests.factories import SIELLicenceTemplateFactory class GeneratedCaseDocumentFactory(factory.django.DjangoModelFactory): @@ -13,3 +15,10 @@ class GeneratedCaseDocumentFactory(factory.django.DjangoModelFactory): class Meta: model = GeneratedCaseDocument + + +class SIELLicenceDocumentFactory(GeneratedCaseDocumentFactory): + text = factory.Faker("sentence") + template = factory.SubFactory(SIELLicenceTemplateFactory) + licence = None + advice_type = AdviceType.APPROVE diff --git a/api/cases/libraries/finalise.py b/api/cases/libraries/finalise.py index 104b7b139b..4fdbf70283 100644 --- a/api/cases/libraries/finalise.py +++ b/api/cases/libraries/finalise.py @@ -1,8 +1,8 @@ +from api.audit_trail.models import Audit +from api.applications.models import GoodOnApplication from api.cases.enums import AdviceType, CaseTypeSubTypeEnum, AdviceLevel from api.cases.models import Advice, GoodCountryDecision -from api.applications.models import GoodOnApplication from api.flags.models import Flag -from api.audit_trail.models import Audit def get_required_decision_document_types(case): diff --git a/api/cases/migrations/0067_licencedecision.py b/api/cases/migrations/0067_licencedecision.py new file mode 100644 index 0000000000..a5cdebb8fa --- /dev/null +++ b/api/cases/migrations/0067_licencedecision.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.16 on 2024-11-01 11:48 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ("licences", "0019_auto_20210506_0340"), + ("cases", "0066_delete_casereviewdate"), + ] + + operations = [ + migrations.CreateModel( + name="LicenceDecision", + fields=[ + ( + "created_at", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created_at" + ), + ), + ( + "updated_at", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="updated_at" + ), + ), + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ( + "decision", + models.CharField( + choices=[("issued", "issued"), ("refused", "refused"), ("revoked", "revoked")], max_length=50 + ), + ), + ( + "case", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="licence_decisions", + to="cases.case", + ), + ), + ( + "licence", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="licence_decisions", + to="licences.licence", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/api/cases/migrations/0068_populate_licence_decisions.py b/api/cases/migrations/0068_populate_licence_decisions.py new file mode 100644 index 0000000000..21480c6c7a --- /dev/null +++ b/api/cases/migrations/0068_populate_licence_decisions.py @@ -0,0 +1,121 @@ +# Generated by Django 4.2.16 on 2024-10-31 12:43 +import functools +import operator + +from django.contrib.postgres.aggregates import ArrayAgg +from django.db import migrations, transaction +from django.db.models import Case as DBCase, Q, TextField, Value, When +from django.db.models.functions import Cast + +from api.audit_trail.enums import AuditType +from api.cases.enums import AdviceType, LicenceDecisionType +from api.licences.enums import LicenceStatus + + +@transaction.atomic +def populate_licence_decisions(apps, schema_editor): + """ + To back populate licence decision we primarily take the timestamp of CREATED_FINAL_RECOMMENDATION audit log + as the decision date. This is emitted when the case is finalised and all decision documents are published + to Exporter so this is the accurate decision date. + However this event is introduced at a later point of time and not available for cases. In these cases + we fallback to using the document generation date as the decision date. Usually these two steps happen + without much time difference (generating documents and publishing them) so it is a reliable approximation. + We also observed that only few cases differ and maximum variation is ~3days which doesn't affect reports. + """ + + Audit = apps.get_model("audit_trail", "Audit") + GeneratedCaseDocument = apps.get_model("generated_documents", "GeneratedCaseDocument") + LicenceDecision = apps.get_model("cases", "LicenceDecision") + + licence_decisions = [] + + final_decision_qs = Audit.objects.filter(verb=AuditType.CREATED_FINAL_RECOMMENDATION).order_by("-created_at") + + document_qs = ( + GeneratedCaseDocument.objects.filter( + template_id__in=LicenceDecisionType.templates().values(), + advice_type__in=[AdviceType.APPROVE, AdviceType.REFUSE], + visible_to_exporter=True, + safe=True, + ) + .annotate(template_ids=ArrayAgg(Cast("template_id", output_field=TextField()), distinct=True)) + .filter( + functools.reduce( + operator.or_, + [Q(template_ids=[template_id]) for template_id in LicenceDecisionType.templates().values()], + ) + ) + .annotate( + decision=DBCase( + *[ + When(template_ids=[template_id], then=Value(decision)) + for decision, template_id in LicenceDecisionType.templates().items() + ] + ) + ) + ) + + # When running tests audit entries are not available so filtering documents + # the audit log created date earlier fails + if final_decision_qs: + earliest_audit_log = final_decision_qs.last() + document_qs = document_qs.filter( + created_at__date__lt=earliest_audit_log.created_at.date(), + ) + + for audit_log in final_decision_qs: + advice_type = audit_log.payload["decision"] + if advice_type not in [AdviceType.APPROVE, AdviceType.REFUSE]: + continue + + decision = LicenceDecisionType.advice_type_to_decision(advice_type) + licence_decisions.append( + LicenceDecision( + case_id=str(audit_log.target_object_id), + decision=decision, + created_at=audit_log.created_at, + ) + ) + + for document in document_qs: + licence_decisions.append( + LicenceDecision( + case_id=str(document.case_id), + decision=document.decision, + created_at=document.created_at, + ) + ) + + # Revoked cases + revoked_audit_qs = Audit.objects.filter( + payload__status=LicenceStatus.REVOKED, + verb=AuditType.LICENCE_UPDATED_STATUS, + ) + + for audit_log in revoked_audit_qs: + case_id = audit_log.target_object_id + licence_decisions.append( + LicenceDecision( + case_id=case_id, + decision=LicenceDecisionType.REVOKED, + created_at=audit_log.created_at, + ) + ) + + LicenceDecision.objects.bulk_create(licence_decisions) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0067_licencedecision"), + ("generated_documents", "0002_alter_generatedcasedocument_advice_type"), + ] + + operations = [ + migrations.RunPython( + populate_licence_decisions, + migrations.RunPython.noop, + ), + ] diff --git a/api/cases/models.py b/api/cases/models.py index 1735d392bd..c927d9214f 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -5,7 +5,7 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation -from django.db import models +from django.db import models, transaction from django.db.models import Q from django.utils import timezone from api.users.enums import UserType @@ -23,6 +23,7 @@ ECJUQueryType, AdviceLevel, EnforcementXMLEntityTypes, + LicenceDecisionType, ) from api.cases.helpers import working_days_in_range from api.cases.libraries.reference_code import generate_reference_code @@ -34,8 +35,9 @@ from api.organisations.models import Organisation from api.queues.models import Queue from api.staticdata.countries.models import Country +from api.staticdata.decisions.models import Decision from api.staticdata.denial_reasons.models import DenialReason -from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.enums import CaseStatusEnum, CaseSubStatusIdEnum from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status from api.staticdata.statuses.models import ( CaseStatus, @@ -137,6 +139,15 @@ def save(self, *args, **kwargs): super(Case, self).save(*args, **kwargs) + @classmethod + def get_decision_actions(cls): + return { + AdviceType.APPROVE: cls.approve, + AdviceType.REFUSE: cls.refuse, + AdviceType.NO_LICENCE_REQUIRED: cls.no_licence_required, + AdviceType.INFORM: lambda x: x, + } + def _reset_sub_status_on_status_change(self): from api.audit_trail import service as audit_trail_service @@ -307,6 +318,110 @@ def set_sub_status(self, sub_status_id): payload={"sub_status": self.sub_status.name, "status": CaseStatusEnum.get_text(self.status.status)}, ) + def approve(self): + from api.cases.notify import notify_exporter_licence_issued + + self.set_sub_status(CaseSubStatusIdEnum.FINALISED__APPROVED) + notify_exporter_licence_issued(self) + + def refuse(self): + from api.cases.notify import notify_exporter_licence_refused + + self.set_sub_status(CaseSubStatusIdEnum.FINALISED__REFUSED) + notify_exporter_licence_refused(self) + + def no_licence_required(self): + from api.cases.notify import notify_exporter_no_licence_required + + notify_exporter_no_licence_required(self) + + @transaction.atomic + def finalise(self, request, decisions): + from api.audit_trail import service as audit_trail_service + from api.cases.libraries.finalise import remove_flags_on_finalisation, remove_flags_from_audit_trail + from api.licences.models import Licence + + try: + licence = Licence.objects.get_draft_licence(self) + except Licence.DoesNotExist: + # This is not an error as there won't be a licence for refusal cases + licence = None + + if AdviceType.APPROVE in decisions and licence: + licence.decisions.set([Decision.objects.get(name=decision) for decision in decisions]) + + logging.info("Initiate issue of licence %s (status: %s)", licence.reference_code, licence.status) + licence.issue() + + if Licence.objects.filter(case=self).count() > 1: + audit_trail_service.create( + actor=request.user, + verb=AuditType.REINSTATED_APPLICATION, + target=self, + payload={ + "licence_duration": licence.duration, + "start_date": licence.start_date.strftime("%Y-%m-%d"), + }, + ) + + # Finalise Case + old_status = self.status.status + self.status = get_case_status_by_status(CaseStatusEnum.FINALISED) + self.save() + + audit_trail_service.create( + actor=request.user, + verb=AuditType.UPDATED_STATUS, + target=self, + payload={ + "status": {"new": self.status.status, "old": old_status}, + "additional_text": request.data.get("note"), + }, + ) + logging.info("Case is now finalised") + + decision_actions = self.get_decision_actions() + for advice_type in decisions: + decision_actions[advice_type](self) + + # NLR is not considered as licence decision + if advice_type in [AdviceType.APPROVE, AdviceType.REFUSE]: + LicenceDecision.objects.create( + case=self, + decision=LicenceDecisionType.advice_type_to_decision(advice_type), + licence=licence, + ) + + licence_reference = licence.reference_code if licence and advice_type == AdviceType.APPROVE else "" + audit_trail_service.create( + actor=request.user, + verb=AuditType.CREATED_FINAL_RECOMMENDATION, + target=self, + payload={ + "case_reference": self.reference_code, + "decision": advice_type, + "licence_reference": licence_reference, + }, + ) + + self.publish_decision_documents() + + # Remove Flags and related Audits when Finalising + remove_flags_on_finalisation(self) + remove_flags_from_audit_trail(self) + + return licence.id if licence else "" + + def publish_decision_documents(self): + from api.cases.generated_documents.models import GeneratedCaseDocument + + documents = GeneratedCaseDocument.objects.filter(advice_type__isnull=False, case=self) + documents.update(visible_to_exporter=True) + for document in documents: + document.send_exporter_notifications() + + logging.info("Licence documents published to exporter, notification sent") + class CaseQueue(TimestampableModel): case = models.ForeignKey(Case, related_name="casequeues", on_delete=models.DO_NOTHING) @@ -686,3 +801,15 @@ class EnforcementCheckID(models.Model): id = models.AutoField(primary_key=True) entity_id = models.UUIDField(unique=True) entity_type = models.CharField(choices=EnforcementXMLEntityTypes.choices, max_length=20) + + +class LicenceDecision(TimestampableModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + case = models.ForeignKey(Case, on_delete=models.DO_NOTHING, related_name="licence_decisions") + decision = models.CharField(choices=LicenceDecisionType.choices, max_length=50, null=False, blank=False) + licence = models.ForeignKey( + "licences.Licence", on_delete=models.DO_NOTHING, related_name="licence_decisions", null=True, blank=True + ) + + def __str__(self): + return f"{self.case.reference_code} - {self.decision} ({self.created_at})" diff --git a/api/cases/tests/test_finalise_advice.py b/api/cases/tests/test_finalise_advice.py index 2f9a533af1..c342705acc 100644 --- a/api/cases/tests/test_finalise_advice.py +++ b/api/cases/tests/test_finalise_advice.py @@ -1,23 +1,26 @@ from unittest import mock from django.urls import reverse -from api.audit_trail.enums import AuditType -from api.flags.models import Flag -from api.users.enums import UserType -from api.users.models import BaseUser from rest_framework import status from parameterized import parameterized +from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit -from api.cases.enums import AdviceType, CaseTypeEnum +from api.audit_trail import service as audit_trail_service +from api.cases.enums import AdviceType, CaseTypeEnum, LicenceDecisionType +from api.cases.models import LicenceDecision from api.cases.tests.factories import FinalAdviceFactory from api.cases.libraries.get_case import get_case from api.cases.generated_documents.models import GeneratedCaseDocument -from api.core.constants import GovPermissions +from api.cases.generated_documents.tests.factories import SIELLicenceDocumentFactory +from api.flags.models import Flag +from api.licences.enums import LicenceStatus +from api.licences.tests.factories import StandardLicenceFactory from api.staticdata.decisions.models import Decision from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.statuses.models import CaseStatus +from api.users.enums import UserType +from api.users.models import BaseUser from test_helpers.clients import DataTestClient -from api.audit_trail import service as audit_trail_service from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status @@ -33,13 +36,12 @@ def setUp(self): decisions=[Decision.objects.get(name=AdviceType.REFUSE)], ) - @mock.patch("api.cases.views.views.notify_exporter_licence_refused") + @mock.patch("api.cases.notify.notify_exporter_licence_refused") @mock.patch("api.cases.generated_documents.models.GeneratedCaseDocument.send_exporter_notifications") def test_refuse_standard_application_success(self, send_exporter_notifications_func, mock_notify): - self.gov_user.role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) self.create_generated_case_document(self.application, self.template, advice_type=AdviceType.REFUSE) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.application.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -56,16 +58,25 @@ def test_refuse_standard_application_success(self, send_exporter_notifications_f mock_notify.assert_called_with(case) send_exporter_notifications_func.assert_called() assert case.sub_status.name == "Refused" + self.assertTrue( + Audit.objects.filter( + target_object_id=self.application.id, + verb=AuditType.CREATED_FINAL_RECOMMENDATION, + payload__decision=AdviceType.REFUSE, + ).exists() + ) + self.assertTrue( + LicenceDecision.objects.filter(case=self.application, decision=LicenceDecisionType.REFUSED).exists() + ) - @mock.patch("api.cases.views.views.notify_exporter_licence_refused") + @mock.patch("api.cases.notify.notify_exporter_licence_refused") @mock.patch("api.cases.generated_documents.models.GeneratedCaseDocument.send_exporter_notifications") def test_refuse_standard_application_success_inform_letter_feature_letter_on( self, send_exporter_notifications_func, mock_notify ): - self.gov_user.role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) self.create_generated_case_document(self.application, self.template, advice_type=AdviceType.REFUSE) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.application.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -95,8 +106,8 @@ def setUp(self): decisions=[Decision.objects.get(name=AdviceType.NO_LICENCE_REQUIRED)], ) - @mock.patch("api.cases.views.views.notify_exporter_licence_issued") - @mock.patch("api.cases.views.views.notify_exporter_no_licence_required") + @mock.patch("api.cases.notify.notify_exporter_licence_issued") + @mock.patch("api.cases.notify.notify_exporter_no_licence_required") @mock.patch("api.cases.generated_documents.models.GeneratedCaseDocument.send_exporter_notifications") def test_no_licence_required_standard_application_success( self, @@ -104,10 +115,9 @@ def test_no_licence_required_standard_application_success( mock_notify_exporter_no_licence_required, mock_notify_exporter_licence_issued, ): - self.gov_user.role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) self.create_generated_case_document(self.application, self.template, advice_type=AdviceType.NO_LICENCE_REQUIRED) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.application.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -121,6 +131,14 @@ def test_no_licence_required_standard_application_success( mock_notify_exporter_licence_issued.assert_not_called() send_exporter_notifications_func.assert_called() + self.assertTrue( + Audit.objects.filter( + target_object_id=self.application.id, + verb=AuditType.CREATED_FINAL_RECOMMENDATION, + payload__decision=AdviceType.NO_LICENCE_REQUIRED, + ).exists() + ) + assert case.sub_status == None @@ -153,23 +171,18 @@ def setUp(self): self.url = reverse("cases:finalise", kwargs={"pk": self.application.id}) FinalAdviceFactory(user=self.gov_user, case=self.application, type=AdviceType.APPROVE) - self.template = self.create_letter_template( - name="Template", - case_types=[CaseTypeEnum.SIEL.id], - decisions=[Decision.objects.get(name=AdviceType.NO_LICENCE_REQUIRED)], - ) - @mock.patch("api.cases.views.views.notify_exporter_licence_issued") + @mock.patch("api.cases.notify.notify_exporter_licence_issued") @mock.patch("api.cases.generated_documents.models.GeneratedCaseDocument.send_exporter_notifications") def test_approve_standard_application_success( self, send_exporter_notifications_func, mock_notify_exporter_licence_issued, ): - self.gov_user.role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) - self.create_generated_case_document(self.application, self.template, advice_type=AdviceType.APPROVE) + licence = StandardLicenceFactory(case=self.application, status=LicenceStatus.DRAFT) + SIELLicenceDocumentFactory(case=self.application, licence=licence) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.application.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -182,19 +195,19 @@ def test_approve_standard_application_success( assert case.sub_status.name == "Approved" - @mock.patch("api.cases.views.views.notify_exporter_licence_issued") + @mock.patch("api.cases.notify.notify_exporter_licence_issued") @mock.patch("api.cases.generated_documents.models.GeneratedCaseDocument.send_exporter_notifications") def test_finalised_standard_application_with_flags_removed( self, send_exporter_notifications_func, mock_notify_exporter_licence_issued, ): - self.gov_user.role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) - self.create_generated_case_document(self.application, self.template, advice_type=AdviceType.APPROVE) + licence = StandardLicenceFactory(case=self.application, status=LicenceStatus.DRAFT) + SIELLicenceDocumentFactory(case=self.application, licence=licence) self.assertEqual(self.application.flags.count(), 2) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.application.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_201_CREATED) diff --git a/api/cases/tests/test_grant_licence.py b/api/cases/tests/test_grant_licence.py index 9ac100ac9b..148fdce442 100644 --- a/api/cases/tests/test_grant_licence.py +++ b/api/cases/tests/test_grant_licence.py @@ -4,9 +4,12 @@ from api.licences.enums import LicenceStatus from api.licences.models import Licence +from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit -from api.cases.enums import AdviceType, CaseTypeEnum +from api.cases.enums import AdviceType, LicenceDecisionType +from api.licences.models import LicenceDecision from api.cases.generated_documents.models import GeneratedCaseDocument +from api.cases.generated_documents.tests.factories import SIELLicenceDocumentFactory from api.cases.tests.factories import FinalAdviceFactory from api.core.constants import GovPermissions from api.core.exceptions import PermissionDeniedError @@ -25,27 +28,18 @@ def setUp(self): self.standard_case = self.create_standard_application_case(self.organisation) self.url = reverse("cases:finalise", kwargs={"pk": self.standard_case.id}) FinalAdviceFactory(user=self.gov_user, case=self.standard_case, type=AdviceType.APPROVE) - self.template = self.create_letter_template( - name="Template", - case_types=[CaseTypeEnum.SIEL.id], - decisions=[Decision.objects.get(name=AdviceType.APPROVE)], - ) - @mock.patch("api.cases.views.views.notify_exporter_licence_issued") + @mock.patch("api.cases.notify.notify_exporter_licence_issued") @mock.patch("api.cases.generated_documents.models.GeneratedCaseDocument.send_exporter_notifications") def test_grant_standard_application_success(self, send_exporter_notifications_func, mock_notify): - self.gov_user.role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) licence = StandardLicenceFactory(case=self.standard_case, status=LicenceStatus.DRAFT) - self.create_generated_case_document( - self.standard_case, self.template, advice_type=AdviceType.APPROVE, licence=licence - ) + SIELLicenceDocumentFactory(case=self.standard_case, licence=licence) self.assertIsNone(self.standard_case.appeal_deadline) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.standard_case.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["licence"], str(licence.id)) self.assertEqual( Licence.objects.filter( case=self.standard_case, @@ -59,6 +53,17 @@ def test_grant_standard_application_success(self, send_exporter_notifications_fu self.assertTrue(document.visible_to_exporter) self.assertEqual(Audit.objects.count(), 6) + self.assertTrue( + Audit.objects.filter( + target_object_id=self.standard_case.id, + verb=AuditType.CREATED_FINAL_RECOMMENDATION, + payload__decision=AdviceType.APPROVE, + ).exists() + ) + self.assertTrue( + LicenceDecision.objects.filter(case=self.standard_case, decision=LicenceDecisionType.ISSUED).exists() + ) + self.assertIsNone(self.standard_case.appeal_deadline) send_exporter_notifications_func.assert_called() mock_notify.assert_called_with(self.standard_case.get_case()) @@ -66,7 +71,7 @@ def test_grant_standard_application_success(self, send_exporter_notifications_fu def test_grant_standard_application_wrong_permission_failure(self): self.gov_user.role.permissions.set([GovPermissions.MANAGE_CLEARANCE_FINAL_ADVICE.name]) StandardLicenceFactory(case=self.standard_case, status=LicenceStatus.DRAFT) - self.create_generated_case_document(self.standard_case, self.template, advice_type=AdviceType.APPROVE) + SIELLicenceDocumentFactory(case=self.standard_case) response = self.client.put(self.url, data={}, **self.gov_headers) @@ -74,42 +79,34 @@ def test_grant_standard_application_wrong_permission_failure(self): self.assertEqual(response.json(), {"errors": {"error": PermissionDeniedError.default_detail}}) def test_missing_advice_document_failure(self): - self.gov_user.role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) StandardLicenceFactory(case=self.standard_case, status=LicenceStatus.DRAFT) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json(), {"errors": {"decision-approve": [Cases.Licence.MISSING_DOCUMENTS]}}) def test_finalise_case_without_licence_success(self): - self.gov_user.role.permissions.set([GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name]) - self.create_generated_case_document(self.standard_case, self.template, advice_type=AdviceType.APPROVE) + SIELLicenceDocumentFactory(case=self.standard_case) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json(), {"case": str(self.standard_case.pk)}) + self.assertEqual(response.json(), {"case": str(self.standard_case.pk), "licence": ""}) @mock.patch("api.licences.models.notify_exporter_licence_revoked") - @mock.patch("api.cases.views.views.notify_exporter_licence_issued") + @mock.patch("api.cases.notify.notify_exporter_licence_issued") @mock.patch("api.cases.generated_documents.models.GeneratedCaseDocument.send_exporter_notifications") def test_grant_standard_application_licence_and_revoke( self, send_exporter_notifications_func, mock_notify_licence_issue, mock_notify_licence_revoked ): - self.gov_user.role.permissions.set( - [GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, GovPermissions.REOPEN_CLOSED_CASES.name] - ) licence = StandardLicenceFactory(case=self.standard_case, status=LicenceStatus.DRAFT) - self.create_generated_case_document( - self.standard_case, self.template, advice_type=AdviceType.APPROVE, licence=licence - ) + SIELLicenceDocumentFactory(case=self.standard_case, licence=licence) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.standard_case.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["licence"], str(licence.id)) self.assertEqual( Licence.objects.filter( case=self.standard_case, @@ -135,24 +132,18 @@ def test_grant_standard_application_licence_and_revoke( mock_notify_licence_revoked.assert_called_with(licence) @mock.patch("api.licences.models.notify_exporter_licence_suspended") - @mock.patch("api.cases.views.views.notify_exporter_licence_issued") + @mock.patch("api.cases.notify.notify_exporter_licence_issued") @mock.patch("api.cases.generated_documents.models.GeneratedCaseDocument.send_exporter_notifications") def test_grant_standard_application_licence_and_suspend( self, send_exporter_notifications_func, mock_notify_licence_issue, mock_notify_licence_suspended ): - self.gov_user.role.permissions.set( - [GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, GovPermissions.REOPEN_CLOSED_CASES.name] - ) licence = StandardLicenceFactory(case=self.standard_case, status=LicenceStatus.DRAFT) - self.create_generated_case_document( - self.standard_case, self.template, advice_type=AdviceType.APPROVE, licence=licence - ) + SIELLicenceDocumentFactory(case=self.standard_case, licence=licence) - response = self.client.put(self.url, data={}, **self.gov_headers) + response = self.client.put(self.url, data={}, **self.lu_case_officer_headers) self.standard_case.refresh_from_db() self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.json()["licence"], str(licence.id)) self.assertEqual( Licence.objects.filter( case=self.standard_case, diff --git a/api/cases/views/views.py b/api/cases/views/views.py index 1d4bdbb2ae..c1f4a1d7bb 100644 --- a/api/cases/views/views.py +++ b/api/cases/views/views.py @@ -1,5 +1,3 @@ -import logging - from django.core.exceptions import PermissionDenied from django.db import transaction from django.http.response import JsonResponse, HttpResponse @@ -21,20 +19,12 @@ from api.audit_trail import service as audit_trail_service from api.audit_trail.enums import AuditType from api.cases import notify -from api.cases.enums import ( - CaseTypeSubTypeEnum, - AdviceType, - AdviceLevel, -) +from api.cases.enums import AdviceType, AdviceLevel from api.cases.generated_documents.models import GeneratedCaseDocument from api.cases.generated_documents.serializers import AdviceDocumentGovSerializer from api.cases.helpers import create_system_mention from api.cases.libraries.advice import group_advice -from api.cases.libraries.finalise import ( - get_required_decision_document_types, - remove_flags_on_finalisation, - remove_flags_from_audit_trail, -) +from api.cases.libraries.finalise import get_required_decision_document_types from api.cases.libraries.get_case import get_case, get_case_document from api.cases.libraries.get_destination import get_destination from api.cases.libraries.get_ecju_queries import get_ecju_query @@ -60,11 +50,7 @@ CaseAssignment, ) from api.cases.models import CountersignAdvice -from api.cases.notify import ( - notify_exporter_licence_issued, - notify_exporter_licence_refused, - notify_exporter_no_licence_required, -) + from api.cases.serializers import ( CaseDocumentViewSerializer, CaseDocumentCreateSerializer, @@ -79,7 +65,6 @@ EcjuQueryDocumentCreateSerializer, EcjuQueryDocumentViewSerializer, ) -from api.compliance.helpers import generate_compliance_site_case from api.core import constants from api.core.authentication import GovAuthentication, SharedAuthentication, ExporterAuthentication from api.core.constants import GovPermissions @@ -97,9 +82,7 @@ from api.parties.serializers import PartySerializer, AdditionalContactSerializer from api.queues.models import Queue from api.staticdata.countries.models import Country -from api.staticdata.decisions.models import Decision -from api.staticdata.statuses.enums import CaseStatusEnum, CaseSubStatusIdEnum -from api.staticdata.statuses.libraries.get_case_status import get_case_status_by_status +from api.staticdata.statuses.enums import CaseStatusEnum from api.users.libraries.get_user import get_user_by_pk from lite_content.lite_api import strings from lite_content.lite_api.strings import Documents, Cases @@ -887,11 +870,7 @@ def put(self, request, pk): """ case = get_case(pk) - # Check Permissions - if CaseTypeSubTypeEnum.is_mod_clearance(case.case_type.sub_type): - assert_user_has_permission(request.user.govuser, GovPermissions.MANAGE_CLEARANCE_FINAL_ADVICE) - else: - assert_user_has_permission(request.user.govuser, GovPermissions.MANAGE_LICENCE_FINAL_ADVICE) + assert_user_has_permission(request.user.govuser, GovPermissions.MANAGE_LICENCE_FINAL_ADVICE) required_decisions = get_required_decision_document_types(case) @@ -900,7 +879,6 @@ def put(self, request, pk): required_decisions.remove(AdviceType.INFORM) # Check that each decision has a document - # Excluding approve (done in the licence section below) generated_document_decisions = set( GeneratedCaseDocument.objects.filter(advice_type__isnull=False, case=case).values_list( "advice_type", flat=True @@ -915,106 +893,10 @@ def put(self, request, pk): } ) - return_payload = {"case": pk} - - # If a licence object exists, finalise the licence. - try: - licence = Licence.objects.get_draft_licence(pk) - - if AdviceType.APPROVE in required_decisions: - # Check that a licence document has been created - # (new document required for new licence) - licence_document_exists = GeneratedCaseDocument.objects.filter( - advice_type=AdviceType.APPROVE, licence=licence - ).exists() - if not licence_document_exists: - raise ParseError({"decision-approve": [Cases.Licence.MISSING_LICENCE_DOCUMENT]}) - - audit_trail_service.create( - actor=request.user, - verb=AuditType.CREATED_FINAL_RECOMMENDATION, - target=case, - payload={ - "case_reference": case.reference_code, - "decision": AdviceType.APPROVE, - "licence_reference": licence.reference_code, - }, - ) - - licence.decisions.set([Decision.objects.get(name=decision) for decision in required_decisions]) - - logging.info("Initiate issue of licence %s (status: %s)", licence.reference_code, licence.status) - licence.issue() - - return_payload["licence"] = licence.id - if Licence.objects.filter(case=case).count() > 1: - audit_trail_service.create( - actor=request.user, - verb=AuditType.REINSTATED_APPLICATION, - target=case, - payload={ - "licence_duration": licence.duration, - "start_date": licence.start_date.strftime("%Y-%m-%d"), - }, - ) - generate_compliance_site_case(case) - except Licence.DoesNotExist: - # Do nothing if Licence doesn't exist - pass - - # Finalise Case - old_status = case.status.status - case.status = get_case_status_by_status(CaseStatusEnum.FINALISED) - case.save() - logging.info("Case status is now finalised") - - # Remove Flags and related Audits when Finalising - remove_flags_on_finalisation(case) - remove_flags_from_audit_trail(case) - - decisions = required_decisions.copy() - - if AdviceType.REFUSE in decisions: - case.set_sub_status(CaseSubStatusIdEnum.FINALISED__REFUSED) - notify_exporter_licence_refused(case) - - if AdviceType.NO_LICENCE_REQUIRED in decisions: - notify_exporter_no_licence_required(case) - - if AdviceType.APPROVE in decisions: - case.set_sub_status(CaseSubStatusIdEnum.FINALISED__APPROVED) - notify_exporter_licence_issued(case) - - if AdviceType.APPROVE in decisions: - decisions.remove(AdviceType.APPROVE) - - for decision in decisions: - audit_trail_service.create( - actor=request.user, - verb=AuditType.CREATED_FINAL_RECOMMENDATION, - target=case, - payload={"case_reference": case.reference_code, "decision": decision, "licence_reference": ""}, - ) - - audit_trail_service.create( - actor=request.user, - verb=AuditType.UPDATED_STATUS, - target=case, - payload={ - "status": {"new": case.status.status, "old": old_status}, - "additional_text": request.data.get("note"), - }, - ) - - # Show documents to exporter & notify - documents = GeneratedCaseDocument.objects.filter(advice_type__isnull=False, case=case) - documents.update(visible_to_exporter=True) - for document in documents: - document.send_exporter_notifications() - - logging.info("Licence documents visible to exporter, notification sent") + # finalises case, grants licence and publishes decision documents + licence_id = case.finalise(request, required_decisions) - return JsonResponse(return_payload, status=status.HTTP_201_CREATED) + return JsonResponse({"case": pk, "licence": licence_id}, status=status.HTTP_201_CREATED) class AdditionalContacts(ListCreateAPIView): diff --git a/api/conf/gconfig-dbt-platform.py b/api/conf/gconfig-dbt-platform.py new file mode 100644 index 0000000000..920a7ab514 --- /dev/null +++ b/api/conf/gconfig-dbt-platform.py @@ -0,0 +1,6 @@ +workers = 4 +worker_connections = 1000 +# timeout affect document uploads +# if timeout is set to be too low, documents that would successfully upload will fail. +# creating a 502 bad gateway error +timeout = 600 diff --git a/api/conf/settings.py b/api/conf/settings.py index edb95d77f6..581d9011e5 100644 --- a/api/conf/settings.py +++ b/api/conf/settings.py @@ -124,10 +124,6 @@ "api.external_data", "api.support", "health_check", - "health_check.db", - "health_check.cache", - "health_check.storage", - "health_check.contrib.migrations", "health_check.contrib.celery", "health_check.contrib.celery_ping", "django_audit_log_middleware", @@ -141,6 +137,14 @@ "drf_spectacular", ] +if not IS_ENV_DBT_PLATFORM: + INSTALLED_APPS += [ + "health_check.db", + "health_check.cache", + "health_check.storage", + "health_check.contrib.migrations", + ] + MOCK_VIRUS_SCAN_ACTIVATE_ENDPOINTS = env("MOCK_VIRUS_SCAN_ACTIVATE_ENDPOINTS") if MOCK_VIRUS_SCAN_ACTIVATE_ENDPOINTS: @@ -321,6 +325,7 @@ environment=env.str("SENTRY_ENVIRONMENT"), integrations=[DjangoIntegration()], send_default_pii=True, + traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", 1.0), ) # Application Performance Monitoring @@ -464,12 +469,28 @@ def _build_redis_url(base_url, db_number, **query_args): if IS_ENV_DBT_PLATFORM: ALLOWED_HOSTS = setup_allowed_hosts(ALLOWED_HOSTS) - + AWS_ENDPOINT_URL = env("AWS_ENDPOINT_URL", default=None) + AWS_REGION = "eu-west-2" DATABASES = {"default": dj_database_url.config(default=database_url_from_env("DATABASE_CREDENTIALS"))} CELERY_BROKER_URL = env("CELERY_BROKER_URL", default=None) CELERY_RESULT_BACKEND = CELERY_BROKER_URL REDIS_BASE_URL = env("REDIS_BASE_URL", default=None) + AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") + DB_ANONYMISER_AWS_ENDPOINT_URL = AWS_ENDPOINT_URL + DB_ANONYMISER_AWS_ACCESS_KEY_ID = env("DB_ANONYMISER_AWS_ACCESS_KEY_ID", default=None) + DB_ANONYMISER_AWS_SECRET_ACCESS_KEY = env("DB_ANONYMISER_AWS_SECRET_ACCESS_KEY", default=None) + DB_ANONYMISER_AWS_REGION = env("DB_ANONYMISER_AWS_REGION", default=None) + DB_ANONYMISER_AWS_STORAGE_BUCKET_NAME = env("DB_ANONYMISER_AWS_STORAGE_BUCKET_NAME", default=None) + + if REDIS_BASE_URL: + # Give celery tasks their own redis DB - future uses of redis should use a different DB + REDIS_CELERY_DB = env("REDIS_CELERY_DB", default=0) + is_redis_ssl = REDIS_BASE_URL.startswith("rediss://") + url_args = {"ssl_cert_reqs": "CERT_REQUIRED"} if is_redis_ssl else {} + CELERY_BROKER_URL = _build_redis_url(REDIS_BASE_URL, REDIS_CELERY_DB, **url_args) + CELERY_RESULT_BACKEND = CELERY_BROKER_URL + # Elasticsearch configuration LITE_API_ENABLE_ES = env.bool("LITE_API_ENABLE_ES", False) if LITE_API_ENABLE_ES: diff --git a/api/data_workspace/urls.py b/api/data_workspace/urls.py index 58bbcadd8e..ac00469713 100644 --- a/api/data_workspace/urls.py +++ b/api/data_workspace/urls.py @@ -5,6 +5,7 @@ from api.data_workspace.v0.urls import router_v0 from api.data_workspace.v1.urls import router_v1 +from api.data_workspace.v2.urls import router_v2 app_name = "data_workspace" @@ -12,4 +13,5 @@ urlpatterns = [ path("v0/", include((router_v0.urls, "data_workspace_v0"), namespace="v0")), path("v1/", include((router_v1.urls, "data_workspace_v1"), namespace="v1")), + path("v2/", include((router_v2.urls, "data_workspace_v2"), namespace="v2")), ] diff --git a/api/data_workspace/v2/__init__.py b/api/data_workspace/v2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py new file mode 100644 index 0000000000..d5f48f94cc --- /dev/null +++ b/api/data_workspace/v2/serializers.py @@ -0,0 +1,33 @@ +from rest_framework import serializers + +from api.cases.enums import LicenceDecisionType +from api.cases.models import Case + + +class LicenceDecisionSerializer(serializers.ModelSerializer): + decision = serializers.SerializerMethodField() + decision_made_at = serializers.SerializerMethodField() + + class Meta: + model = Case + fields = ( + "id", + "reference_code", + "decision", + "decision_made_at", + ) + + def get_decision(self, case): + return case.decision + + def get_decision_made_at(self, case): + if case.decision not in LicenceDecisionType.decisions(): + raise ValueError(f"Unknown decision type `{case.decision}`") # pragma: no cover + + return ( + case.licence_decisions.filter( + decision=case.decision, + ) + .earliest("created_at") + .created_at + ) diff --git a/api/data_workspace/v2/tests/__init__.py b/api/data_workspace/v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/data_workspace/v2/tests/bdd/__init__.py b/api/data_workspace/v2/tests/bdd/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/data_workspace/v2/tests/bdd/conftest.py b/api/data_workspace/v2/tests/bdd/conftest.py new file mode 100644 index 0000000000..2e255702de --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -0,0 +1,121 @@ +import json +import pytest + +from rest_framework import status + +from api.cases.enums import CaseTypeEnum +from api.cases.models import CaseType +from api.core.constants import GovPermissions, Roles +from api.letter_templates.models import LetterTemplate +from api.staticdata.letter_layouts.models import LetterLayout +from api.users.libraries.user_to_token import user_to_token +from api.users.enums import SystemUser, UserType +from api.users.models import BaseUser, Permission +from api.users.tests.factories import BaseUserFactory, GovUserFactory, RoleFactory + + +def load_json(filename): + with open(filename) as f: + return json.load(f) + + +@pytest.fixture() +def seed_layouts(): + layouts = load_json("api/data_workspace/v2/tests/bdd/initial_data/letter_layouts.json") + for layout in layouts: + LetterLayout.objects.get_or_create(**layout) + + +@pytest.fixture() +def seed_templates(seed_layouts): + # if this template exists the seed command is executed and all templates are seeded + if LetterTemplate.objects.filter(name="SIEL template").exists(): + return + + templates = load_json("api/data_workspace/v2/tests/bdd/initial_data/letter_templates.json") + for template in templates: + template_instance, _ = LetterTemplate.objects.get_or_create(**template) + template_instance.case_types.add(CaseType.objects.get(id=CaseTypeEnum.SIEL.id)) + + +@pytest.fixture() +def siel_template(seed_templates): + return LetterTemplate.objects.get(layout_id="00000000-0000-0000-0000-000000000001") + + +@pytest.fixture() +def siel_refusal_template(seed_templates): + return LetterTemplate.objects.get(layout_id="00000000-0000-0000-0000-000000000006") + + +@pytest.fixture(autouse=True) +def system_user(db): + if BaseUser.objects.filter(id=SystemUser.id).exists(): + return BaseUser.objects.get(id=SystemUser.id) + else: + return BaseUserFactory(id=SystemUser.id) + + +@pytest.fixture() +def gov_user(): + return GovUserFactory() + + +@pytest.fixture() +def lu_user(): + return GovUserFactory() + + +@pytest.fixture() +def gov_user_permissions(): + for permission in GovPermissions: + Permission.objects.get_or_create(id=permission.name, name=permission.value, type=UserType.INTERNAL.value) + + +@pytest.fixture() +def lu_case_officer(gov_user, gov_user_permissions): + gov_user.role = RoleFactory(name="Case officer", type=UserType.INTERNAL) + gov_user.role.permissions.set( + [GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, GovPermissions.MANAGE_LICENCE_DURATION.name] + ) + gov_user.save() + return gov_user + + +@pytest.fixture() +def lu_senior_manager(lu_user, gov_user_permissions): + lu_user.role = RoleFactory( + id=Roles.INTERNAL_LU_SENIOR_MANAGER_ROLE_ID, name="LU Senior Manager", type=UserType.INTERNAL + ) + lu_user.role.permissions.set( + [GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, GovPermissions.MANAGE_LICENCE_DURATION.name] + ) + lu_user.save() + return lu_user + + +@pytest.fixture() +def gov_headers(gov_user): + return {"HTTP_GOV_USER_TOKEN": user_to_token(gov_user.baseuser_ptr)} + + +@pytest.fixture() +def lu_sr_manager_headers(lu_senior_manager): + return {"HTTP_GOV_USER_TOKEN": user_to_token(lu_senior_manager.baseuser_ptr)} + + +@pytest.fixture() +def unpage_data(client): + def _unpage_data(url): + unpaged_results = [] + while True: + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + unpaged_results += response.data["results"] + if not response.data["next"]: + break + url = response.data["next"] + + return unpaged_results + + return _unpage_data diff --git a/api/data_workspace/v2/tests/bdd/initial_data/letter_layouts.json b/api/data_workspace/v2/tests/bdd/initial_data/letter_layouts.json new file mode 100644 index 0000000000..357579e083 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/initial_data/letter_layouts.json @@ -0,0 +1,17 @@ +[ + { + "id": "00000000-0000-0000-0000-000000000001", + "name": "SIEL", + "filename": "siel" + }, + { + "id": "00000000-0000-0000-0000-000000000003", + "name": "No Licence Required Letter", + "filename": "nlr" + }, + { + "id": "00000000-0000-0000-0000-000000000006", + "name": "Refusal Letter", + "filename": "refusal" + } +] \ No newline at end of file diff --git a/api/data_workspace/v2/tests/bdd/initial_data/letter_templates.json b/api/data_workspace/v2/tests/bdd/initial_data/letter_templates.json new file mode 100644 index 0000000000..00cb3c626d --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/initial_data/letter_templates.json @@ -0,0 +1,23 @@ +[ + { + "id": "d159b195-9256-4a00-9bc8-1eb2cebfa1d2", + "name": "SIEL template", + "layout_id": "00000000-0000-0000-0000-000000000001", + "visible_to_exporter": true, + "include_digital_signature": true + }, + { + "id": "074d8a54-ee10-4dca-82ba-650460650342", + "name": "Refusal letter template", + "layout_id": "00000000-0000-0000-0000-000000000006", + "visible_to_exporter": true, + "include_digital_signature": true + }, + { + "id": "d71c3cfc-a127-46b6-96c0-a435cdd63cdb", + "name": "No licence required letter template", + "layout_id": "00000000-0000-0000-0000-000000000003", + "visible_to_exporter": true, + "include_digital_signature": true + } +] \ No newline at end of file diff --git a/api/data_workspace/v2/tests/bdd/licences/conftest.py b/api/data_workspace/v2/tests/bdd/licences/conftest.py new file mode 100644 index 0000000000..91fa28de4c --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/licences/conftest.py @@ -0,0 +1,72 @@ +import pytest + +from api.applications.tests.factories import GoodOnApplicationFactory, StandardApplicationFactory +from api.cases.enums import AdviceType +from api.cases.tests.factories import FinalAdviceFactory +from api.goods.tests.factories import GoodFactory +from api.licences.enums import LicenceStatus +from api.licences.tests.factories import GoodOnLicenceFactory, StandardLicenceFactory +from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.statuses.models import CaseStatus +from api.staticdata.units.enums import Units + + +@pytest.fixture() +def standard_draft_licence(): + application = StandardApplicationFactory( + status=CaseStatus.objects.get(status=CaseStatusEnum.FINALISED), + ) + good = GoodFactory(organisation=application.organisation) + good_on_application = GoodOnApplicationFactory( + application=application, good=good, quantity=100.0, value=1500, unit=Units.NAR + ) + licence = StandardLicenceFactory(case=application, status=LicenceStatus.DRAFT) + GoodOnLicenceFactory( + good=good_on_application, + quantity=good_on_application.quantity, + usage=0.0, + value=good_on_application.value, + licence=licence, + ) + return licence + + +@pytest.fixture() +def standard_licence(): + application = StandardApplicationFactory( + status=CaseStatus.objects.get(status=CaseStatusEnum.FINALISED), + ) + good = GoodFactory(organisation=application.organisation) + good_on_application = GoodOnApplicationFactory( + application=application, good=good, quantity=100.0, value=1500, unit=Units.NAR + ) + licence = StandardLicenceFactory(case=application, status=LicenceStatus.DRAFT) + GoodOnLicenceFactory( + good=good_on_application, + quantity=good_on_application.quantity, + usage=0.0, + value=good_on_application.value, + licence=licence, + ) + licence.status = LicenceStatus.ISSUED + licence.save() + return licence + + +@pytest.fixture() +def standard_case_with_final_advice(lu_case_officer): + case = StandardApplicationFactory( + status=CaseStatus.objects.get(status=CaseStatusEnum.UNDER_FINAL_REVIEW), + ) + good = GoodFactory(organisation=case.organisation) + good_on_application = GoodOnApplicationFactory( + application=case, good=good, quantity=100.0, value=1500, unit=Units.NAR + ) + FinalAdviceFactory(user=lu_case_officer, case=case, good=good_on_application.good) + return case + + +@pytest.fixture() +def standard_case_with_refused_advice(lu_case_officer, standard_case_with_final_advice): + standard_case_with_final_advice.advice.update(type=AdviceType.REFUSE) + return standard_case_with_final_advice diff --git a/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py b/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py new file mode 100644 index 0000000000..887aa903cc --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py @@ -0,0 +1,223 @@ +import pytest + +from django.urls import reverse +from django.utils import timezone +from pytest_bdd import ( + given, + then, + when, + scenarios, +) +from unittest import mock + +from api.cases.enums import AdviceType, LicenceDecisionType +from api.cases.models import LicenceDecision +from api.licences.enums import LicenceStatus +from api.licences.models import Licence +from api.staticdata.statuses.enums import CaseStatusEnum + + +scenarios("../scenarios/licence_decisions.feature") + + +@pytest.fixture() +def licence_decisions_list_url(): + return reverse("data_workspace:v2:dw-licence-decisions-list") + + +@when("I fetch all licence decisions", target_fixture="licence_decisions") +def fetch_licence_decisions(licence_decisions_list_url, unpage_data): + return unpage_data(licence_decisions_list_url) + + +@given("a standard draft licence is created", target_fixture="draft_licence") +def standard_draft_licence_created(standard_draft_licence): + assert standard_draft_licence.status == LicenceStatus.DRAFT + return standard_draft_licence + + +@then("the draft licence is not included in the extract") +def draft_licence_not_included_in_extract(draft_licence, unpage_data, licence_decisions_list_url): + licences = unpage_data(licence_decisions_list_url) + + assert draft_licence.reference_code not in [item["reference_code"] for item in licences] + + +@given("a standard licence is cancelled", target_fixture="cancelled_licence") +def standard_licence_is_cancelled(standard_licence): + standard_licence.status = LicenceStatus.CANCELLED + standard_licence.save() + + return standard_licence + + +@then("the cancelled licence is not included in the extract") +def cancelled_licence_not_included_in_extract(cancelled_licence, unpage_data, licence_decisions_list_url): + licences = unpage_data(licence_decisions_list_url) + + assert cancelled_licence.reference_code not in [item["reference_code"] for item in licences] + + +@then("I see issued licence is included in the extract") +def licence_included_in_extract(issued_licence, unpage_data, licence_decisions_list_url): + licences = unpage_data(licence_decisions_list_url) + + assert issued_licence.reference_code in [item["reference_code"] for item in licences] + + +@then("I see refused case is included in the extract") +def refused_case_included_in_extract(refused_case, unpage_data, licence_decisions_list_url): + licences = unpage_data(licence_decisions_list_url) + + assert refused_case.reference_code in [item["reference_code"] for item in licences] + + +@given("a case is ready to be finalised", target_fixture="case_with_final_advice") +def case_ready_to_be_finalised(standard_case_with_final_advice): + assert standard_case_with_final_advice.status.status == CaseStatusEnum.UNDER_FINAL_REVIEW + return standard_case_with_final_advice + + +@given("a case is ready to be refused", target_fixture="case_with_refused_advice") +def case_ready_to_be_refused(standard_case_with_refused_advice): + assert standard_case_with_refused_advice.status.status == CaseStatusEnum.UNDER_FINAL_REVIEW + return standard_case_with_refused_advice + + +@when("the licence for the case is approved") +def licence_for_case_is_approved(client, gov_headers, case_with_final_advice): + data = {"action": AdviceType.APPROVE, "duration": 24} + for good_on_app in case_with_final_advice.goods.all(): + data[f"quantity-{good_on_app.id}"] = str(good_on_app.quantity) + data[f"value-{good_on_app.id}"] = str(good_on_app.value) + + issue_date = timezone.now() + data.update({"year": issue_date.year, "month": issue_date.month, "day": issue_date.day}) + + url = reverse("applications:finalise", kwargs={"pk": case_with_final_advice.id}) + response = client.put(url, data, content_type="application/json", **gov_headers) + assert response.status_code == 200 + response = response.json() + + assert response["reference_code"] is not None + licence = Licence.objects.get(reference_code=response["reference_code"]) + assert licence.status == LicenceStatus.DRAFT + + +@when("case officer generates licence documents") +def case_officer_generates_licence_documents(client, siel_template, gov_headers, case_with_final_advice): + data = { + "template": str(siel_template.id), + "text": "", + "visible_to_exporter": False, + "advice_type": AdviceType.APPROVE, + } + url = reverse( + "cases:generated_documents:generated_documents", + kwargs={"pk": str(case_with_final_advice.pk)}, + ) + with mock.patch("api.cases.generated_documents.views.s3_operations.upload_bytes_file", return_value=None): + response = client.post(url, data, content_type="application/json", **gov_headers) + assert response.status_code == 201 + + +@when("case officer issues licence for this case", target_fixture="issued_licence") +def case_officer_issues_licence(client, gov_headers, case_with_final_advice): + url = reverse( + "cases:finalise", + kwargs={"pk": str(case_with_final_advice.pk)}, + ) + response = client.put(url, {}, content_type="application/json", **gov_headers) + assert response.status_code == 201 + + case_with_final_advice.refresh_from_db() + assert case_with_final_advice.status.status == CaseStatusEnum.FINALISED + assert case_with_final_advice.sub_status.name == "Approved" + + response = response.json() + assert response["licence"] is not None + + licence = Licence.objects.get(id=response["licence"]) + assert licence.status == LicenceStatus.ISSUED + + assert LicenceDecision.objects.filter( + case=case_with_final_advice, + decision=LicenceDecisionType.ISSUED, + ).exists() + + return licence + + +@when("the licence for the case is refused") +def licence_for_case_is_refused(client, gov_headers, case_with_refused_advice): + data = {"action": AdviceType.REFUSE} + + url = reverse("applications:finalise", kwargs={"pk": case_with_refused_advice.id}) + response = client.put(url, data, content_type="application/json", **gov_headers) + assert response.status_code == 200 + + +@when("case officer generates refusal documents") +def generate_refusal_documents(client, siel_refusal_template, gov_headers, case_with_refused_advice): + data = { + "template": str(siel_refusal_template.id), + "text": "", + "visible_to_exporter": False, + "advice_type": AdviceType.REFUSE, + } + url = reverse( + "cases:generated_documents:generated_documents", + kwargs={"pk": str(case_with_refused_advice.pk)}, + ) + with mock.patch("api.cases.generated_documents.views.s3_operations.upload_bytes_file", return_value=None): + response = client.post(url, data, content_type="application/json", **gov_headers) + assert response.status_code == 201 + + +@when("case officer refuses licence for this case", target_fixture="refused_case") +def licence_for_case_is_refused(client, gov_headers, case_with_refused_advice): + url = reverse( + "cases:finalise", + kwargs={"pk": str(case_with_refused_advice.pk)}, + ) + response = client.put(url, {}, content_type="application/json", **gov_headers) + assert response.status_code == 201 + + case_with_refused_advice.refresh_from_db() + assert case_with_refused_advice.status.status == CaseStatusEnum.FINALISED + assert case_with_refused_advice.sub_status.name == "Refused" + + assert LicenceDecision.objects.filter( + case=case_with_refused_advice, + decision=LicenceDecisionType.REFUSED, + ).exists() + + return case_with_refused_advice + + +@when("case officer revokes issued licence", target_fixture="revoked_licence") +def case_officer_revokes_licence(client, lu_sr_manager_headers, issued_licence): + url = reverse("licences:licence_details", kwargs={"pk": str(issued_licence.pk)}) + response = client.patch( + url, {"status": LicenceStatus.REVOKED}, content_type="application/json", **lu_sr_manager_headers + ) + assert response.status_code == 200 + + assert LicenceDecision.objects.filter( + case=issued_licence.case, + decision=LicenceDecisionType.REVOKED, + ).exists() + + revoked_licence = LicenceDecision.objects.get( + case=issued_licence.case, decision=LicenceDecisionType.REVOKED + ).licence + + return revoked_licence + + +@then("I see revoked licence is included in the extract") +def revoked_licence_decision_included_in_extract(licence_decisions, revoked_licence): + + all_revoked_licences = [item for item in licence_decisions if item["decision"] == "revoked"] + + assert revoked_licence.case.reference_code in [item["reference_code"] for item in all_revoked_licences] diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature b/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature new file mode 100644 index 0000000000..a283968a30 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature @@ -0,0 +1,36 @@ +@db +Feature: Licence Decisions + +Scenario: Check that draft licences are not included in the extract + Given a standard draft licence is created + Then the draft licence is not included in the extract + +Scenario: Check that cancelled licences are not included in the extract + Given a standard licence is cancelled + Then the cancelled licence is not included in the extract + +Scenario: Issued licence decision is created when licence is issued + Given a case is ready to be finalised + When the licence for the case is approved + And case officer generates licence documents + And case officer issues licence for this case + When I fetch all licence decisions + Then I see issued licence is included in the extract + +Scenario: Refused licence decision is created when licence is refused + Given a case is ready to be refused + When the licence for the case is refused + And case officer generates refusal documents + And case officer refuses licence for this case + When I fetch all licence decisions + Then I see refused case is included in the extract + +Scenario: Revoked licence decision is created when licence is revoked + Given a case is ready to be finalised + When the licence for the case is approved + And case officer generates licence documents + And case officer issues licence for this case + Then I see issued licence is included in the extract + When case officer revokes issued licence + And I fetch all licence decisions + Then I see revoked licence is included in the extract diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py new file mode 100644 index 0000000000..a8f6055a1a --- /dev/null +++ b/api/data_workspace/v2/urls.py @@ -0,0 +1,12 @@ +from rest_framework.routers import DefaultRouter + +from api.data_workspace.v2 import views + + +router_v2 = DefaultRouter() + +router_v2.register( + "licence-decisions", + views.LicenceDecisionViewSet, + basename="dw-licence-decisions", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py new file mode 100644 index 0000000000..ce28e0dd81 --- /dev/null +++ b/api/data_workspace/v2/views.py @@ -0,0 +1,57 @@ +from rest_framework import viewsets +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.settings import api_settings + +from rest_framework_csv.renderers import PaginatedCSVRenderer + +from django.contrib.postgres.aggregates import ArrayAgg +from django.db.models import F +from api.cases.models import Case +from api.core.authentication import DataWorkspaceOnlyAuthentication +from api.core.helpers import str_to_bool +from api.data_workspace.v2.serializers import ( + LicenceDecisionSerializer, + LicenceDecisionType, +) + + +class DisableableLimitOffsetPagination(LimitOffsetPagination): + def paginate_queryset(self, queryset, request, view=None): + if str_to_bool(request.GET.get("disable_pagination", False)): + return # pragma: no cover + + return super().paginate_queryset(queryset, request, view) + + +class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = LicenceDecisionSerializer + + def get_queryset(self): + queryset = ( + ( + Case.objects.filter( + licence_decisions__decision__in=[LicenceDecisionType.ISSUED, LicenceDecisionType.REFUSED], + ) + .annotate( + unique_decisions=ArrayAgg("licence_decisions__decision", distinct=True), + ) + .filter(unique_decisions__len=1) + .annotate(decision=F("unique_decisions__0")) + ) + .union( + Case.objects.filter( + licence_decisions__decision__in=[LicenceDecisionType.REVOKED], + ) + .annotate( + unique_decisions=ArrayAgg("licence_decisions__decision", distinct=True), + ) + .filter(unique_decisions__len=1) + .annotate(decision=F("unique_decisions__0")), + all=True, + ) + .order_by("-reference_code") + ) + return queryset diff --git a/api/documents/libraries/s3_operations.py b/api/documents/libraries/s3_operations.py index 5253a17685..98d7daf163 100644 --- a/api/documents/libraries/s3_operations.py +++ b/api/documents/libraries/s3_operations.py @@ -8,6 +8,7 @@ from django.conf import settings from django.http import FileResponse +from dbt_copilot_python.utility import is_copilot logger = logging.getLogger(__name__) @@ -23,14 +24,23 @@ def init_s3_client(): additional_s3_params = {} if settings.AWS_ENDPOINT_URL: additional_s3_params["endpoint_url"] = settings.AWS_ENDPOINT_URL - _client = boto3.client( - "s3", - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - region_name=settings.AWS_REGION, - config=Config(connect_timeout=settings.S3_CONNECT_TIMEOUT, read_timeout=settings.S3_REQUEST_TIMEOUT), - **additional_s3_params, - ) + + if is_copilot(): + _client = boto3.client( + "s3", + region_name=settings.AWS_REGION, + config=Config(connect_timeout=settings.S3_CONNECT_TIMEOUT, read_timeout=settings.S3_REQUEST_TIMEOUT), + **additional_s3_params, + ) + else: + _client = boto3.client( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + region_name=settings.AWS_REGION, + config=Config(connect_timeout=settings.S3_CONNECT_TIMEOUT, read_timeout=settings.S3_REQUEST_TIMEOUT), + **additional_s3_params, + ) return _client diff --git a/api/documents/libraries/tests/test_s3_operations.py b/api/documents/libraries/tests/test_s3_operations.py index 02fb348911..17e4889c47 100644 --- a/api/documents/libraries/tests/test_s3_operations.py +++ b/api/documents/libraries/tests/test_s3_operations.py @@ -87,6 +87,31 @@ def test_get_client_with_aws_endpoint_url(self, mock_Config, mock_boto3): endpoint_url="AWS_ENDPOINT_URL", ) + @patch("api.documents.libraries.s3_operations.is_copilot") + @patch("api.documents.libraries.s3_operations._client") + def test_get_client_with_is_copilot(self, mock_client, mock_is_copilot, mock_Config, mock_boto3): + mock_is_copilot.return_value = True + mock_client = Mock() + mock_boto3.client.return_value = mock_client + + returned_client = init_s3_client() + self.assertEqual(returned_client, mock_client) + + mock_Config.assert_called_with( + connect_timeout=22, + read_timeout=44, + ) + config = mock_Config( + connection_timeout=22, + read_timeout=44, + ) + mock_boto3.client.assert_called_with( + "s3", + region_name="AWS_REGION", + config=config, + endpoint_url="AWS_ENDPOINT_URL", + ) + @override_settings( AWS_STORAGE_BUCKET_NAME="test-bucket", diff --git a/api/gov_users/tests/test_roles_and_permissions.py b/api/gov_users/tests/test_roles_and_permissions.py index 9031ad6c5d..a0fedb9d74 100644 --- a/api/gov_users/tests/test_roles_and_permissions.py +++ b/api/gov_users/tests/test_roles_and_permissions.py @@ -129,7 +129,7 @@ def test_only_roles_that_a_user_sees_are_roles_with_a_subset_of_the_permissions_ response_data = response.json()["roles"] self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response_data), 6) + self.assertEqual(len(response_data), 5) self.assertIn(str(Role.objects.get(name="multi permission role").id), str(response_data)) self.assertIn( str(Role.objects.get(name=constants.GovPermissions.MANAGE_TEAM_ADVICE.name).id), str(response_data) @@ -172,7 +172,7 @@ def test_only_roles_that_a_user_sees_are_roles_with_a_subset_of_the_permissions_ response_data = response.json()["roles"] self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response_data), 4) + self.assertEqual(len(response_data), 3) self.assertIn( str(Role.objects.get(name=constants.GovPermissions.MANAGE_TEAM_ADVICE.name).id), str(response_data) ) @@ -207,7 +207,7 @@ def test_only_roles_that_a_user_sees_are_roles_with_a_subset_of_the_permissions_ response_data = response.json()["roles"] self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response_data), 3) + self.assertEqual(len(response_data), 2) self.assertIn( str(Role.objects.get(name=constants.GovPermissions.MANAGE_TEAM_ADVICE.name).id), str(response_data) ) diff --git a/api/letter_templates/tests/factories.py b/api/letter_templates/tests/factories.py new file mode 100644 index 0000000000..3d3b646b0f --- /dev/null +++ b/api/letter_templates/tests/factories.py @@ -0,0 +1,44 @@ +import factory + +from api.cases.enums import CaseTypeEnum + +from api.letter_templates.models import LetterTemplate +from api.staticdata.letter_layouts.models import LetterLayout + + +class LayoutFactory(factory.django.DjangoModelFactory): + name = factory.Faker("word") + filename = factory.Faker("word") + + class Meta: + model = LetterLayout + + +class LetterTemplateFactory(factory.django.DjangoModelFactory): + name = factory.Faker("word") + layout = factory.SubFactory(LayoutFactory) + visible_to_exporter = False + include_digital_signature = False + + @factory.post_generation + def case_types(self, create, extracted, **kwargs): + if not create: + return + + case_types = extracted or [CaseTypeEnum.SIEL.id] + self.case_types.set(case_types) + + @factory.post_generation + def decisions(self, create, extracted, **kwargs): + if not create: + return + + decisions = extracted or [] + self.decisions.set(decisions) + + class Meta: + model = LetterTemplate + + +class SIELLicenceTemplateFactory(LetterTemplateFactory): + layout_id = "00000000-0000-0000-0000-000000000001" diff --git a/api/licences/enums.py b/api/licences/enums.py index 3aa338ea76..2458571c31 100644 --- a/api/licences/enums.py +++ b/api/licences/enums.py @@ -45,6 +45,10 @@ def to_str(cls, status): def can_edit_status(cls, status): return status in cls._can_edit_status + @classmethod + def all(cls): + return [getattr(cls, param) for param in dir(cls) if param.isupper()] # pragma: no cover + hmrc_integration_action_to_licence_status = { HMRCIntegrationActionEnum.SURRENDER: LicenceStatus.SURRENDERED, diff --git a/api/licences/models.py b/api/licences/models.py index 748c54a97f..02738e5134 100644 --- a/api/licences/models.py +++ b/api/licences/models.py @@ -8,7 +8,8 @@ from django.core.exceptions import ImproperlyConfigured from api.applications.models import GoodOnApplication -from api.cases.models import Case +from api.cases.enums import LicenceDecisionType +from api.cases.models import Case, LicenceDecision from api.common.models import TimestampableModel from api.core.helpers import add_months from api.licences.enums import LicenceStatus, licence_status_to_hmrc_integration_action @@ -97,6 +98,11 @@ def suspend(self, user=None): def revoke(self, user=None): self._set_status(LicenceStatus.REVOKED, user=user, send_status_change_to_hmrc=True) + LicenceDecision.objects.create( + case=self.case, + decision=LicenceDecisionType.REVOKED, + licence=self, + ) notify_exporter_licence_revoked(self) def cancel(self, user=None, send_status_change_to_hmrc=True): diff --git a/api/teams/tests/test_list_user_by_team.py b/api/teams/tests/test_list_user_by_team.py index f2db411ae0..d22dcae7c4 100644 --- a/api/teams/tests/test_list_user_by_team.py +++ b/api/teams/tests/test_list_user_by_team.py @@ -39,6 +39,6 @@ def test_view_user_by_team(self): response_data = response.json() self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response_data["users"]), self.gov_user_preexisting_count + 1) + self.assertEqual(len(response_data["users"]), self.gov_user_preexisting_count) self.assertContains(response, "test2@mail.com") self.assertNotContains(response, "test3@mail.com") diff --git a/test_helpers/clients.py b/test_helpers/clients.py index 4a41403d0d..9645dc72fc 100644 --- a/test_helpers/clients.py +++ b/test_helpers/clients.py @@ -43,7 +43,7 @@ ) from api.cases.celery_tasks import get_application_target_sla from django.conf import settings -from api.core.constants import Roles +from api.core.constants import GovPermissions, Roles from api.conf.urls import urlpatterns from api.documents.libraries.s3_operations import init_s3_client from api.flags.enums import SystemFlags, FlagStatuses, FlagLevels @@ -139,6 +139,17 @@ def setUp(self): self.gov_user.save() self.gov_headers = {"HTTP_GOV_USER_TOKEN": user_to_token(self.base_user)} + self.lu_case_officer = GovUserFactory( + baseuser_ptr__email="case.officer@lu.gov.uk", + baseuser_ptr__first_name="Case", + baseuser_ptr__last_name="Officer", + team=Team.objects.get(name="Licensing Unit"), + ) + self.lu_case_officer_headers = {"HTTP_GOV_USER_TOKEN": user_to_token(self.lu_case_officer.baseuser_ptr)} + self.lu_case_officer.role.permissions.set( + [GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, GovPermissions.REOPEN_CLOSED_CASES.name] + ) + # Exporter User Setup (self.organisation, self.exporter_user) = self.create_organisation_with_exporter_user() (self.hmrc_organisation, self.hmrc_exporter_user) = self.create_organisation_with_exporter_user(