From 5d58f86c3f6e623a6f4eb168a75127885858d22e Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 21 Aug 2024 20:44:25 +0100 Subject: [PATCH 01/89] Add DRF csv renderer --- Pipfile | 1 + Pipfile.lock | 264 ++++++++++++++++++++++++++------------------------- 2 files changed, 137 insertions(+), 128 deletions(-) diff --git a/Pipfile b/Pipfile index 5efe7d68b..47f1b5ba2 100644 --- a/Pipfile +++ b/Pipfile @@ -79,6 +79,7 @@ dj-database-url = "~=2.2.0" certifi = "~=2024.7.4" pytz = "~=2024.1" drf-spectacular = "~=0.27.2" +djangorestframework-csv = "~=3.0.2" [requires] python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock index 5920071ed..7fd58c6d8 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a02161b9424791a7c4d75975805967c8db8d9c804cc31cff2135462027c93a04" + "sha256": "18f9623880f1a9c30b19e3b17cef551b5b18306b7ff85a746011c06fb34b37df" }, "pipfile-spec": 6, "requires": { @@ -650,6 +650,14 @@ "markers": "python_version >= '3.8'", "version": "==3.15.2" }, + "djangorestframework-csv": { + "hashes": [ + "sha256:b269b692feda1971e1342f395a21d339c6a16d2961ff64357a9a6188f27af10f", + "sha256:d1bcfbaaeaa5145af6bb0985a36a5bbf2f853d9961c722f69c7b0c9c3bcc269a" + ], + "index": "pypi", + "version": "==3.0.2" + }, "docopt": { "hashes": [ "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" @@ -720,11 +728,11 @@ }, "et-xmlfile": { "hashes": [ - "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", - "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada" + "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", + "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54" ], - "markers": "python_version >= '3.6'", - "version": "==1.1.0" + "markers": "python_version >= '3.8'", + "version": "==2.0.0" }, "factory-boy": { "hashes": [ @@ -904,64 +912,64 @@ }, "grpcio": { "hashes": [ - "sha256:014dfc020e28a0d9be7e93a91f85ff9f4a87158b7df9952fe23cc42d29d31e1e", - "sha256:0892dd200ece4822d72dd0952f7112c542a487fc48fe77568deaaa399c1e717d", - "sha256:0bb94e66cd8f0baf29bd3184b6aa09aeb1a660f9ec3d85da615c5003154bc2bf", - "sha256:0c69bf11894cad9da00047f46584d5758d6ebc9b5950c0dc96fec7e0bce5cde9", - "sha256:15c05a26a0f7047f720da41dc49406b395c1470eef44ff7e2c506a47ac2c0591", - "sha256:16724ffc956ea42967f5758c2f043faef43cb7e48a51948ab593570570d1e68b", - "sha256:227316b5631260e0bef8a3ce04fa7db4cc81756fea1258b007950b6efc90c05d", - "sha256:2b7183c80b602b0ad816315d66f2fb7887614ead950416d60913a9a71c12560d", - "sha256:2f55c1e0e2ae9bdd23b3c63459ee4c06d223b68aeb1961d83c48fb63dc29bc03", - "sha256:30d47dbacfd20cbd0c8be9bfa52fdb833b395d4ec32fe5cff7220afc05d08571", - "sha256:323741b6699cd2b04a71cb38f502db98f90532e8a40cb675393d248126a268af", - "sha256:3840994689cc8cbb73d60485c594424ad8adb56c71a30d8948d6453083624b52", - "sha256:391df8b0faac84d42f5b8dfc65f5152c48ed914e13c522fd05f2aca211f8bfad", - "sha256:42199e704095b62688998c2d84c89e59a26a7d5d32eed86d43dc90e7a3bd04aa", - "sha256:54d16383044e681f8beb50f905249e4e7261dd169d4aaf6e52eab67b01cbbbe2", - "sha256:5a1e03c3102b6451028d5dc9f8591131d6ab3c8a0e023d94c28cb930ed4b5f81", - "sha256:62492bd534979e6d7127b8a6b29093161a742dee3875873e01964049d5250a74", - "sha256:662c8e105c5e5cee0317d500eb186ed7a93229586e431c1bf0c9236c2407352c", - "sha256:682968427a63d898759474e3b3178d42546e878fdce034fd7474ef75143b64e3", - "sha256:74b900566bdf68241118f2918d312d3bf554b2ce0b12b90178091ea7d0a17b3d", - "sha256:77196216d5dd6f99af1c51e235af2dd339159f657280e65ce7e12c1a8feffd1d", - "sha256:7f200aca719c1c5dc72ab68be3479b9dafccdf03df530d137632c534bb6f1ee3", - "sha256:7fc1d2b9fd549264ae585026b266ac2db53735510a207381be509c315b4af4e8", - "sha256:82e5bd4b67b17c8c597273663794a6a46a45e44165b960517fe6d8a2f7f16d23", - "sha256:8c9a35b8bc50db35ab8e3e02a4f2a35cfba46c8705c3911c34ce343bd777813a", - "sha256:985b2686f786f3e20326c4367eebdaed3e7aa65848260ff0c6644f817042cb15", - "sha256:9d75641a2fca9ae1ae86454fd25d4c298ea8cc195dbc962852234d54a07060ad", - "sha256:a4e95e43447a02aa603abcc6b5e727d093d161a869c83b073f50b9390ecf0fa8", - "sha256:a6b9a5c18863fd4b6624a42e2712103fb0f57799a3b29651c0e5b8119a519d65", - "sha256:aa8d025fae1595a207b4e47c2e087cb88d47008494db258ac561c00877d4c8f8", - "sha256:ac11ecb34a86b831239cc38245403a8de25037b448464f95c3315819e7519772", - "sha256:ae6de510f670137e755eb2a74b04d1041e7210af2444103c8c95f193340d17ee", - "sha256:b2a44e572fb762c668e4812156b81835f7aba8a721b027e2d4bb29fb50ff4d33", - "sha256:b6eb68493a05d38b426604e1dc93bfc0137c4157f7ab4fac5771fd9a104bbaa6", - "sha256:b9bca3ca0c5e74dea44bf57d27e15a3a3996ce7e5780d61b7c72386356d231db", - "sha256:bd79929b3bb96b54df1296cd3bf4d2b770bd1df6c2bdf549b49bab286b925cdc", - "sha256:c4c425f440fb81f8d0237c07b9322fc0fb6ee2b29fbef5f62a322ff8fcce240d", - "sha256:cb204a742997277da678611a809a8409657b1398aaeebf73b3d9563b7d154c13", - "sha256:cf51d28063338608cd8d3cd64677e922134837902b70ce00dad7f116e3998210", - "sha256:cfd9306511fdfc623a1ba1dc3bc07fbd24e6cfbe3c28b4d1e05177baa2f99617", - "sha256:cff8e54d6a463883cda2fab94d2062aad2f5edd7f06ae3ed030f2a74756db365", - "sha256:d01793653248f49cf47e5695e0a79805b1d9d4eacef85b310118ba1dfcd1b955", - "sha256:d4ea4509d42c6797539e9ec7496c15473177ce9abc89bc5c71e7abe50fc25737", - "sha256:d90cfdafcf4b45a7a076e3e2a58e7bc3d59c698c4f6470b0bb13a4d869cf2273", - "sha256:e090b2553e0da1c875449c8e75073dd4415dd71c9bde6a406240fdf4c0ee467c", - "sha256:e91d154689639932305b6ea6f45c6e46bb51ecc8ea77c10ef25aa77f75443ad4", - "sha256:eef1dce9d1a46119fd09f9a992cf6ab9d9178b696382439446ca5f399d7b96fe", - "sha256:efe32b45dd6d118f5ea2e5deaed417d8a14976325c93812dd831908522b402c9", - "sha256:f4d613fbf868b2e2444f490d18af472ccb47660ea3df52f068c9c8801e1f3e85", - "sha256:f55f077685f61f0fbd06ea355142b71e47e4a26d2d678b3ba27248abfe67163a", - "sha256:f623c57a5321461c84498a99dddf9d13dac0e40ee056d884d6ec4ebcab647a78", - "sha256:f6bd2ab135c64a4d1e9e44679a616c9bc944547357c830fafea5c3caa3de5153", - "sha256:f95e15db43e75a534420e04822df91f645664bf4ad21dfaad7d51773c80e6bb4", - "sha256:fd6bc27861e460fe28e94226e3673d46e294ca4673d46b224428d197c5935e69", - "sha256:fe89295219b9c9e47780a0f1c75ca44211e706d1c598242249fe717af3385ec8" - ], - "markers": "python_version >= '3.8'", - "version": "==1.67.0" + "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04", + "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292", + "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955", + "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426", + "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65", + "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970", + "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e", + "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab", + "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953", + "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8", + "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085", + "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732", + "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f", + "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af", + "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78", + "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc", + "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98", + "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e", + "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f", + "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e", + "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3", + "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed", + "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38", + "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb", + "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5", + "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771", + "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc", + "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb", + "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8", + "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75", + "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f", + "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f", + "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb", + "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8", + "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8", + "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311", + "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335", + "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62", + "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af", + "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b", + "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce", + "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1", + "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f", + "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0", + "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e", + "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121", + "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744", + "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa", + "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e", + "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d", + "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0", + "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d", + "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46", + "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96", + "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba" + ], + "markers": "python_version >= '3.8'", + "version": "==1.67.1" }, "gunicorn": { "hashes": [ @@ -1286,11 +1294,11 @@ }, "phonenumbers": { "hashes": [ - "sha256:53c5e7c6d431cafe4efdd44956078404ae9bc8b0eacc47be3105d3ccc88aaffa", - "sha256:5d3c0142ef7055ca5551884352e3b6b93bfe002a0bc95b8eaba39b0e2184541b" + "sha256:5c51939acefa390eb74119750afb10a85d3c628dc83fd62c52d6f532fcf5d205", + "sha256:62d8df9b0f3c3c41571c6b396f044ddd999d61631534001b8be7fdf7ba1b18f3" ], "index": "pypi", - "version": "==8.13.47" + "version": "==8.13.48" }, "pickleshare": { "hashes": [ @@ -1475,11 +1483,11 @@ }, "pyphen": { "hashes": [ - "sha256:2c006b3ddf072c9571ab97606d9ab3c26a92eaced4c0d59fd1d26988f308f413", - "sha256:b4a4c6d7d5654b698b5fc68123148bb799b3debe0175d1d5dc3edfe93066fc4c" + "sha256:1d13acd1ce37a384d7612954ae6c7801bb4c5316da0e2b937b2127ba702a3da4", + "sha256:dad0b2e4aa80f6d70bf06711b2da36c47a756b933c1d0c4cbbab40f643a5958c" ], - "markers": "python_version >= '3.8'", - "version": "==0.16.0" + "markers": "python_version >= '3.9'", + "version": "==0.17.0" }, "python-dateutil": { "hashes": [ @@ -1726,11 +1734,11 @@ }, "setuptools": { "hashes": [ - "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec", - "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8" + "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd", + "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686" ], "markers": "python_version >= '3.8'", - "version": "==75.2.0" + "version": "==75.3.0" }, "six": { "hashes": [ @@ -1759,11 +1767,11 @@ }, "tinycss2": { "hashes": [ - "sha256:152f9acabd296a8375fbca5b84c961ff95971fcfc32e79550c8df8e29118c54d", - "sha256:54a8dbdffb334d536851be0226030e9505965bb2f30f21a4a82c55fb2a80fae7" + "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", + "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289" ], "markers": "python_version >= '3.8'", - "version": "==1.3.0" + "version": "==1.4.0" }, "tomli": { "hashes": [ @@ -1831,11 +1839,11 @@ }, "virtualenv": { "hashes": [ - "sha256:2ca56a68ed615b8fe4326d11a0dca5dfbe8fd68510fb6c6349163bed3c15f2b2", - "sha256:44a72c29cceb0ee08f300b314848c86e57bf8d1f13107a5e671fb9274138d655" + "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba", + "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4" ], "markers": "python_version >= '3.8'", - "version": "==20.27.0" + "version": "==20.27.1" }, "wcwidth": { "hashes": [ @@ -1980,46 +1988,46 @@ }, "zope.interface": { "hashes": [ - "sha256:07add15de0cc7e69917f7d286b64d54125c950aeb43efed7a5ea7172f000fbc1", - "sha256:0ac20581fc6cd7c754f6dff0ae06fedb060fa0e9ea6309d8be8b2701d9ea51c4", - "sha256:124149e2d42067b9c6597f4dafdc7a0983d0163868f897b7bb5dc850b14f9a87", - "sha256:27cfb5205d68b12682b6e55ab8424662d96e8ead19550aad0796b08dd2c9a45e", - "sha256:2a29ac607e970b5576547f0e3589ec156e04de17af42839eedcf478450687317", - "sha256:2b6a4924f5bad9fe21d99f66a07da60d75696a136162427951ec3cb223a5570d", - "sha256:2bd9e9f366a5df08ebbdc159f8224904c1c5ce63893984abb76954e6fbe4381a", - "sha256:3bcff5c09d0215f42ba64b49205a278e44413d9bf9fa688fd9e42bfe472b5f4f", - "sha256:3f005869a1a05e368965adb2075f97f8ee9a26c61898a9e52a9764d93774f237", - "sha256:4a00ead2e24c76436e1b457a5132d87f83858330f6c923640b7ef82d668525d1", - "sha256:4af4a12b459a273b0b34679a5c3dc5e34c1847c3dd14a628aa0668e19e638ea2", - "sha256:5501e772aff595e3c54266bc1bfc5858e8f38974ce413a8f1044aae0f32a83a3", - "sha256:5e28ea0bc4b084fc93a483877653a033062435317082cdc6388dec3438309faf", - "sha256:5e956b1fd7f3448dd5e00f273072e73e50dfafcb35e4227e6d5af208075593c9", - "sha256:5fcf379b875c610b5a41bc8a891841533f98de0520287d7f85e25386cd10d3e9", - "sha256:6159e767d224d8f18deff634a1d3722e68d27488c357f62ebeb5f3e2f5288b1f", - "sha256:661d5df403cd3c5b8699ac480fa7f58047a3253b029db690efa0c3cf209993ef", - "sha256:711eebc77f2092c6a8b304bad0b81a6ce3cf5490b25574e7309fbc07d881e3af", - "sha256:80a3c00b35f6170be5454b45abe2719ea65919a2f09e8a6e7b1362312a872cd3", - "sha256:848b6fa92d7c8143646e64124ed46818a0049a24ecc517958c520081fd147685", - "sha256:91b6c30689cfd87c8f264acb2fc16ad6b3c72caba2aec1bf189314cf1a84ca33", - "sha256:9733a9a0f94ef53d7aa64661811b20875b5bc6039034c6e42fb9732170130573", - "sha256:9940d5bc441f887c5f375ec62bcf7e7e495a2d5b1da97de1184a88fb567f06af", - "sha256:9e3e48f3dea21c147e1b10c132016cb79af1159facca9736d231694ef5a740a8", - "sha256:a14c9decf0eb61e0892631271d500c1e306c7b6901c998c7035e194d9150fdd1", - "sha256:a735f82d2e3ed47ca01a20dfc4c779b966b16352650a8036ab3955aad151ed8a", - "sha256:a99240b1d02dc469f6afbe7da1bf617645e60290c272968f4e53feec18d7dce8", - "sha256:b7b25db127db3e6b597c5f74af60309c4ad65acd826f89609662f0dc33a54728", - "sha256:b936d61dbe29572fd2cfe13e30b925e5383bed1aba867692670f5a2a2eb7b4e9", - "sha256:bec001798ab62c3fc5447162bf48496ae9fba02edc295a9e10a0b0c639a6452e", - "sha256:cc8a318162123eddbdf22fcc7b751288ce52e4ad096d3766ff1799244352449d", - "sha256:d0a45b5af9f72c805ee668d1479480ca85169312211bed6ed18c343e39307d5f", - "sha256:e53c291debef523b09e1fe3dffe5f35dde164f1c603d77f770b88a1da34b7ed6", - "sha256:ec1ef1fdb6f014d5886b97e52b16d0f852364f447d2ab0f0c6027765777b6667", - "sha256:ec59fe53db7d32abb96c6d4efeed84aab4a7c38c62d7a901a9b20c09dd936e7a", - "sha256:f245d039f72e6f802902375755846f5de1ee1e14c3e8736c078565599bcab621", - "sha256:ff115ef91c0eeac69cd92daeba36a9d8e14daee445b504eeea2b1c0b55821984" - ], - "markers": "python_version >= '3.8'", - "version": "==7.1.0" + "sha256:0de23bcb93401994ea00bc5c677ef06d420340ac0a4e9c10d80e047b9ce5af3f", + "sha256:179ad46ece518c9084cb272e4a69d266b659f7f8f48e51706746c2d8a426433e", + "sha256:190eeec67e023d5aac54d183fa145db0b898664234234ac54643a441da434616", + "sha256:1a2ed0852c25950cf430067f058f8d98df6288502ac313861d9803fe7691a9b3", + "sha256:1c4e1b4c06d9abd1037c088dae1566c85f344a3e6ae4350744c3f7f7259d9c67", + "sha256:1d0e23c6b746eb8ce04573cc47bcac60961ac138885d207bd6f57e27a1431ae8", + "sha256:2317e1d4dba68203a5227ea3057f9078ec9376275f9700086b8f0ffc0b358e1b", + "sha256:2d553e02b68c0ea5a226855f02edbc9eefd99f6a8886fa9f9bdf999d77f46585", + "sha256:3603ef82a9920bd0bfb505423cb7e937498ad971ad5a6141841e8f76d2fd5446", + "sha256:3defc925c4b22ac1272d544a49c6ba04c3eefcce3200319ee1be03d9270306dd", + "sha256:3e59f175e868f856a77c0a77ba001385c377df2104fdbda6b9f99456a01e102a", + "sha256:4284d664ef0ff7b709836d4de7b13d80873dc5faeffc073abdb280058bfac5e3", + "sha256:55c373becbd36a44d0c9be1d5271422fdaa8562d158fb44b4192297b3c67096c", + "sha256:5836b8fb044c6e75ba34dfaabc602493019eadfa0faf6ff25f4c4c356a71a853", + "sha256:5cdb7e7e5524b76d3ec037c1d81a9e2c7457b240fd4cb0a2476b65c3a5a6c81f", + "sha256:6650bd56ef350d37c8baccfd3ee8a0483ed6f8666e641e4b9ae1a1827b79f9e5", + "sha256:7395f13533318f150ee72adb55b29284b16e73b6d5f02ab21f173b3e83f242b8", + "sha256:7720322763aceb5e0a7cadcc38c67b839efe599f0887cbf6c003c55b1458c501", + "sha256:7cd5e3d910ac87652a09f6e5db8e41bc3b49cf08ddd2d73d30afc644801492cd", + "sha256:81744a7e61b598ebcf4722ac56a7a4f50502432b5b4dc7eb29075a89cf82d029", + "sha256:84e87eba6b77a3af187bae82d8de1a7c208c2a04ec9f6bd444fd091b811ad92e", + "sha256:8d0fe45be57b5219aa4b96e846631c04615d5ef068146de5a02ccd15c185321f", + "sha256:9595e478047ce752b35cfa221d7601a5283ccdaab40422e0dc1d4a334c70f580", + "sha256:99c14f0727c978639139e6cad7a60e82b7720922678d75aacb90cf4ef74a068c", + "sha256:9b1eed7670d564f1025d7cda89f99f216c30210e42e95de466135be0b4a499d9", + "sha256:9fad9bd5502221ab179f13ea251cb30eef7cf65023156967f86673aff54b53a0", + "sha256:ad339509dcfbbc99bf8e147db6686249c4032f26586699ec4c82f6e5909c9fe2", + "sha256:bcbeb44fc16e0078b3b68a95e43f821ae34dcbf976dde6985141838a5f23dd3d", + "sha256:c8e7b05dc6315a193cceaec071cc3cf1c180cea28808ccded0b1283f1c38ba73", + "sha256:ca95594d936ee349620900be5b46c0122a1ff6ce42d7d5cb2cf09dc84071ef16", + "sha256:d029fac6a80edae80f79c37e5e3abfa92968fe921886139b3ee470a1b177321a", + "sha256:d17e7fc814eaab93409b80819fd6d30342844345c27f3bc3c4b43c2425a8d267", + "sha256:d6821ef9870f32154da873fcde439274f99814ea452dd16b99fa0b66345c4b6b", + "sha256:e6503534b52bb1720ace9366ee30838a58a3413d3e197512f3338c8f34b5d89d", + "sha256:ed1df8cc01dd1e3970666a7370b8bfc7457371c58ba88c57bd5bca17ab198053", + "sha256:f1d52d052355e0c5c89e0630dd2ff7c0b823fd5f56286a663e92444761b35e25", + "sha256:f85b290e5b8b11814efb0d004d8ce6c9a483c35c462e8d9bf84abb93e79fa770" + ], + "markers": "python_version >= '3.8'", + "version": "==7.1.1" } }, "develop": { @@ -2612,11 +2620,11 @@ }, "mako": { "hashes": [ - "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a", - "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc" + "sha256:9ec3a1583713479fae654f83ed9fa8c9a4c16b7bb0daba0e6bbebff50c0d983d", + "sha256:a91198468092a2f1a0de86ca92690fb0cfc43ca90ee17e15d93662b4c04b241a" ], "markers": "python_version >= '3.8'", - "version": "==1.3.5" + "version": "==1.3.6" }, "markupsafe": { "hashes": [ @@ -3204,11 +3212,11 @@ }, "setuptools": { "hashes": [ - "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec", - "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8" + "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd", + "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686" ], "markers": "python_version >= '3.8'", - "version": "==75.2.0" + "version": "==75.3.0" }, "six": { "hashes": [ @@ -3345,11 +3353,11 @@ }, "werkzeug": { "hashes": [ - "sha256:02c9eb92b7d6c06f31a782811505d2157837cea66aaede3e217c7c27c039476c", - "sha256:34f2371506b250df4d4f84bfe7b0921e4762525762bbd936614909fe25cd7306" + "sha256:1bc0c2310d2fbb07b1dd1105eba2f7af72f322e1e455f2f93c993bee8c8a5f17", + "sha256:a8dd59d4de28ca70471a34cba79bed5f7ef2e036a76b3ab0835474246eb41f8d" ], "markers": "python_version >= '3.8'", - "version": "==3.0.4" + "version": "==3.0.6" }, "wrapt": { "hashes": [ From 684aac73413d9bdec1fa678129f46365a903d699 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 20 Aug 2024 14:18:10 +0100 Subject: [PATCH 02/89] Add endpoint for licence statuses --- api/data_workspace/urls.py | 2 + api/data_workspace/v2/__init__.py | 0 api/data_workspace/v2/serializers.py | 5 ++ api/data_workspace/v2/tests/__init__.py | 0 api/data_workspace/v2/tests/bdd/__init__.py | 0 .../bdd/scenarios/licence_statuses.feature | 13 ++++ .../v2/tests/bdd/test_licence_statuses.py | 65 +++++++++++++++++++ api/data_workspace/v2/urls.py | 11 ++++ api/data_workspace/v2/views.py | 20 ++++++ api/licences/enums.py | 4 ++ 10 files changed, 120 insertions(+) create mode 100644 api/data_workspace/v2/__init__.py create mode 100644 api/data_workspace/v2/serializers.py create mode 100644 api/data_workspace/v2/tests/__init__.py create mode 100644 api/data_workspace/v2/tests/bdd/__init__.py create mode 100644 api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature create mode 100644 api/data_workspace/v2/tests/bdd/test_licence_statuses.py create mode 100644 api/data_workspace/v2/urls.py create mode 100644 api/data_workspace/v2/views.py diff --git a/api/data_workspace/urls.py b/api/data_workspace/urls.py index 58bbcadd8..ac0046971 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 000000000..e69de29bb diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py new file mode 100644 index 000000000..db93b0882 --- /dev/null +++ b/api/data_workspace/v2/serializers.py @@ -0,0 +1,5 @@ +from rest_framework import serializers + + +class LicenceStatusSerializer(serializers.Serializer): + name = serializers.CharField(source="*") diff --git a/api/data_workspace/v2/tests/__init__.py b/api/data_workspace/v2/tests/__init__.py new file mode 100644 index 000000000..e69de29bb 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 000000000..e69de29bb diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature b/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature new file mode 100644 index 000000000..182c80135 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature @@ -0,0 +1,13 @@ +Scenario: Licence statuses + Given the following licence statuses: + | name | + | cancelled | + | draft | + | exhausted | + | expired | + | issued | + | reinstated | + | revoked | + | surrendered | + | suspended | + Then there are no other licence statuses diff --git a/api/data_workspace/v2/tests/bdd/test_licence_statuses.py b/api/data_workspace/v2/tests/bdd/test_licence_statuses.py new file mode 100644 index 000000000..b6d593874 --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/test_licence_statuses.py @@ -0,0 +1,65 @@ +import pytest + +from django.urls import reverse +from pytest_bdd import ( + given, + parsers, + then, + scenarios, +) +from rest_framework import status + + +scenarios("./scenarios/licence_statuses.feature") + + +def create_table(data_table): + lines = data_table.strip().split("\n") + + keys = [key.strip() for key in lines[0].split("|") if key] + + parsed_data_table = [] + for line in lines[1:]: + values = [value.strip() for value in line.split("|") if value] + entry = dict(zip(keys, values)) + parsed_data_table.append(entry) + + return parsed_data_table + + +@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 + + +@pytest.fixture() +def licence_status_list_url(): + return reverse("data_workspace:v2:dw-licence-statuses-list") + + +@given( + parsers.parse("the following licence statuses:\n{licence_statuses}"), target_fixture="found_licence_status_names" +) +def the_following_licence_statuses(licence_statuses): + licence_statuses = create_table(licence_statuses) + return [licence_status["name"] for licence_status in licence_statuses] + + +@then("there are no other licence statuses") +def no_other_licence_statuses(found_licence_status_names, unpage_data, licence_status_list_url): + licence_status_data = unpage_data(licence_status_list_url) + assert sorted(found_licence_status_names) == sorted( + [licence_status["name"] for licence_status in licence_status_data] + ) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py new file mode 100644 index 000000000..052933284 --- /dev/null +++ b/api/data_workspace/v2/urls.py @@ -0,0 +1,11 @@ +from rest_framework.routers import DefaultRouter + +from api.data_workspace.v2 import views + + +router_v2 = DefaultRouter() +router_v2.register( + "licence-statuses", + views.LicenceStatusesListView, + basename="dw-licence-statuses", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py new file mode 100644 index 000000000..13a738612 --- /dev/null +++ b/api/data_workspace/v2/views.py @@ -0,0 +1,20 @@ +from rest_framework import viewsets +from rest_framework.generics import ListAPIView +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.settings import api_settings + +from rest_framework_csv.renderers import PaginatedCSVRenderer + +from api.core.authentication import DataWorkspaceOnlyAuthentication +from api.data_workspace.v2.serializers import LicenceStatusSerializer +from api.licences.enums import LicenceStatus + + +class LicenceStatusesListView(viewsets.GenericViewSet, ListAPIView): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = LimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = LicenceStatusSerializer + + def get_queryset(self): + return LicenceStatus.all() diff --git a/api/licences/enums.py b/api/licences/enums.py index 3aa338ea7..d090cd7aa 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()] + hmrc_integration_action_to_licence_status = { HMRCIntegrationActionEnum.SURRENDER: LicenceStatus.SURRENDERED, From 6cb853276c51e310c7b2d0029527eb377f047e90 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 15 Oct 2024 15:29:22 +0100 Subject: [PATCH 03/89] Add endpoint for retrieving SIEL licences list --- api/data_workspace/v2/serializers.py | 13 ++++++++ .../v2/tests/bdd/licences/conftest.py | 31 +++++++++++++++++++ .../{ => licences}/test_licence_statuses.py | 24 +++++++++++++- .../bdd/scenarios/licence_statuses.feature | 5 +++ api/data_workspace/v2/urls.py | 6 ++++ api/data_workspace/v2/views.py | 13 +++++++- 6 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 api/data_workspace/v2/tests/bdd/licences/conftest.py rename api/data_workspace/v2/tests/bdd/{ => licences}/test_licence_statuses.py (70%) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index db93b0882..4ef762378 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,5 +1,18 @@ from rest_framework import serializers +from api.licences.models import Licence + class LicenceStatusSerializer(serializers.Serializer): name = serializers.CharField(source="*") + + +class SIELLicenceSerializer(serializers.ModelSerializer): + status = serializers.SerializerMethodField() + + class Meta: + model = Licence + fields = ( + "id", + "reference_code", + ) 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 000000000..a6f4a208d --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/licences/conftest.py @@ -0,0 +1,31 @@ +import pytest + +from api.applications.tests.factories import GoodOnApplicationFactory, StandardApplicationFactory +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_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 diff --git a/api/data_workspace/v2/tests/bdd/test_licence_statuses.py b/api/data_workspace/v2/tests/bdd/licences/test_licence_statuses.py similarity index 70% rename from api/data_workspace/v2/tests/bdd/test_licence_statuses.py rename to api/data_workspace/v2/tests/bdd/licences/test_licence_statuses.py index b6d593874..7ea1d8675 100644 --- a/api/data_workspace/v2/tests/bdd/test_licence_statuses.py +++ b/api/data_workspace/v2/tests/bdd/licences/test_licence_statuses.py @@ -10,7 +10,7 @@ from rest_framework import status -scenarios("./scenarios/licence_statuses.feature") +scenarios("../scenarios/licence_statuses.feature") def create_table(data_table): @@ -49,6 +49,11 @@ def licence_status_list_url(): return reverse("data_workspace:v2:dw-licence-statuses-list") +@pytest.fixture() +def licences_list_url(): + return reverse("data_workspace:v2:dw-siel-licences-list") + + @given( parsers.parse("the following licence statuses:\n{licence_statuses}"), target_fixture="found_licence_status_names" ) @@ -63,3 +68,20 @@ def no_other_licence_statuses(found_licence_status_names, unpage_data, licence_s assert sorted(found_licence_status_names) == sorted( [licence_status["name"] for licence_status in licence_status_data] ) + + +@given("a standard licence is issued", target_fixture="issued_licence") +def standard_licence_issued(standard_licence): + assert standard_licence.status == "issued" + return standard_licence + + +@then("the issued licence is included in the extract") +def licence_included_in_extract(issued_licence, unpage_data, licences_list_url): + licences = unpage_data(licences_list_url) + + licence = [item for item in licences if item["id"] == str(issued_licence.id)] + assert len(licence) == 1 + licence = licence[0] + + assert licence["status"] == "issued" diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature b/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature index 182c80135..e37352c66 100644 --- a/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature +++ b/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature @@ -11,3 +11,8 @@ Scenario: Licence statuses | surrendered | | suspended | Then there are no other licence statuses + + +Scenario: Issued licence is included in the extract + Given a standard licence is issued + Then the issued licence is included in the extract diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 052933284..076aa4f65 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -9,3 +9,9 @@ views.LicenceStatusesListView, basename="dw-licence-statuses", ) + +router_v2.register( + "siel-licences", + views.SIELLicencesListView, + basename="dw-siel-licences", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 13a738612..16d92956d 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -6,8 +6,9 @@ from rest_framework_csv.renderers import PaginatedCSVRenderer from api.core.authentication import DataWorkspaceOnlyAuthentication -from api.data_workspace.v2.serializers import LicenceStatusSerializer +from api.data_workspace.v2.serializers import LicenceStatusSerializer, SIELLicenceSerializer from api.licences.enums import LicenceStatus +from api.licences.models import Licence class LicenceStatusesListView(viewsets.GenericViewSet, ListAPIView): @@ -18,3 +19,13 @@ class LicenceStatusesListView(viewsets.GenericViewSet, ListAPIView): def get_queryset(self): return LicenceStatus.all() + + +class SIELLicencesListView(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = LimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = SIELLicenceSerializer + + def get_queryset(self): + return Licence.objects.exclude(status=LicenceStatus.DRAFT) From f86d6f3bce4246a5f5e0d7f9542f44b3aed4580d Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 15 Oct 2024 17:12:50 +0100 Subject: [PATCH 04/89] Address review comments - remove licence statuses endpoint for now - make tests work --- api/data_workspace/v2/serializers.py | 7 +- api/data_workspace/v2/tests/bdd/conftest.py | 28 ++++++ .../v2/tests/bdd/licences/conftest.py | 20 +++++ .../bdd/licences/test_licence_statuses.py | 87 ------------------- .../v2/tests/bdd/licences/test_licences.py | 44 ++++++++++ .../bdd/scenarios/licence_statuses.feature | 18 ---- .../v2/tests/bdd/scenarios/licences.feature | 10 +++ api/data_workspace/v2/urls.py | 9 +- api/data_workspace/v2/views.py | 17 +--- 9 files changed, 108 insertions(+), 132 deletions(-) create mode 100644 api/data_workspace/v2/tests/bdd/conftest.py delete mode 100644 api/data_workspace/v2/tests/bdd/licences/test_licence_statuses.py create mode 100644 api/data_workspace/v2/tests/bdd/licences/test_licences.py delete mode 100644 api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature create mode 100644 api/data_workspace/v2/tests/bdd/scenarios/licences.feature diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 4ef762378..a2ad495b2 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -3,12 +3,7 @@ from api.licences.models import Licence -class LicenceStatusSerializer(serializers.Serializer): - name = serializers.CharField(source="*") - - -class SIELLicenceSerializer(serializers.ModelSerializer): - status = serializers.SerializerMethodField() +class LicenceSerializer(serializers.ModelSerializer): class Meta: model = Licence 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 000000000..dcc6918eb --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -0,0 +1,28 @@ +import pytest + +from rest_framework import status + +from api.users.enums import SystemUser +from api.users.tests.factories import BaseUserFactory + + +@pytest.fixture(autouse=True) +def system_user(db): + return BaseUserFactory(id=SystemUser.id) + + +@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/licences/conftest.py b/api/data_workspace/v2/tests/bdd/licences/conftest.py index a6f4a208d..f0a00c72c 100644 --- a/api/data_workspace/v2/tests/bdd/licences/conftest.py +++ b/api/data_workspace/v2/tests/bdd/licences/conftest.py @@ -9,6 +9,26 @@ 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( diff --git a/api/data_workspace/v2/tests/bdd/licences/test_licence_statuses.py b/api/data_workspace/v2/tests/bdd/licences/test_licence_statuses.py deleted file mode 100644 index 7ea1d8675..000000000 --- a/api/data_workspace/v2/tests/bdd/licences/test_licence_statuses.py +++ /dev/null @@ -1,87 +0,0 @@ -import pytest - -from django.urls import reverse -from pytest_bdd import ( - given, - parsers, - then, - scenarios, -) -from rest_framework import status - - -scenarios("../scenarios/licence_statuses.feature") - - -def create_table(data_table): - lines = data_table.strip().split("\n") - - keys = [key.strip() for key in lines[0].split("|") if key] - - parsed_data_table = [] - for line in lines[1:]: - values = [value.strip() for value in line.split("|") if value] - entry = dict(zip(keys, values)) - parsed_data_table.append(entry) - - return parsed_data_table - - -@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 - - -@pytest.fixture() -def licence_status_list_url(): - return reverse("data_workspace:v2:dw-licence-statuses-list") - - -@pytest.fixture() -def licences_list_url(): - return reverse("data_workspace:v2:dw-siel-licences-list") - - -@given( - parsers.parse("the following licence statuses:\n{licence_statuses}"), target_fixture="found_licence_status_names" -) -def the_following_licence_statuses(licence_statuses): - licence_statuses = create_table(licence_statuses) - return [licence_status["name"] for licence_status in licence_statuses] - - -@then("there are no other licence statuses") -def no_other_licence_statuses(found_licence_status_names, unpage_data, licence_status_list_url): - licence_status_data = unpage_data(licence_status_list_url) - assert sorted(found_licence_status_names) == sorted( - [licence_status["name"] for licence_status in licence_status_data] - ) - - -@given("a standard licence is issued", target_fixture="issued_licence") -def standard_licence_issued(standard_licence): - assert standard_licence.status == "issued" - return standard_licence - - -@then("the issued licence is included in the extract") -def licence_included_in_extract(issued_licence, unpage_data, licences_list_url): - licences = unpage_data(licences_list_url) - - licence = [item for item in licences if item["id"] == str(issued_licence.id)] - assert len(licence) == 1 - licence = licence[0] - - assert licence["status"] == "issued" diff --git a/api/data_workspace/v2/tests/bdd/licences/test_licences.py b/api/data_workspace/v2/tests/bdd/licences/test_licences.py new file mode 100644 index 000000000..624c0d15a --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/licences/test_licences.py @@ -0,0 +1,44 @@ +import pytest + +from django.urls import reverse +from pytest_bdd import ( + given, + then, + scenarios, +) + +from api.licences.enums import LicenceStatus + + +scenarios("../scenarios/licences.feature") + + +@pytest.fixture() +def licences_list_url(): + return reverse("data_workspace:v2:dw-licences-list") + + +@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, licences_list_url): + licences = unpage_data(licences_list_url) + + assert draft_licence.reference_code not in [item["reference_code"] for item in licences] + + +@given("a standard licence is issued", target_fixture="issued_licence") +def standard_licence_issued(standard_licence): + assert standard_licence.status == LicenceStatus.ISSUED + return standard_licence + + +@then("the issued licence is included in the extract") +def licence_included_in_extract(issued_licence, unpage_data, licences_list_url): + licences = unpage_data(licences_list_url) + + assert issued_licence.reference_code in [item["reference_code"] for item in licences] diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature b/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature deleted file mode 100644 index e37352c66..000000000 --- a/api/data_workspace/v2/tests/bdd/scenarios/licence_statuses.feature +++ /dev/null @@ -1,18 +0,0 @@ -Scenario: Licence statuses - Given the following licence statuses: - | name | - | cancelled | - | draft | - | exhausted | - | expired | - | issued | - | reinstated | - | revoked | - | surrendered | - | suspended | - Then there are no other licence statuses - - -Scenario: Issued licence is included in the extract - Given a standard licence is issued - Then the issued licence is included in the extract diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licences.feature b/api/data_workspace/v2/tests/bdd/scenarios/licences.feature new file mode 100644 index 000000000..cafa9c05f --- /dev/null +++ b/api/data_workspace/v2/tests/bdd/scenarios/licences.feature @@ -0,0 +1,10 @@ +@db +Feature: Licences + +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: Issued licence is included in the extract + Given a standard licence is issued + Then the issued licence is included in the extract diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 076aa4f65..9f36efbc4 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -4,14 +4,9 @@ router_v2 = DefaultRouter() -router_v2.register( - "licence-statuses", - views.LicenceStatusesListView, - basename="dw-licence-statuses", -) router_v2.register( "siel-licences", - views.SIELLicencesListView, - basename="dw-siel-licences", + views.LicencesListView, + basename="dw-licences", ) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 16d92956d..28e85d4fb 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -1,31 +1,20 @@ from rest_framework import viewsets -from rest_framework.generics import ListAPIView from rest_framework.pagination import LimitOffsetPagination from rest_framework.settings import api_settings from rest_framework_csv.renderers import PaginatedCSVRenderer from api.core.authentication import DataWorkspaceOnlyAuthentication -from api.data_workspace.v2.serializers import LicenceStatusSerializer, SIELLicenceSerializer +from api.data_workspace.v2.serializers import LicenceSerializer from api.licences.enums import LicenceStatus from api.licences.models import Licence -class LicenceStatusesListView(viewsets.GenericViewSet, ListAPIView): +class LicencesListView(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = LimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) - serializer_class = LicenceStatusSerializer - - def get_queryset(self): - return LicenceStatus.all() - - -class SIELLicencesListView(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = LimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) - serializer_class = SIELLicenceSerializer + serializer_class = LicenceSerializer def get_queryset(self): return Licence.objects.exclude(status=LicenceStatus.DRAFT) From d90ca371fe111ddc0eaf78ef1628ba8934c081a0 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Wed, 16 Oct 2024 17:15:12 +0100 Subject: [PATCH 05/89] Update url name and fix fixture --- api/data_workspace/v2/tests/bdd/conftest.py | 6 +++++- api/data_workspace/v2/urls.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/tests/bdd/conftest.py b/api/data_workspace/v2/tests/bdd/conftest.py index dcc6918eb..5ee9cb8c1 100644 --- a/api/data_workspace/v2/tests/bdd/conftest.py +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -3,12 +3,16 @@ from rest_framework import status from api.users.enums import SystemUser +from api.users.models import BaseUser from api.users.tests.factories import BaseUserFactory @pytest.fixture(autouse=True) def system_user(db): - return BaseUserFactory(id=SystemUser.id) + if BaseUser.objects.filter(id=SystemUser.id).exists(): + return BaseUser.objects.get(id=SystemUser.id) + else: + return BaseUserFactory(id=SystemUser.id) @pytest.fixture() diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 9f36efbc4..11d451351 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -6,7 +6,7 @@ router_v2 = DefaultRouter() router_v2.register( - "siel-licences", + "licences", views.LicencesListView, basename="dw-licences", ) From 545f1cdc898face2db1f53669e95fbc5652d6b34 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 18 Oct 2024 11:21:51 +0100 Subject: [PATCH 06/89] Add licence issue date to licences list Licence document generation date is taken for this purpose. --- api/data_workspace/v2/serializers.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index a2ad495b2..82f2bc28f 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,13 +1,40 @@ from rest_framework import serializers +from api.cases.generated_documents.models import GeneratedCaseDocument +from api.licences.enums import LicenceStatus from api.licences.models import Licence +SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" + class LicenceSerializer(serializers.ModelSerializer): + status = serializers.SerializerMethodField() + issued_date = serializers.SerializerMethodField() class Meta: model = Licence fields = ( "id", "reference_code", + "status", + "issued_date", + ) + + def get_status(self, licence): + return LicenceStatus.ISSUED + + def get_issued_date(self, licence): + licence_document = GeneratedCaseDocument.objects.filter( + licence=licence, + template_id=SIEL_TEMPLATE_ID, + safe=True, + visible_to_exporter=True, ) + + if not licence_document.exists(): + return "Invalid licence" + + if licence_document.count() > 1: + return "Multiple licence documents" + + return licence_document.first().updated_at From e73df0fc3ce6a761256f91bd903b148e5cd8fa8c Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 18 Oct 2024 11:43:57 +0100 Subject: [PATCH 07/89] Update Licences list view to excludes licences with no goods When all products on the application are NLR then the current code allowed creation of a licence due a bug. This is not fixed yet so exclude these cases as it can skew licence data. --- api/data_workspace/v2/serializers.py | 2 +- api/data_workspace/v2/views.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 82f2bc28f..f486a3481 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -32,7 +32,7 @@ def get_issued_date(self, licence): ) if not licence_document.exists(): - return "Invalid licence" + return f"Invalid licence ({licence.status})" if licence_document.count() > 1: return "Multiple licence documents" diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 28e85d4fb..6dab95a33 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -1,3 +1,4 @@ +from django.db.models import Count from rest_framework import viewsets from rest_framework.pagination import LimitOffsetPagination from rest_framework.settings import api_settings @@ -17,4 +18,13 @@ class LicencesListView(viewsets.ReadOnlyModelViewSet): serializer_class = LicenceSerializer def get_queryset(self): - return Licence.objects.exclude(status=LicenceStatus.DRAFT) + # When an application with all goods as NLR is finalised then the current code + # creates a licence however the goods on this licence will be empty. This + # will skew licence data hence exclude them + return ( + Licence.objects.prefetch_related("goods") + .annotate(num_licensed_goods=Count("goods")) + .exclude(status=LicenceStatus.DRAFT) + .exclude(num_licensed_goods=0) + .order_by("-reference_code") + ) From 95963ba82f550bedaa1b0e53388ede94a2ad9e23 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 18 Oct 2024 11:49:56 +0100 Subject: [PATCH 08/89] Only consider licences for finalised Cases In some case a licence is issued but subsequently application is marked as withdrawn. Licence also should've been cancelled in this case but we are not handling this atm so filter these cases. --- api/data_workspace/v2/views.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 6dab95a33..0c9a13ef8 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -9,6 +9,7 @@ from api.data_workspace.v2.serializers import LicenceSerializer from api.licences.enums import LicenceStatus from api.licences.models import Licence +from api.staticdata.statuses.enums import CaseStatusEnum class LicencesListView(viewsets.ReadOnlyModelViewSet): @@ -26,5 +27,11 @@ def get_queryset(self): .annotate(num_licensed_goods=Count("goods")) .exclude(status=LicenceStatus.DRAFT) .exclude(num_licensed_goods=0) + .filter( + case__status__status__in=[ + CaseStatusEnum.FINALISED, + CaseStatusEnum.SUPERSEDED_BY_EXPORTER_EDIT, + ], + ) .order_by("-reference_code") ) From 0ae59252078b323099a70ff19ac9438f9fe7e0ce Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 18 Oct 2024 12:03:13 +0100 Subject: [PATCH 09/89] Ignore cancelled licences As there can be multiple documents in these cases --- api/data_workspace/v2/serializers.py | 3 --- api/data_workspace/v2/views.py | 7 ++++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index f486a3481..7c4fbc5ab 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -31,9 +31,6 @@ def get_issued_date(self, licence): visible_to_exporter=True, ) - if not licence_document.exists(): - return f"Invalid licence ({licence.status})" - if licence_document.count() > 1: return "Multiple licence documents" diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 0c9a13ef8..ec52bea8a 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -25,7 +25,12 @@ def get_queryset(self): return ( Licence.objects.prefetch_related("goods") .annotate(num_licensed_goods=Count("goods")) - .exclude(status=LicenceStatus.DRAFT) + .exclude( + status__in=[ + LicenceStatus.DRAFT, + LicenceStatus.CANCELLED, + ] + ) .exclude(num_licensed_goods=0) .filter( case__status__status__in=[ From 27868050e0f26a1f843be8abfc333485c9369ba7 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 18 Oct 2024 15:51:07 +0100 Subject: [PATCH 10/89] Rename status to decision as that is what it is --- api/data_workspace/v2/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 7c4fbc5ab..3b7450ca3 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -8,7 +8,7 @@ class LicenceSerializer(serializers.ModelSerializer): - status = serializers.SerializerMethodField() + decision = serializers.SerializerMethodField() issued_date = serializers.SerializerMethodField() class Meta: @@ -16,11 +16,11 @@ class Meta: fields = ( "id", "reference_code", - "status", + "decision", "issued_date", ) - def get_status(self, licence): + def get_decision(self, licence): return LicenceStatus.ISSUED def get_issued_date(self, licence): From 673b60c4d9935c7166972eb4a58a271c612f2956 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 21 Oct 2024 06:15:25 +0100 Subject: [PATCH 11/89] Add more common fixtures --- api/data_workspace/v2/tests/bdd/conftest.py | 34 +++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/api/data_workspace/v2/tests/bdd/conftest.py b/api/data_workspace/v2/tests/bdd/conftest.py index 5ee9cb8c1..9ef5c854d 100644 --- a/api/data_workspace/v2/tests/bdd/conftest.py +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -2,9 +2,11 @@ from rest_framework import status -from api.users.enums import SystemUser -from api.users.models import BaseUser -from api.users.tests.factories import BaseUserFactory +from api.core.constants import GovPermissions +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 @pytest.fixture(autouse=True) @@ -15,6 +17,32 @@ def system_user(db): return BaseUserFactory(id=SystemUser.id) +@pytest.fixture() +def gov_user(): + return GovUserFactory() + + +@pytest.fixture() +def gov_user_permissions(): + for permission in GovPermissions: + Permission.objects.get_or_create(id=permission.name, name=permission.name, type=UserType.INTERNAL) + + +@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 gov_headers(gov_user): + return {"HTTP_GOV_USER_TOKEN": user_to_token(gov_user.baseuser_ptr)} + + @pytest.fixture() def unpage_data(client): def _unpage_data(url): From 7927d70b01db005607c6b3adb1a13290755f5402 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 21 Oct 2024 13:22:36 +0100 Subject: [PATCH 12/89] Seed letter layouts and templates --- api/data_workspace/v2/tests/bdd/conftest.py | 31 +++++++++++++++++++ .../bdd/initial_data/letter_layouts.json | 17 ++++++++++ .../bdd/initial_data/letter_templates.json | 23 ++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 api/data_workspace/v2/tests/bdd/initial_data/letter_layouts.json create mode 100644 api/data_workspace/v2/tests/bdd/initial_data/letter_templates.json diff --git a/api/data_workspace/v2/tests/bdd/conftest.py b/api/data_workspace/v2/tests/bdd/conftest.py index 9ef5c854d..d085eef4d 100644 --- a/api/data_workspace/v2/tests/bdd/conftest.py +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -1,14 +1,45 @@ +import csv +import json import pytest from rest_framework import status +from api.cases.enums import CaseTypeEnum, CaseTypeSubTypeEnum +from api.cases.models import CaseType from api.core.constants import GovPermissions +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): + 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(autouse=True) def system_user(db): if BaseUser.objects.filter(id=SystemUser.id).exists(): 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 000000000..357579e08 --- /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 000000000..00cb3c626 --- /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 From 4553cb08937f7b780236c6380b588d59c3b331a6 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 21 Oct 2024 15:07:06 +0100 Subject: [PATCH 13/89] Update licence test to include steps for generating documents --- .../v2/tests/bdd/licences/conftest.py | 14 ++++ .../v2/tests/bdd/licences/test_licences.py | 84 ++++++++++++++++++- .../v2/tests/bdd/scenarios/licences.feature | 11 ++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/api/data_workspace/v2/tests/bdd/licences/conftest.py b/api/data_workspace/v2/tests/bdd/licences/conftest.py index f0a00c72c..134485064 100644 --- a/api/data_workspace/v2/tests/bdd/licences/conftest.py +++ b/api/data_workspace/v2/tests/bdd/licences/conftest.py @@ -1,6 +1,7 @@ import pytest from api.applications.tests.factories import GoodOnApplicationFactory, StandardApplicationFactory +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 @@ -49,3 +50,16 @@ def standard_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 diff --git a/api/data_workspace/v2/tests/bdd/licences/test_licences.py b/api/data_workspace/v2/tests/bdd/licences/test_licences.py index 624c0d15a..c0860a5de 100644 --- a/api/data_workspace/v2/tests/bdd/licences/test_licences.py +++ b/api/data_workspace/v2/tests/bdd/licences/test_licences.py @@ -1,13 +1,18 @@ import pytest from django.urls import reverse +from django.utils import timezone from pytest_bdd import ( given, then, + when, scenarios, ) +from api.cases.enums import AdviceType from api.licences.enums import LicenceStatus +from api.licences.models import Licence +from api.staticdata.statuses.enums import CaseStatusEnum scenarios("../scenarios/licences.feature") @@ -31,14 +36,87 @@ def draft_licence_not_included_in_extract(draft_licence, unpage_data, licences_l assert draft_licence.reference_code not in [item["reference_code"] for item in licences] -@given("a standard licence is issued", target_fixture="issued_licence") -def standard_licence_issued(standard_licence): - assert standard_licence.status == LicenceStatus.ISSUED +@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, licences_list_url): + licences = unpage_data(licences_list_url) + + assert cancelled_licence.reference_code not in [item["reference_code"] for item in licences] + + @then("the issued licence is included in the extract") def licence_included_in_extract(issued_licence, unpage_data, licences_list_url): licences = unpage_data(licences_list_url) assert issued_licence.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 + + +@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 licence_for_case_is_approved(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)}, + ) + 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 licence_for_case_is_approved(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 + + return licence diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licences.feature b/api/data_workspace/v2/tests/bdd/scenarios/licences.feature index cafa9c05f..f9e5d8006 100644 --- a/api/data_workspace/v2/tests/bdd/scenarios/licences.feature +++ b/api/data_workspace/v2/tests/bdd/scenarios/licences.feature @@ -5,6 +5,13 @@ 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: Issued licence is included in the extract - Given a standard licence is issued +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: Licence document is generated 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 Then the issued licence is included in the extract From c0760eff65af32fa8616dd744f635c333762d022 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 22 Oct 2024 16:58:01 +0100 Subject: [PATCH 14/89] Use Case as base object the get licences list Use Case as base object and use it to filter on the licence documents. This avoids filtering of various case statuses, NLR products etc --- api/data_workspace/v2/serializers.py | 31 ++++++++++-------------- api/data_workspace/v2/views.py | 35 ++++++++++------------------ 2 files changed, 24 insertions(+), 42 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 3b7450ca3..c91bcc013 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,18 +1,16 @@ +from django.db.models import F from rest_framework import serializers from api.cases.generated_documents.models import GeneratedCaseDocument -from api.licences.enums import LicenceStatus -from api.licences.models import Licence +from api.cases.models import Case -SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" - -class LicenceSerializer(serializers.ModelSerializer): +class CaseLicenceSerializer(serializers.ModelSerializer): decision = serializers.SerializerMethodField() issued_date = serializers.SerializerMethodField() class Meta: - model = Licence + model = Case fields = ( "id", "reference_code", @@ -20,18 +18,13 @@ class Meta: "issued_date", ) - def get_decision(self, licence): - return LicenceStatus.ISSUED + def get_decision(self, case): + return "issued" - def get_issued_date(self, licence): - licence_document = GeneratedCaseDocument.objects.filter( - licence=licence, - template_id=SIEL_TEMPLATE_ID, - safe=True, - visible_to_exporter=True, + def get_issued_date(self, case): + return ( + case.licences.all() + .annotate(issued_at=F("generatedcasedocument__created_at")) + .earliest("issued_at") + .issued_at ) - - if licence_document.count() > 1: - return "Multiple licence documents" - - return licence_document.first().updated_at diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index ec52bea8a..d719d879a 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -1,42 +1,31 @@ -from django.db.models import Count 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 api.cases.models import Case +from api.cases.generated_documents.models import GeneratedCaseDocument from api.core.authentication import DataWorkspaceOnlyAuthentication -from api.data_workspace.v2.serializers import LicenceSerializer -from api.licences.enums import LicenceStatus -from api.licences.models import Licence -from api.staticdata.statuses.enums import CaseStatusEnum +from api.data_workspace.v2.serializers import CaseLicenceSerializer + +SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" class LicencesListView(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = LimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) - serializer_class = LicenceSerializer + serializer_class = CaseLicenceSerializer def get_queryset(self): - # When an application with all goods as NLR is finalised then the current code - # creates a licence however the goods on this licence will be empty. This - # will skew licence data hence exclude them + licence_documents = GeneratedCaseDocument.objects.filter( + template_id=SIEL_TEMPLATE_ID, visible_to_exporter=True, safe=True + ) return ( - Licence.objects.prefetch_related("goods") - .annotate(num_licensed_goods=Count("goods")) - .exclude( - status__in=[ - LicenceStatus.DRAFT, - LicenceStatus.CANCELLED, - ] - ) - .exclude(num_licensed_goods=0) - .filter( - case__status__status__in=[ - CaseStatusEnum.FINALISED, - CaseStatusEnum.SUPERSEDED_BY_EXPORTER_EDIT, - ], + Case.objects.filter( + licences__generatedcasedocument__in=licence_documents, ) + .distinct() .order_by("-reference_code") ) From 060f962c0f1544944c6f8fec8bdec2d7d5388306 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 22 Oct 2024 19:01:34 +0100 Subject: [PATCH 15/89] Update licence list API view to be called licence decision --- api/data_workspace/v2/serializers.py | 26 ++++++++++++------------- api/data_workspace/v2/urls.py | 6 +++--- api/data_workspace/v2/views.py | 29 +++++++++++++--------------- 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index c91bcc013..97e1634d2 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,13 +1,18 @@ +from enum import Enum + from django.db.models import F from rest_framework import serializers -from api.cases.generated_documents.models import GeneratedCaseDocument from api.cases.models import Case -class CaseLicenceSerializer(serializers.ModelSerializer): +class LicenceDecisionType(str, Enum): + ISSUED = "issued" + + +class LicenceDecisionSerializer(serializers.ModelSerializer): decision = serializers.SerializerMethodField() - issued_date = serializers.SerializerMethodField() + decision_made_at = serializers.SerializerMethodField() class Meta: model = Case @@ -15,16 +20,11 @@ class Meta: "id", "reference_code", "decision", - "issued_date", + "decision_made_at", ) def get_decision(self, case): - return "issued" - - def get_issued_date(self, case): - return ( - case.licences.all() - .annotate(issued_at=F("generatedcasedocument__created_at")) - .earliest("issued_at") - .issued_at - ) + return LicenceDecisionType.ISSUED + + def get_decision_made_at(self, case): + return case.licences.annotate(issued_at=F("generatedcasedocument__created_at")).earliest("issued_at").issued_at diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 11d451351..27b94da8e 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -6,7 +6,7 @@ router_v2 = DefaultRouter() router_v2.register( - "licences", - views.LicencesListView, - basename="dw-licences", + "licence-decisions", + views.LicenceDecisionListView, + basename="dw-licence-decisions", ) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index d719d879a..f9cd7eeac 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -5,27 +5,24 @@ from rest_framework_csv.renderers import PaginatedCSVRenderer from api.cases.models import Case -from api.cases.generated_documents.models import GeneratedCaseDocument from api.core.authentication import DataWorkspaceOnlyAuthentication -from api.data_workspace.v2.serializers import CaseLicenceSerializer +from api.data_workspace.v2.serializers import LicenceDecisionSerializer + SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" -class LicencesListView(viewsets.ReadOnlyModelViewSet): +class LicenceDecisionListView(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = LimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) - serializer_class = CaseLicenceSerializer - - def get_queryset(self): - licence_documents = GeneratedCaseDocument.objects.filter( - template_id=SIEL_TEMPLATE_ID, visible_to_exporter=True, safe=True - ) - return ( - Case.objects.filter( - licences__generatedcasedocument__in=licence_documents, - ) - .distinct() - .order_by("-reference_code") + queryset = ( + Case.objects.filter( + licences__generatedcasedocument__template_id=SIEL_TEMPLATE_ID, + licences__generatedcasedocument__visible_to_exporter=True, + licences__generatedcasedocument__safe=True, ) + .distinct() + .order_by("-reference_code") + ) + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = LicenceDecisionSerializer From c10f0e92e98005ad57f257c70495e6707c35479f Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 22 Oct 2024 19:31:09 +0100 Subject: [PATCH 16/89] Allow pagination to be disabled for licence decisions --- api/data_workspace/v2/urls.py | 2 +- api/data_workspace/v2/views.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 27b94da8e..a8f6055a1 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -7,6 +7,6 @@ router_v2.register( "licence-decisions", - views.LicenceDecisionListView, + views.LicenceDecisionViewSet, basename="dw-licence-decisions", ) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index f9cd7eeac..216e6f64b 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -6,15 +6,24 @@ 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 SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" -class LicenceDecisionListView(viewsets.ReadOnlyModelViewSet): +class DisableableLimitOffsetPagination(LimitOffsetPagination): + def paginate_queryset(self, queryset, request, view=None): + if str_to_bool(request.GET.get("disable_pagination", False)): + return + + return super().paginate_queryset(queryset, request, view) + + +class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = LimitOffsetPagination + pagination_class = DisableableLimitOffsetPagination queryset = ( Case.objects.filter( licences__generatedcasedocument__template_id=SIEL_TEMPLATE_ID, From 7b0632298bb4b3818e94611e2b27600194d23b93 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 28 Oct 2024 12:20:08 +0000 Subject: [PATCH 17/89] Include refused cases in the extract Use the refusal letter generated date as the decision date --- api/data_workspace/v2/serializers.py | 13 ++++- api/data_workspace/v2/tests/bdd/conftest.py | 8 ++- .../v2/tests/bdd/licences/conftest.py | 8 +++ .../v2/tests/bdd/licences/test_licences.py | 56 ++++++++++++++++++- .../v2/tests/bdd/scenarios/licences.feature | 8 +++ api/data_workspace/v2/views.py | 29 ++++++---- 6 files changed, 106 insertions(+), 16 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 97e1634d2..bbd7bbd68 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -4,10 +4,15 @@ from rest_framework import serializers from api.cases.models import Case +from api.cases.generated_documents.models import GeneratedCaseDocument + +SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" +SIEL_REFUSAL_TEMPLATE_ID = "074d8a54-ee10-4dca-82ba-650460650342" class LicenceDecisionType(str, Enum): ISSUED = "issued" + REFUSED = "refused" class LicenceDecisionSerializer(serializers.ModelSerializer): @@ -24,7 +29,13 @@ class Meta: ) def get_decision(self, case): - return LicenceDecisionType.ISSUED + if case.licences.count(): + return LicenceDecisionType.ISSUED + else: + return LicenceDecisionType.REFUSED def get_decision_made_at(self, case): + if not case.licences.count(): + return GeneratedCaseDocument.objects.get(case=case, template_id=SIEL_REFUSAL_TEMPLATE_ID).created_at + return case.licences.annotate(issued_at=F("generatedcasedocument__created_at")).earliest("issued_at").issued_at diff --git a/api/data_workspace/v2/tests/bdd/conftest.py b/api/data_workspace/v2/tests/bdd/conftest.py index d085eef4d..83d021fad 100644 --- a/api/data_workspace/v2/tests/bdd/conftest.py +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -1,10 +1,9 @@ -import csv import json import pytest from rest_framework import status -from api.cases.enums import CaseTypeEnum, CaseTypeSubTypeEnum +from api.cases.enums import CaseTypeEnum from api.cases.models import CaseType from api.core.constants import GovPermissions from api.letter_templates.models import LetterTemplate @@ -40,6 +39,11 @@ 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(): diff --git a/api/data_workspace/v2/tests/bdd/licences/conftest.py b/api/data_workspace/v2/tests/bdd/licences/conftest.py index 134485064..947316924 100644 --- a/api/data_workspace/v2/tests/bdd/licences/conftest.py +++ b/api/data_workspace/v2/tests/bdd/licences/conftest.py @@ -1,6 +1,7 @@ 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 @@ -63,3 +64,10 @@ def standard_case_with_final_advice(lu_case_officer): ) 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_licences.py b/api/data_workspace/v2/tests/bdd/licences/test_licences.py index c0860a5de..455365301 100644 --- a/api/data_workspace/v2/tests/bdd/licences/test_licences.py +++ b/api/data_workspace/v2/tests/bdd/licences/test_licences.py @@ -20,7 +20,7 @@ @pytest.fixture() def licences_list_url(): - return reverse("data_workspace:v2:dw-licences-list") + return reverse("data_workspace:v2:dw-licence-decisions-list") @given("a standard draft licence is created", target_fixture="draft_licence") @@ -58,12 +58,25 @@ def licence_included_in_extract(issued_licence, unpage_data, licences_list_url): assert issued_licence.reference_code in [item["reference_code"] for item in licences] +@then("the refused case is included in the extract") +def refused_case_included_in_extract(refused_case, unpage_data, licences_list_url): + licences = unpage_data(licences_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} @@ -120,3 +133,44 @@ def licence_for_case_is_approved(client, gov_headers, case_with_final_advice): assert licence.status == LicenceStatus.ISSUED 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)}, + ) + 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" + + return case_with_refused_advice diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licences.feature b/api/data_workspace/v2/tests/bdd/scenarios/licences.feature index f9e5d8006..eff790c6e 100644 --- a/api/data_workspace/v2/tests/bdd/scenarios/licences.feature +++ b/api/data_workspace/v2/tests/bdd/scenarios/licences.feature @@ -15,3 +15,11 @@ Scenario: Licence document is generated when licence is issued And case officer generates licence documents And case officer issues licence for this case Then the issued licence is included in the extract + +@refusal +Scenario: Refusal letter is generated 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 + Then the refused case is included in the extract diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 216e6f64b..ae7d4857a 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -4,13 +4,11 @@ from rest_framework_csv.renderers import PaginatedCSVRenderer +from api.cases.generated_documents.models import GeneratedCaseDocument 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 - - -SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" +from api.data_workspace.v2.serializers import SIEL_TEMPLATE_ID, SIEL_REFUSAL_TEMPLATE_ID, LicenceDecisionSerializer class DisableableLimitOffsetPagination(LimitOffsetPagination): @@ -24,14 +22,21 @@ def paginate_queryset(self, queryset, request, view=None): class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination - queryset = ( - Case.objects.filter( - licences__generatedcasedocument__template_id=SIEL_TEMPLATE_ID, - licences__generatedcasedocument__visible_to_exporter=True, - licences__generatedcasedocument__safe=True, - ) - .distinct() - .order_by("-reference_code") + + issued_qs = Case.objects.filter( + licences__generatedcasedocument__template_id=SIEL_TEMPLATE_ID, + licences__generatedcasedocument__visible_to_exporter=True, + licences__generatedcasedocument__safe=True, ) + refused_qs = Case.objects.filter( + id__in=GeneratedCaseDocument.objects.filter( + template_id=SIEL_REFUSAL_TEMPLATE_ID, + visible_to_exporter=True, + safe=True, + ).values_list("case", flat=True) + ) + + queryset = (issued_qs | refused_qs).distinct().order_by("-reference_code") + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = LicenceDecisionSerializer From 84c0b2f8cb9d4c7b93f4fcd944596fd7967f1745 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Mon, 28 Oct 2024 14:33:02 +0000 Subject: [PATCH 18/89] Simplify query for getting cases with case documents --- api/data_workspace/v2/views.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index ae7d4857a..44540fb91 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -4,7 +4,6 @@ from rest_framework_csv.renderers import PaginatedCSVRenderer -from api.cases.generated_documents.models import GeneratedCaseDocument from api.cases.models import Case from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool @@ -23,20 +22,15 @@ class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination - issued_qs = Case.objects.filter( - licences__generatedcasedocument__template_id=SIEL_TEMPLATE_ID, - licences__generatedcasedocument__visible_to_exporter=True, - licences__generatedcasedocument__safe=True, + queryset = ( + Case.objects.filter( + casedocument__generatedcasedocument__template_id__in=[SIEL_TEMPLATE_ID, SIEL_REFUSAL_TEMPLATE_ID], + casedocument__visible_to_exporter=True, + casedocument__safe=True, + ) + .distinct() + .order_by("-reference_code") ) - refused_qs = Case.objects.filter( - id__in=GeneratedCaseDocument.objects.filter( - template_id=SIEL_REFUSAL_TEMPLATE_ID, - visible_to_exporter=True, - safe=True, - ).values_list("case", flat=True) - ) - - queryset = (issued_qs | refused_qs).distinct().order_by("-reference_code") renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = LicenceDecisionSerializer From 0600888e8a7c4aee15fb9bc2e0947a5b736fd7cc Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Mon, 28 Oct 2024 15:26:56 +0000 Subject: [PATCH 19/89] Determine issued and refused in queryset --- api/data_workspace/v2/serializers.py | 14 ++++++++------ api/data_workspace/v2/views.py | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index bbd7bbd68..6217b1f10 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -29,13 +29,15 @@ class Meta: ) def get_decision(self, case): - if case.licences.count(): - return LicenceDecisionType.ISSUED - else: - return LicenceDecisionType.REFUSED + return case.decision def get_decision_made_at(self, case): - if not case.licences.count(): + if case.decision == "issued": + return ( + case.licences.annotate(issued_at=F("generatedcasedocument__created_at")).earliest("issued_at").issued_at + ) + + if case.decision == "refused": return GeneratedCaseDocument.objects.get(case=case, template_id=SIEL_REFUSAL_TEMPLATE_ID).created_at - return case.licences.annotate(issued_at=F("generatedcasedocument__created_at")).earliest("issued_at").issued_at + raise Exception("No decision found") diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 44540fb91..7e5173830 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -4,6 +4,16 @@ from rest_framework_csv.renderers import PaginatedCSVRenderer +from django.contrib.postgres.aggregates import ArrayAgg +from django.db.models import ( + Case as DBCase, + Q, + TextField, + Value, + When, +) +from django.db.models.functions import Cast + from api.cases.models import Case from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool @@ -28,6 +38,18 @@ class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): casedocument__visible_to_exporter=True, casedocument__safe=True, ) + .annotate( + template_ids=ArrayAgg( + Cast("casedocument__generatedcasedocument__template_id", output_field=TextField()), distinct=True + ) + ) + .filter(Q(template_ids=[SIEL_TEMPLATE_ID]) | Q(template_ids=[SIEL_REFUSAL_TEMPLATE_ID])) + .annotate( + decision=DBCase( + When(template_ids=[SIEL_TEMPLATE_ID], then=Value("issued")), + When(template_ids=[SIEL_REFUSAL_TEMPLATE_ID], then=Value("refused")), + ) + ) .distinct() .order_by("-reference_code") ) From ce5f7ffd5393b8c0a77d32bc98101a915f32b2a3 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Mon, 28 Oct 2024 19:58:45 +0000 Subject: [PATCH 20/89] Condense down logic for finding decision made at --- api/data_workspace/v2/serializers.py | 27 +++++++++++++++++---------- api/data_workspace/v2/views.py | 23 ++++++++++++++++++----- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 6217b1f10..a9d7ca379 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,10 +1,8 @@ from enum import Enum -from django.db.models import F from rest_framework import serializers from api.cases.models import Case -from api.cases.generated_documents.models import GeneratedCaseDocument SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" SIEL_REFUSAL_TEMPLATE_ID = "074d8a54-ee10-4dca-82ba-650460650342" @@ -14,6 +12,17 @@ class LicenceDecisionType(str, Enum): ISSUED = "issued" REFUSED = "refused" + @classmethod + def templates(cls): + return { + cls.ISSUED: SIEL_TEMPLATE_ID, + cls.REFUSED: SIEL_REFUSAL_TEMPLATE_ID, + } + + @classmethod + def get_template(cls, decision): + return cls.templates()[cls(decision)] + class LicenceDecisionSerializer(serializers.ModelSerializer): decision = serializers.SerializerMethodField() @@ -32,12 +41,10 @@ def get_decision(self, case): return case.decision def get_decision_made_at(self, case): - if case.decision == "issued": - return ( - case.licences.annotate(issued_at=F("generatedcasedocument__created_at")).earliest("issued_at").issued_at - ) - - if case.decision == "refused": - return GeneratedCaseDocument.objects.get(case=case, template_id=SIEL_REFUSAL_TEMPLATE_ID).created_at + documents = case.casedocument_set.filter( + generatedcasedocument__template_id=LicenceDecisionType.get_template(case.decision), + safe=True, + visible_to_exporter=True, + ) - raise Exception("No decision found") + return documents.earliest("created_at").created_at diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 7e5173830..0a3aa7dbf 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -1,3 +1,6 @@ +import functools +import operator + from rest_framework import viewsets from rest_framework.pagination import LimitOffsetPagination from rest_framework.settings import api_settings @@ -17,7 +20,10 @@ 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 SIEL_TEMPLATE_ID, SIEL_REFUSAL_TEMPLATE_ID, LicenceDecisionSerializer +from api.data_workspace.v2.serializers import ( + LicenceDecisionSerializer, + LicenceDecisionType, +) class DisableableLimitOffsetPagination(LimitOffsetPagination): @@ -34,7 +40,7 @@ class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): queryset = ( Case.objects.filter( - casedocument__generatedcasedocument__template_id__in=[SIEL_TEMPLATE_ID, SIEL_REFUSAL_TEMPLATE_ID], + casedocument__generatedcasedocument__template_id__in=LicenceDecisionType.templates().values(), casedocument__visible_to_exporter=True, casedocument__safe=True, ) @@ -43,11 +49,18 @@ class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): Cast("casedocument__generatedcasedocument__template_id", output_field=TextField()), distinct=True ) ) - .filter(Q(template_ids=[SIEL_TEMPLATE_ID]) | Q(template_ids=[SIEL_REFUSAL_TEMPLATE_ID])) + .filter( + functools.reduce( + operator.or_, + [Q(template_ids=[template_id]) for template_id in LicenceDecisionType.templates().values()], + ) + ) .annotate( decision=DBCase( - When(template_ids=[SIEL_TEMPLATE_ID], then=Value("issued")), - When(template_ids=[SIEL_REFUSAL_TEMPLATE_ID], then=Value("refused")), + *[ + When(template_ids=[template_id], then=Value(decision.value)) + for decision, template_id in LicenceDecisionType.templates().items() + ] ) ) .distinct() From 35f1450d4e797a88db80707f204152f87116a0b2 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 29 Oct 2024 11:41:55 +0000 Subject: [PATCH 21/89] Temporarily skip hawk authentication --- api/core/authentication.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/core/authentication.py b/api/core/authentication.py index 6f95857ed..009f7ba6b 100644 --- a/api/core/authentication.py +++ b/api/core/authentication.py @@ -180,6 +180,8 @@ def authenticate(self, request): Only approve HAWK Signed requests from the Data workspace """ + return AnonymousUser(), _ + try: hawk_receiver = _authenticate(request, _lookup_credentials_data_workspace_access) except HawkFail as e: From 91b3d219aa3d9dffb6badca76215d1bcc9615eca Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 29 Oct 2024 11:54:43 +0000 Subject: [PATCH 22/89] Fix DW authentication --- api/core/authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/core/authentication.py b/api/core/authentication.py index 009f7ba6b..f9b95c819 100644 --- a/api/core/authentication.py +++ b/api/core/authentication.py @@ -180,7 +180,7 @@ def authenticate(self, request): Only approve HAWK Signed requests from the Data workspace """ - return AnonymousUser(), _ + return AnonymousUser(), None try: hawk_receiver = _authenticate(request, _lookup_credentials_data_workspace_access) From df81a440b97cc41864ab158585e488f0a76d3102 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 29 Oct 2024 14:04:06 +0000 Subject: [PATCH 23/89] Add revoked as a licence decision --- api/data_workspace/v2/serializers.py | 26 +++++++++--- api/data_workspace/v2/views.py | 62 ++++++++++++++++++---------- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index a9d7ca379..812df52f1 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -2,7 +2,10 @@ from rest_framework import serializers +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit from api.cases.models import Case +from api.licences.enums import LicenceStatus SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" SIEL_REFUSAL_TEMPLATE_ID = "074d8a54-ee10-4dca-82ba-650460650342" @@ -41,10 +44,21 @@ def get_decision(self, case): return case.decision def get_decision_made_at(self, case): - documents = case.casedocument_set.filter( - generatedcasedocument__template_id=LicenceDecisionType.get_template(case.decision), - safe=True, - visible_to_exporter=True, - ) + if case.decision in list(LicenceDecisionType): + documents = case.casedocument_set.filter( + generatedcasedocument__template_id=LicenceDecisionType.get_template(case.decision), + safe=True, + visible_to_exporter=True, + ) + return documents.earliest("created_at").created_at + + if case.decision == "revoked": + audits = Audit.objects.filter( + target_object_id=case.pk, + payload__status=LicenceStatus.REVOKED, + verb=AuditType.LICENCE_UPDATED_STATUS, + ) + + return audits.earliest("created_at").created_at - return documents.earliest("created_at").created_at + raise ValueError(f"Unknown decision type `{case.decision}`") diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 0a3aa7dbf..cc3fbc350 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -8,6 +8,7 @@ from rest_framework_csv.renderers import PaginatedCSVRenderer from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField from django.db.models import ( Case as DBCase, Q, @@ -17,6 +18,8 @@ ) from django.db.models.functions import Cast +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit from api.cases.models import Case from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool @@ -24,6 +27,7 @@ LicenceDecisionSerializer, LicenceDecisionType, ) +from api.licences.enums import LicenceStatus class DisableableLimitOffsetPagination(LimitOffsetPagination): @@ -39,31 +43,47 @@ class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination queryset = ( - Case.objects.filter( - casedocument__generatedcasedocument__template_id__in=LicenceDecisionType.templates().values(), - casedocument__visible_to_exporter=True, - casedocument__safe=True, - ) - .annotate( - template_ids=ArrayAgg( - Cast("casedocument__generatedcasedocument__template_id", output_field=TextField()), distinct=True + ( + Case.objects.filter( + casedocument__generatedcasedocument__template_id__in=LicenceDecisionType.templates().values(), + casedocument__visible_to_exporter=True, + casedocument__safe=True, ) - ) - .filter( - functools.reduce( - operator.or_, - [Q(template_ids=[template_id]) for template_id in LicenceDecisionType.templates().values()], + .annotate( + template_ids=ArrayAgg( + Cast("casedocument__generatedcasedocument__template_id", output_field=TextField()), distinct=True + ) ) - ) - .annotate( - decision=DBCase( - *[ - When(template_ids=[template_id], then=Value(decision.value)) - for decision, template_id in LicenceDecisionType.templates().items() - ] + .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.value)) + for decision, template_id in LicenceDecisionType.templates().items() + ] + ) + ) + .distinct() + ) + .union( + Case.objects.filter( + pk__in=list( + Audit.objects.filter( + payload__status=LicenceStatus.REVOKED, + verb=AuditType.LICENCE_UPDATED_STATUS, + ).values_list("target_object_id", flat=True) + ) + ).annotate( + template_ids=Value([], output_field=ArrayField(TextField())), + decision=Value("revoked", output_field=TextField()), + ), + all=True, ) - .distinct() .order_by("-reference_code") ) From 55c8493e718b95ccceeed8103324b4c17143eadf Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 29 Oct 2024 16:38:14 +0000 Subject: [PATCH 24/89] Move into queryset --- api/data_workspace/v2/views.py | 86 +++++++++++++++++----------------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index cc3fbc350..3d6af863c 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -41,51 +41,53 @@ def paginate_queryset(self, queryset, request, view=None): class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = LicenceDecisionSerializer - queryset = ( - ( - Case.objects.filter( - casedocument__generatedcasedocument__template_id__in=LicenceDecisionType.templates().values(), - casedocument__visible_to_exporter=True, - casedocument__safe=True, - ) - .annotate( - template_ids=ArrayAgg( - Cast("casedocument__generatedcasedocument__template_id", output_field=TextField()), distinct=True + def get_queryset(self): + queryset = ( + ( + Case.objects.filter( + casedocument__generatedcasedocument__template_id__in=LicenceDecisionType.templates().values(), + casedocument__visible_to_exporter=True, + casedocument__safe=True, ) - ) - .filter( - functools.reduce( - operator.or_, - [Q(template_ids=[template_id]) for template_id in LicenceDecisionType.templates().values()], + .annotate( + template_ids=ArrayAgg( + Cast("casedocument__generatedcasedocument__template_id", output_field=TextField()), + distinct=True, + ) ) - ) - .annotate( - decision=DBCase( - *[ - When(template_ids=[template_id], then=Value(decision.value)) - for decision, template_id in LicenceDecisionType.templates().items() - ] + .filter( + functools.reduce( + operator.or_, + [Q(template_ids=[template_id]) for template_id in LicenceDecisionType.templates().values()], + ) ) - ) - .distinct() - ) - .union( - Case.objects.filter( - pk__in=list( - Audit.objects.filter( - payload__status=LicenceStatus.REVOKED, - verb=AuditType.LICENCE_UPDATED_STATUS, - ).values_list("target_object_id", flat=True) + .annotate( + decision=DBCase( + *[ + When(template_ids=[template_id], then=Value(decision.value)) + for decision, template_id in LicenceDecisionType.templates().items() + ] + ) ) - ).annotate( - template_ids=Value([], output_field=ArrayField(TextField())), - decision=Value("revoked", output_field=TextField()), - ), - all=True, + .distinct() + ) + .union( + Case.objects.filter( + pk__in=list( + Audit.objects.filter( + payload__status=LicenceStatus.REVOKED, + verb=AuditType.LICENCE_UPDATED_STATUS, + ).values_list("target_object_id", flat=True) + ) + ).annotate( + template_ids=Value([], output_field=ArrayField(TextField())), + decision=Value("revoked", output_field=TextField()), + ), + all=True, + ) + .order_by("-reference_code") ) - .order_by("-reference_code") - ) - - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) - serializer_class = LicenceDecisionSerializer + return queryset From 581870746429f0eff4a861b68bca93233316fe21 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 31 Oct 2024 12:41:50 +0000 Subject: [PATCH 25/89] Add LicenceDecision model to concretely define a case outcome A case can have multiple outcomes and we create instances of this model for each outcome. --- api/cases/enums.py | 32 ++++++++++ api/cases/migrations/0067_licencedecision.py | 63 ++++++++++++++++++++ api/cases/models.py | 13 ++++ 3 files changed, 108 insertions(+) create mode 100644 api/cases/migrations/0067_licencedecision.py diff --git a/api/cases/enums.py b/api/cases/enums.py index 6376a92a4..6d21e6407 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,32 @@ 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 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/migrations/0067_licencedecision.py b/api/cases/migrations/0067_licencedecision.py new file mode 100644 index 000000000..a5cdebb8f --- /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/models.py b/api/cases/models.py index 1735d392b..f69016bc1 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -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 @@ -686,3 +687,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})" From 3ad9f1a3d64ce2d1e0fe9014f84e7a39ed314563 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 31 Oct 2024 12:47:11 +0000 Subject: [PATCH 26/89] Add migration to back populate LicenceDecision instances These are created for issued, refused and revoked outcomes. --- api/cases/enums.py | 4 + .../0068_populate_licence_decisions.py | 104 ++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 api/cases/migrations/0068_populate_licence_decisions.py diff --git a/api/cases/enums.py b/api/cases/enums.py index 6d21e6407..8113cd8d6 100644 --- a/api/cases/enums.py +++ b/api/cases/enums.py @@ -440,3 +440,7 @@ def templates(cls): @classmethod def advice_type_to_decision(cls, advice_type): return cls.decision_map[advice_type] + + @classmethod + def get_template(cls, decision): + return cls.templates()[decision] 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 000000000..2928fe299 --- /dev/null +++ b/api/cases/migrations/0068_populate_licence_decisions.py @@ -0,0 +1,104 @@ +# 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): + 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") + earliest_audit_log = final_decision_qs.last() + + document_qs = ( + GeneratedCaseDocument.objects.filter( + created_at__date__lt=earliest_audit_log.created_at.date(), + 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() + ] + ) + ) + ) + + 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"), + ] + + operations = [ + migrations.RunPython( + populate_licence_decisions, + migrations.RunPython.noop, + ), + ] From 9ed53776a1dd0ff38fd25632fab3d8e99dea6a05 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 31 Oct 2024 12:57:46 +0000 Subject: [PATCH 27/89] Add new endpoint to export licence decisions from the model --- api/cases/enums.py | 4 +++ api/data_workspace/v2/serializers.py | 50 +++++++++++++++++----------- api/data_workspace/v2/urls.py | 6 ++++ api/data_workspace/v2/views.py | 40 ++++++++++++++++++++-- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/api/cases/enums.py b/api/cases/enums.py index 8113cd8d6..aeee7b6b0 100644 --- a/api/cases/enums.py +++ b/api/cases/enums.py @@ -429,6 +429,10 @@ class LicenceDecisionType: AdviceType.REFUSE: REFUSED, } + @classmethod + def decisions(cls): + return [d[0] for d in cls.choices] + @classmethod def templates(cls): return { diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 812df52f1..eaa910b65 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,9 +1,8 @@ -from enum import Enum - from rest_framework import serializers from api.audit_trail.enums import AuditType from api.audit_trail.models import Audit +from api.cases.enums import LicenceDecisionType from api.cases.models import Case from api.licences.enums import LicenceStatus @@ -11,23 +10,7 @@ SIEL_REFUSAL_TEMPLATE_ID = "074d8a54-ee10-4dca-82ba-650460650342" -class LicenceDecisionType(str, Enum): - ISSUED = "issued" - REFUSED = "refused" - - @classmethod - def templates(cls): - return { - cls.ISSUED: SIEL_TEMPLATE_ID, - cls.REFUSED: SIEL_REFUSAL_TEMPLATE_ID, - } - - @classmethod - def get_template(cls, decision): - return cls.templates()[cls(decision)] - - -class LicenceDecisionSerializer(serializers.ModelSerializer): +class LicenceDecisionDerivedSerializer(serializers.ModelSerializer): decision = serializers.SerializerMethodField() decision_made_at = serializers.SerializerMethodField() @@ -62,3 +45,32 @@ def get_decision_made_at(self, case): return audits.earliest("created_at").created_at raise ValueError(f"Unknown decision type `{case.decision}`") + + +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}`") + + return ( + case.licence_decisions.filter( + decision=case.decision, + ) + .earliest("created_at") + .created_at + ) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index a8f6055a1..f42c20458 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -5,6 +5,12 @@ router_v2 = DefaultRouter() +router_v2.register( + "licence-decisions-derived", + views.LicenceDecisionDerivedViewSet, + basename="dw-licence-decisions-derived", +) + router_v2.register( "licence-decisions", views.LicenceDecisionViewSet, diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 3d6af863c..8ea2394b3 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -11,6 +11,7 @@ from django.contrib.postgres.fields import ArrayField from django.db.models import ( Case as DBCase, + F, Q, TextField, Value, @@ -24,6 +25,7 @@ from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool from api.data_workspace.v2.serializers import ( + LicenceDecisionDerivedSerializer, LicenceDecisionSerializer, LicenceDecisionType, ) @@ -38,11 +40,11 @@ def paginate_queryset(self, queryset, request, view=None): return super().paginate_queryset(queryset, request, view) -class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): +class LicenceDecisionDerivedViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) - serializer_class = LicenceDecisionSerializer + serializer_class = LicenceDecisionDerivedSerializer def get_queryset(self): queryset = ( @@ -91,3 +93,37 @@ def get_queryset(self): .order_by("-reference_code") ) return queryset + + +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 From 8c3e26aa5c801a0096961b51bd9cce74cd5a2817 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 7 Nov 2024 16:07:02 +0000 Subject: [PATCH 28/89] Remove unused licence decisions endpoint We now have LicenceDecision model so we can generate the extract using those instances instead of deriving from other models. --- api/data_workspace/v2/serializers.py | 43 ---------------- api/data_workspace/v2/urls.py | 6 --- api/data_workspace/v2/views.py | 74 +--------------------------- 3 files changed, 1 insertion(+), 122 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index eaa910b65..182111247 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,50 +1,7 @@ from rest_framework import serializers -from api.audit_trail.enums import AuditType -from api.audit_trail.models import Audit from api.cases.enums import LicenceDecisionType from api.cases.models import Case -from api.licences.enums import LicenceStatus - -SIEL_TEMPLATE_ID = "d159b195-9256-4a00-9bc8-1eb2cebfa1d2" -SIEL_REFUSAL_TEMPLATE_ID = "074d8a54-ee10-4dca-82ba-650460650342" - - -class LicenceDecisionDerivedSerializer(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 in list(LicenceDecisionType): - documents = case.casedocument_set.filter( - generatedcasedocument__template_id=LicenceDecisionType.get_template(case.decision), - safe=True, - visible_to_exporter=True, - ) - return documents.earliest("created_at").created_at - - if case.decision == "revoked": - audits = Audit.objects.filter( - target_object_id=case.pk, - payload__status=LicenceStatus.REVOKED, - verb=AuditType.LICENCE_UPDATED_STATUS, - ) - - return audits.earliest("created_at").created_at - - raise ValueError(f"Unknown decision type `{case.decision}`") class LicenceDecisionSerializer(serializers.ModelSerializer): diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index f42c20458..a8f6055a1 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -5,12 +5,6 @@ router_v2 = DefaultRouter() -router_v2.register( - "licence-decisions-derived", - views.LicenceDecisionDerivedViewSet, - basename="dw-licence-decisions-derived", -) - router_v2.register( "licence-decisions", views.LicenceDecisionViewSet, diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 8ea2394b3..8a4973a97 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -1,6 +1,3 @@ -import functools -import operator - from rest_framework import viewsets from rest_framework.pagination import LimitOffsetPagination from rest_framework.settings import api_settings @@ -8,28 +5,14 @@ from rest_framework_csv.renderers import PaginatedCSVRenderer from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models import ( - Case as DBCase, - F, - Q, - TextField, - Value, - When, -) -from django.db.models.functions import Cast - -from api.audit_trail.enums import AuditType -from api.audit_trail.models import Audit +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 ( - LicenceDecisionDerivedSerializer, LicenceDecisionSerializer, LicenceDecisionType, ) -from api.licences.enums import LicenceStatus class DisableableLimitOffsetPagination(LimitOffsetPagination): @@ -40,61 +23,6 @@ def paginate_queryset(self, queryset, request, view=None): return super().paginate_queryset(queryset, request, view) -class LicenceDecisionDerivedViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) - serializer_class = LicenceDecisionDerivedSerializer - - def get_queryset(self): - queryset = ( - ( - Case.objects.filter( - casedocument__generatedcasedocument__template_id__in=LicenceDecisionType.templates().values(), - casedocument__visible_to_exporter=True, - casedocument__safe=True, - ) - .annotate( - template_ids=ArrayAgg( - Cast("casedocument__generatedcasedocument__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.value)) - for decision, template_id in LicenceDecisionType.templates().items() - ] - ) - ) - .distinct() - ) - .union( - Case.objects.filter( - pk__in=list( - Audit.objects.filter( - payload__status=LicenceStatus.REVOKED, - verb=AuditType.LICENCE_UPDATED_STATUS, - ).values_list("target_object_id", flat=True) - ) - ).annotate( - template_ids=Value([], output_field=ArrayField(TextField())), - decision=Value("revoked", output_field=TextField()), - ), - all=True, - ) - .order_by("-reference_code") - ) - return queryset - - class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination From a5a178033a0373be3d7e75c852af7f786ffe519d Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 7 Nov 2024 16:31:25 +0000 Subject: [PATCH 29/89] Add endpoint to export list of applications --- api/data_workspace/v2/serializers.py | 12 ++++++++++++ api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 12 ++++++++++++ 3 files changed, 30 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 182111247..23512765c 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers +from api.applications.models import StandardApplication from api.cases.enums import LicenceDecisionType from api.cases.models import Case @@ -31,3 +32,14 @@ def get_decision_made_at(self, case): .earliest("created_at") .created_at ) + + +class ApplicationSerializer(serializers.ModelSerializer): + licence_type = serializers.CharField(source="case_type.reference") + + class Meta: + model = StandardApplication + fields = ( + "id", + "licence_type", + ) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index a8f6055a1..69b010de4 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -10,3 +10,9 @@ views.LicenceDecisionViewSet, basename="dw-licence-decisions", ) + +router_v2.register( + "applications", + views.ApplicationViewSet, + basename="dw-applications", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 8a4973a97..c2f1aa381 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -6,13 +6,17 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import F + +from api.applications.models import StandardApplication 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 ( + ApplicationSerializer, LicenceDecisionSerializer, LicenceDecisionType, ) +from api.staticdata.statuses.enums import CaseStatusEnum class DisableableLimitOffsetPagination(LimitOffsetPagination): @@ -55,3 +59,11 @@ def get_queryset(self): .order_by("-reference_code") ) return queryset + + +class ApplicationViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = ApplicationSerializer + queryset = StandardApplication.objects.exclude(status__status=CaseStatusEnum.terminal_statuses()) From 2c7e80e75fc0249ded29b496a2c00c50b3f6031c Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 7 Nov 2024 16:44:59 +0000 Subject: [PATCH 30/89] Add endpoint to export list of countries --- api/data_workspace/v2/serializers.py | 12 ++++++++++++ api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 10 ++++++++++ 3 files changed, 28 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 23512765c..351b6b760 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -3,6 +3,7 @@ from api.applications.models import StandardApplication from api.cases.enums import LicenceDecisionType from api.cases.models import Case +from api.staticdata.countries.models import Country class LicenceDecisionSerializer(serializers.ModelSerializer): @@ -43,3 +44,14 @@ class Meta: "id", "licence_type", ) + + +class CountrySerializer(serializers.ModelSerializer): + code = serializers.CharField(source="id") + + class Meta: + model = Country + fields = ( + "code", + "name", + ) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 69b010de4..7cdabc973 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -16,3 +16,9 @@ views.ApplicationViewSet, basename="dw-applications", ) + +router_v2.register( + "countries", + views.CountryViewSet, + basename="dw-countries", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index c2f1aa381..2cbe620b8 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -13,9 +13,11 @@ from api.core.helpers import str_to_bool from api.data_workspace.v2.serializers import ( ApplicationSerializer, + CountrySerializer, LicenceDecisionSerializer, LicenceDecisionType, ) +from api.staticdata.countries.models import Country from api.staticdata.statuses.enums import CaseStatusEnum @@ -67,3 +69,11 @@ class ApplicationViewSet(viewsets.ReadOnlyModelViewSet): renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = ApplicationSerializer queryset = StandardApplication.objects.exclude(status__status=CaseStatusEnum.terminal_statuses()) + + +class CountryViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = CountrySerializer + queryset = Country.objects.all().order_by("id", "name") From 760f2f550bff96cd7dce3ef374d314caf5ea90fb Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 7 Nov 2024 17:00:07 +0000 Subject: [PATCH 31/89] Add endpoint to export list of destinations on the applications --- api/data_workspace/v2/serializers.py | 16 +++++++++++++++- api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 11 ++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 351b6b760..ff73f3a1d 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from api.applications.models import StandardApplication +from api.applications.models import PartyOnApplication, StandardApplication from api.cases.enums import LicenceDecisionType from api.cases.models import Case from api.staticdata.countries.models import Country @@ -55,3 +55,17 @@ class Meta: "code", "name", ) + + +class DestinationSerializer(serializers.ModelSerializer): + country_code = serializers.CharField(source="party.country.id") + application_id = serializers.CharField(source="application.id") + type = serializers.CharField(source="party.type") + + class Meta: + model = PartyOnApplication + fields = ( + "country_code", + "application_id", + "type", + ) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 7cdabc973..b8f6880e9 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -22,3 +22,9 @@ views.CountryViewSet, basename="dw-countries", ) + +router_v2.register( + "destinations", + views.DestinationViewSet, + basename="dw-destinations", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 2cbe620b8..3740ad55e 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -7,13 +7,14 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import F -from api.applications.models import StandardApplication +from api.applications.models import PartyOnApplication, StandardApplication 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 ( ApplicationSerializer, CountrySerializer, + DestinationSerializer, LicenceDecisionSerializer, LicenceDecisionType, ) @@ -77,3 +78,11 @@ class CountryViewSet(viewsets.ReadOnlyModelViewSet): renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = CountrySerializer queryset = Country.objects.all().order_by("id", "name") + + +class DestinationViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = DestinationSerializer + queryset = PartyOnApplication.objects.exclude(deleted_at__isnull=True) From 11d689d9dff4cd9dbfddbc5db681490290767e41 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Mon, 11 Nov 2024 14:40:35 +0000 Subject: [PATCH 32/89] Move reference code into application table --- api/data_workspace/v2/serializers.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index ff73f3a1d..3d93f3e01 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -7,32 +7,33 @@ class LicenceDecisionSerializer(serializers.ModelSerializer): - decision = serializers.SerializerMethodField() + id = serializers.SerializerMethodField() + application_id = serializers.UUIDField(source="id") + decision = serializers.CharField() decision_made_at = serializers.SerializerMethodField() class Meta: model = Case fields = ( "id", - "reference_code", + "application_id", "decision", "decision_made_at", ) - def get_decision(self, case): - return case.decision - - def get_decision_made_at(self, case): + def get_licence_decision(self, case): if case.decision not in LicenceDecisionType.decisions(): raise ValueError(f"Unknown decision type `{case.decision}`") - return ( - case.licence_decisions.filter( - decision=case.decision, - ) - .earliest("created_at") - .created_at - ) + return case.licence_decisions.filter( + decision=case.decision, + ).earliest("created_at") + + def get_id(self, case): + return self.get_licence_decision(case).pk + + def get_decision_made_at(self, case): + return self.get_licence_decision(case).created_at class ApplicationSerializer(serializers.ModelSerializer): @@ -42,6 +43,7 @@ class Meta: model = StandardApplication fields = ( "id", + "reference_code", "licence_type", ) From 83422643c5312f6810a420c328e0c51f325abbfe Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Mon, 11 Nov 2024 16:16:36 +0000 Subject: [PATCH 33/89] Add endpoint for goods --- api/data_workspace/v2/serializers.py | 16 +++++++++++++++- api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 15 ++++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 3d93f3e01..f0236cc89 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,6 +1,10 @@ from rest_framework import serializers -from api.applications.models import PartyOnApplication, StandardApplication +from api.applications.models import ( + GoodOnApplication, + PartyOnApplication, + StandardApplication, +) from api.cases.enums import LicenceDecisionType from api.cases.models import Case from api.staticdata.countries.models import Country @@ -71,3 +75,13 @@ class Meta: "application_id", "type", ) + + +class GoodSerializer(serializers.ModelSerializer): + class Meta: + model = GoodOnApplication + fields = ( + "id", + "application_id", + "value", + ) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index b8f6880e9..4b8129a32 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -28,3 +28,9 @@ views.DestinationViewSet, basename="dw-destinations", ) + +router_v2.register( + "goods", + views.GoodViewSet, + basename="dw-goods", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 3740ad55e..dde8b4da2 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -7,7 +7,11 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import F -from api.applications.models import PartyOnApplication, StandardApplication +from api.applications.models import ( + GoodOnApplication, + PartyOnApplication, + StandardApplication, +) from api.cases.models import Case from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool @@ -15,6 +19,7 @@ ApplicationSerializer, CountrySerializer, DestinationSerializer, + GoodSerializer, LicenceDecisionSerializer, LicenceDecisionType, ) @@ -86,3 +91,11 @@ class DestinationViewSet(viewsets.ReadOnlyModelViewSet): renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = DestinationSerializer queryset = PartyOnApplication.objects.exclude(deleted_at__isnull=True) + + +class GoodViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = GoodSerializer + queryset = GoodOnApplication.objects.all() From 4881448bc1ae8f3c1b2e6338daa9fa81e0c20eb5 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Mon, 11 Nov 2024 16:36:00 +0000 Subject: [PATCH 34/89] Add an assessments table --- api/data_workspace/v2/serializers.py | 12 ++++++++++++ api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 12 ++++++++++++ 3 files changed, 30 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index f0236cc89..e65126be2 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -7,6 +7,7 @@ ) from api.cases.enums import LicenceDecisionType from api.cases.models import Case +from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country @@ -85,3 +86,14 @@ class Meta: "application_id", "value", ) + + +class AssessmentSerializer(serializers.ModelSerializer): + good_id = serializers.UUIDField() + + class Meta: + model = ControlListEntry + fields = ( + "good_id", + "rating", + ) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 4b8129a32..fc7a80482 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -34,3 +34,9 @@ views.GoodViewSet, basename="dw-goods", ) + +router_v2.register( + "assessments", + views.AssessmentViewSet, + basename="dw-assessments", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index dde8b4da2..9c32d47fd 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -17,12 +17,14 @@ from api.core.helpers import str_to_bool from api.data_workspace.v2.serializers import ( ApplicationSerializer, + AssessmentSerializer, CountrySerializer, DestinationSerializer, GoodSerializer, LicenceDecisionSerializer, LicenceDecisionType, ) +from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country from api.staticdata.statuses.enums import CaseStatusEnum @@ -99,3 +101,13 @@ class GoodViewSet(viewsets.ReadOnlyModelViewSet): renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = GoodSerializer queryset = GoodOnApplication.objects.all() + + +class AssessmentViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = AssessmentSerializer + + def get_queryset(self): + return ControlListEntry.objects.annotate(good_id=F("goodonapplication__id")).exclude(good_id__isnull=True) From d3cc67a775c2304897efe822dbc2ee8d31fbbdf9 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 09:31:29 +0000 Subject: [PATCH 35/89] Add licence_id to licence decision for issued licences --- api/data_workspace/v2/serializers.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index e65126be2..68afb64b9 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -7,6 +7,7 @@ ) from api.cases.enums import LicenceDecisionType from api.cases.models import Case +from api.licences.models import Licence from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country @@ -16,6 +17,7 @@ class LicenceDecisionSerializer(serializers.ModelSerializer): application_id = serializers.UUIDField(source="id") decision = serializers.CharField() decision_made_at = serializers.SerializerMethodField() + licence_id = serializers.SerializerMethodField() class Meta: model = Case @@ -24,6 +26,7 @@ class Meta: "application_id", "decision", "decision_made_at", + "licence_id", ) def get_licence_decision(self, case): @@ -40,6 +43,20 @@ def get_id(self, case): def get_decision_made_at(self, case): return self.get_licence_decision(case).created_at + def get_licence_id(self, case): + licence_decision = self.get_licence_decision(case) + if licence_decision.decision != LicenceDecisionType.ISSUED: + return None + + licences = licence_decision.case.licences.exclude(status="draft").order_by("created_at") + try: + return licences.get().pk + except Licence.MultipleObjectsReturned: + pass + + licences = licences.filter(status="cancelled") + return licences.first().pk + class ApplicationSerializer(serializers.ModelSerializer): licence_type = serializers.CharField(source="case_type.reference") From 4e3c3d20fafb461394d49c295866dc2501ade67f Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 12:09:31 +0000 Subject: [PATCH 36/89] Filter out deleted destinations --- api/data_workspace/v2/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 9c32d47fd..1edf11622 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -92,7 +92,7 @@ class DestinationViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = DestinationSerializer - queryset = PartyOnApplication.objects.exclude(deleted_at__isnull=True) + queryset = PartyOnApplication.objects.filter(deleted_at__isnull=True) class GoodViewSet(viewsets.ReadOnlyModelViewSet): From 838d2c436cced12ceaec0bd7c3786ce9ecbc9bf2 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 13:33:35 +0000 Subject: [PATCH 37/89] Add endpoint for goods on licence --- api/data_workspace/v2/serializers.py | 15 ++++++++++++++- api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 11 +++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 68afb64b9..ca1e73c6b 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -7,7 +7,10 @@ ) from api.cases.enums import LicenceDecisionType from api.cases.models import Case -from api.licences.models import Licence +from api.licences.models import ( + GoodOnLicence, + Licence, +) from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country @@ -114,3 +117,13 @@ class Meta: "good_id", "rating", ) + + +class GoodOnLicenceSerializer(serializers.ModelSerializer): + class Meta: + model = GoodOnLicence + fields = ( + "id", + "good_id", + "licence_id", + ) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index fc7a80482..aafcaa695 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -40,3 +40,9 @@ views.AssessmentViewSet, basename="dw-assessments", ) + +router_v2.register( + "goods-on-licence", + views.GoodOnLicenceViewSet, + basename="dw-goods-on-licence", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 1edf11622..94a48ff80 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -20,10 +20,13 @@ AssessmentSerializer, CountrySerializer, DestinationSerializer, + GoodOnLicenceSerializer, GoodSerializer, LicenceDecisionSerializer, LicenceDecisionType, ) +from api.licences.enums import LicenceStatus +from api.licences.models import GoodOnLicence from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country from api.staticdata.statuses.enums import CaseStatusEnum @@ -111,3 +114,11 @@ class AssessmentViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return ControlListEntry.objects.annotate(good_id=F("goodonapplication__id")).exclude(good_id__isnull=True) + + +class GoodOnLicenceViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = GoodOnLicenceSerializer + queryset = GoodOnLicence.objects.exclude(licence__status=LicenceStatus.DRAFT) From a2563bf3aa20a33c5837492e32f98cd605a21420 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 13:48:10 +0000 Subject: [PATCH 38/89] Fix queryset for applications --- api/data_workspace/v2/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 94a48ff80..c0515fd61 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -79,7 +79,7 @@ class ApplicationViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = ApplicationSerializer - queryset = StandardApplication.objects.exclude(status__status=CaseStatusEnum.terminal_statuses()) + queryset = StandardApplication.objects.filter(status__status__in=CaseStatusEnum.terminal_statuses()) class CountryViewSet(viewsets.ReadOnlyModelViewSet): From 78bc1feadf55e8bb10d162ecf2caacc12d0ad20b Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 13:48:32 +0000 Subject: [PATCH 39/89] Update querysets to replicate application filtering --- api/data_workspace/v2/views.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index c0515fd61..cf4ecfe9c 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -95,7 +95,10 @@ class DestinationViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = DestinationSerializer - queryset = PartyOnApplication.objects.filter(deleted_at__isnull=True) + queryset = PartyOnApplication.objects.filter( + deleted_at__isnull=True, + application__status__status__in=CaseStatusEnum.terminal_statuses(), + ) class GoodViewSet(viewsets.ReadOnlyModelViewSet): @@ -103,7 +106,7 @@ class GoodViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = GoodSerializer - queryset = GoodOnApplication.objects.all() + queryset = GoodOnApplication.objects.filter(application__status__status__in=CaseStatusEnum.terminal_statuses()) class AssessmentViewSet(viewsets.ReadOnlyModelViewSet): @@ -121,4 +124,6 @@ class GoodOnLicenceViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = GoodOnLicenceSerializer - queryset = GoodOnLicence.objects.exclude(licence__status=LicenceStatus.DRAFT) + queryset = GoodOnLicence.objects.filter( + licence__case__status__status__in=CaseStatusEnum.terminal_statuses() + ).exclude(licence__status=LicenceStatus.DRAFT) From f2656e6bd3d7ee2b1cdfff4e0c805cec70410133 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 14:13:55 +0000 Subject: [PATCH 40/89] Fix case status filters --- api/data_workspace/v2/views.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index cf4ecfe9c..6c92f408c 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -79,7 +79,7 @@ class ApplicationViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = ApplicationSerializer - queryset = StandardApplication.objects.filter(status__status__in=CaseStatusEnum.terminal_statuses()) + queryset = StandardApplication.objects.exclude(status__status=CaseStatusEnum.DRAFT) class CountryViewSet(viewsets.ReadOnlyModelViewSet): @@ -95,9 +95,8 @@ class DestinationViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = DestinationSerializer - queryset = PartyOnApplication.objects.filter( - deleted_at__isnull=True, - application__status__status__in=CaseStatusEnum.terminal_statuses(), + queryset = PartyOnApplication.objects.filter(deleted_at__isnull=True).exclude( + application__status__status=CaseStatusEnum.DRAFT ) @@ -106,7 +105,7 @@ class GoodViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = GoodSerializer - queryset = GoodOnApplication.objects.filter(application__status__status__in=CaseStatusEnum.terminal_statuses()) + queryset = GoodOnApplication.objects.exclude(application__status__status=CaseStatusEnum.DRAFT) class AssessmentViewSet(viewsets.ReadOnlyModelViewSet): @@ -124,6 +123,6 @@ class GoodOnLicenceViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = GoodOnLicenceSerializer - queryset = GoodOnLicence.objects.filter( - licence__case__status__status__in=CaseStatusEnum.terminal_statuses() - ).exclude(licence__status=LicenceStatus.DRAFT) + queryset = GoodOnLicence.objects.filter(licence__case__status__status=CaseStatusEnum.DRAFT).exclude( + licence__status=LicenceStatus.DRAFT + ) From 5f0a9b3c3ae52c1855b7cd0b933e87fe36549d2a Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 15:47:18 +0000 Subject: [PATCH 41/89] Optimise destinations endpoint --- api/data_workspace/v2/serializers.py | 1 - api/data_workspace/v2/views.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index ca1e73c6b..ad50c2788 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -86,7 +86,6 @@ class Meta: class DestinationSerializer(serializers.ModelSerializer): country_code = serializers.CharField(source="party.country.id") - application_id = serializers.CharField(source="application.id") type = serializers.CharField(source="party.type") class Meta: diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 6c92f408c..a3ee43de7 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -95,8 +95,10 @@ class DestinationViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = DestinationSerializer - queryset = PartyOnApplication.objects.filter(deleted_at__isnull=True).exclude( - application__status__status=CaseStatusEnum.DRAFT + queryset = ( + PartyOnApplication.objects.filter(deleted_at__isnull=True) + .exclude(application__status__status=CaseStatusEnum.DRAFT) + .select_related("party", "party__country") ) From c1094fef4bd9de8903cbcfb46d9e0f29e2408d1c Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 15:52:35 +0000 Subject: [PATCH 42/89] Optimise application endpoint --- api/data_workspace/v2/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index a3ee43de7..f70de3c04 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -79,7 +79,7 @@ class ApplicationViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = ApplicationSerializer - queryset = StandardApplication.objects.exclude(status__status=CaseStatusEnum.DRAFT) + queryset = StandardApplication.objects.exclude(status__status=CaseStatusEnum.DRAFT).select_related("case_type") class CountryViewSet(viewsets.ReadOnlyModelViewSet): From 05b26f786266e65deddd4b411026e88be225b448 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 16:00:50 +0000 Subject: [PATCH 43/89] Simplify assessment endpoint --- api/data_workspace/v2/views.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index f70de3c04..52117179e 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -115,9 +115,7 @@ class AssessmentViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = AssessmentSerializer - - def get_queryset(self): - return ControlListEntry.objects.annotate(good_id=F("goodonapplication__id")).exclude(good_id__isnull=True) + queryset = ControlListEntry.objects.annotate(good_id=F("goodonapplication__id")).exclude(good_id__isnull=True) class GoodOnLicenceViewSet(viewsets.ReadOnlyModelViewSet): From 0c4cdfd025018b18932d2d72197b42e37282ec39 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 12 Nov 2024 16:22:33 +0000 Subject: [PATCH 44/89] Fix filtering of good on licence --- api/data_workspace/v2/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 52117179e..85a67a276 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -123,6 +123,7 @@ class GoodOnLicenceViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = GoodOnLicenceSerializer - queryset = GoodOnLicence.objects.filter(licence__case__status__status=CaseStatusEnum.DRAFT).exclude( - licence__status=LicenceStatus.DRAFT + queryset = GoodOnLicence.objects.exclude( + licence__case__status__status=CaseStatusEnum.DRAFT, + licence__status=LicenceStatus.DRAFT, ) From 083e7a3f3c739ffcdf965c86998617d173cca536 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 13 Nov 2024 09:06:04 +0000 Subject: [PATCH 45/89] Add export type to applications table --- api/data_workspace/v2/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index ad50c2788..ad8a07398 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -68,6 +68,7 @@ class Meta: model = StandardApplication fields = ( "id", + "export_type", "reference_code", "licence_type", ) From 86e83bfbe7e3992df82266e3510347f8e928f6b7 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 13 Nov 2024 10:18:33 +0000 Subject: [PATCH 46/89] Update names for endpoints and serializers --- api/data_workspace/v2/serializers.py | 2 +- api/data_workspace/v2/urls.py | 10 +++++----- api/data_workspace/v2/views.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index ad8a07398..259c5ff96 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -108,7 +108,7 @@ class Meta: ) -class AssessmentSerializer(serializers.ModelSerializer): +class GoodRatingSerializer(serializers.ModelSerializer): good_id = serializers.UUIDField() class Meta: diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index aafcaa695..8d573c9ac 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -36,13 +36,13 @@ ) router_v2.register( - "assessments", - views.AssessmentViewSet, - basename="dw-assessments", + "goods-ratings", + views.GoodRatingViewSet, + basename="dw-goods-ratings", ) router_v2.register( - "goods-on-licence", + "goods-on-licences", views.GoodOnLicenceViewSet, - basename="dw-goods-on-licence", + basename="dw-goods-on-licences", ) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 85a67a276..a76e4ff27 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -17,11 +17,11 @@ from api.core.helpers import str_to_bool from api.data_workspace.v2.serializers import ( ApplicationSerializer, - AssessmentSerializer, CountrySerializer, DestinationSerializer, GoodOnLicenceSerializer, GoodSerializer, + GoodRatingSerializer, LicenceDecisionSerializer, LicenceDecisionType, ) @@ -110,11 +110,11 @@ class GoodViewSet(viewsets.ReadOnlyModelViewSet): queryset = GoodOnApplication.objects.exclude(application__status__status=CaseStatusEnum.DRAFT) -class AssessmentViewSet(viewsets.ReadOnlyModelViewSet): +class GoodRatingViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) - serializer_class = AssessmentSerializer + serializer_class = GoodRatingSerializer queryset = ControlListEntry.objects.annotate(good_id=F("goodonapplication__id")).exclude(good_id__isnull=True) From 72a0d89eb775745d83143512ac2769ed97098294 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 13 Nov 2024 13:14:18 +0000 Subject: [PATCH 47/89] Add sub type --- api/data_workspace/v2/serializers.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 259c5ff96..3002f626b 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from django.db.models import Q + from api.applications.models import ( GoodOnApplication, PartyOnApplication, @@ -63,16 +65,26 @@ def get_licence_id(self, case): class ApplicationSerializer(serializers.ModelSerializer): licence_type = serializers.CharField(source="case_type.reference") + sub_type = serializers.SerializerMethodField() class Meta: model = StandardApplication fields = ( "id", - "export_type", - "reference_code", "licence_type", + "reference_code", + "sub_type", ) + def get_sub_type(self, application): + if application.goods.filter(Q(is_good_incorporated=True) | Q(is_onward_incorporated=True)): + return "incorporation" + + if application.export_type: + return application.export_type + + raise Exception("Unknown sub-type") + class CountrySerializer(serializers.ModelSerializer): code = serializers.CharField(source="id") From 6dd7e3178c5919cd6132aa1c44c4c7baa1366d14 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 13 Nov 2024 13:43:43 +0000 Subject: [PATCH 48/89] Optimise checking for incorporation sub-type --- api/data_workspace/v2/serializers.py | 3 +-- api/data_workspace/v2/views.py | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 3002f626b..f722335b0 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,6 +1,5 @@ from rest_framework import serializers -from django.db.models import Q from api.applications.models import ( GoodOnApplication, @@ -77,7 +76,7 @@ class Meta: ) def get_sub_type(self, application): - if application.goods.filter(Q(is_good_incorporated=True) | Q(is_onward_incorporated=True)): + if any(g.is_good_incorporated or g.is_onward_incorporated for g in application.goods.all()): return "incorporation" if application.export_type: diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index a76e4ff27..d2b8b1ec6 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -79,7 +79,11 @@ class ApplicationViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = ApplicationSerializer - queryset = StandardApplication.objects.exclude(status__status=CaseStatusEnum.DRAFT).select_related("case_type") + queryset = ( + StandardApplication.objects.exclude(status__status=CaseStatusEnum.DRAFT) + .select_related("case_type") + .prefetch_related("goods") + ) class CountryViewSet(viewsets.ReadOnlyModelViewSet): From 8ab9442c96c5b287e7200039335ce133955d3f4f Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Wed, 13 Nov 2024 18:09:21 +0000 Subject: [PATCH 49/89] Add goods descriptions table --- api/data_workspace/v2/serializers.py | 13 +++++++++++++ api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 15 +++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index f722335b0..4b978c9f8 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -14,6 +14,7 @@ ) from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country +from api.staticdata.report_summaries.models import ReportSummary class LicenceDecisionSerializer(serializers.ModelSerializer): @@ -138,3 +139,15 @@ class Meta: "good_id", "licence_id", ) + + +class GoodDescriptionSerializer(serializers.ModelSerializer): + description = serializers.CharField(source="name") + good_id = serializers.UUIDField() + + class Meta: + model = ReportSummary + fields = ( + "description", + "good_id", + ) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 8d573c9ac..a47b3507f 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -46,3 +46,9 @@ views.GoodOnLicenceViewSet, basename="dw-goods-on-licences", ) + +router_v2.register( + "goods-descriptions", + views.GoodDescriptionViewSet, + basename="dw-goods-descriptions", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index d2b8b1ec6..07f8fae68 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -19,6 +19,7 @@ ApplicationSerializer, CountrySerializer, DestinationSerializer, + GoodDescriptionSerializer, GoodOnLicenceSerializer, GoodSerializer, GoodRatingSerializer, @@ -29,6 +30,7 @@ from api.licences.models import GoodOnLicence from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country +from api.staticdata.report_summaries.models import ReportSummary from api.staticdata.statuses.enums import CaseStatusEnum @@ -122,6 +124,19 @@ class GoodRatingViewSet(viewsets.ReadOnlyModelViewSet): queryset = ControlListEntry.objects.annotate(good_id=F("goodonapplication__id")).exclude(good_id__isnull=True) +class GoodDescriptionViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = GoodDescriptionSerializer + queryset = ( + ReportSummary.objects.select_related("prefix", "subject") + .prefetch_related("goods_on_application") + .exclude(goods_on_application__isnull=True) + .annotate(good_id=F("goods_on_application__id")) + ) + + class GoodOnLicenceViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination From 83e21f9cca3734684c816beeb8de3fbe4121da89 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 14 Nov 2024 13:53:27 +0000 Subject: [PATCH 50/89] Add licence refusal criteria --- api/data_workspace/v2/serializers.py | 9 ++++++++- api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 20 +++++++++++++++++++- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 4b978c9f8..225dbef3b 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -7,7 +7,9 @@ StandardApplication, ) from api.cases.enums import LicenceDecisionType -from api.cases.models import Case +from api.cases.models import ( + Case, +) from api.licences.models import ( GoodOnLicence, Licence, @@ -151,3 +153,8 @@ class Meta: "description", "good_id", ) + + +class LicenceRefusalCriteriaSerializer(serializers.Serializer): + criteria = serializers.CharField(source="denial_reasons__display_value") + licence_decision_id = serializers.UUIDField(source="case__licence_decisions__id") diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index a47b3507f..db9e73a33 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -52,3 +52,9 @@ views.GoodDescriptionViewSet, basename="dw-goods-descriptions", ) + +router_v2.register( + "licences-refusals-criteria", + views.LicenceRefusalCriteriaViewSet, + basename="dw-licences-refusals-criteria", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 736897211..61e3496cb 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -12,7 +12,10 @@ PartyOnApplication, StandardApplication, ) -from api.cases.models import Case +from api.cases.models import ( + Advice, + Case, +) from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool from api.data_workspace.v2.serializers import ( @@ -25,6 +28,7 @@ GoodRatingSerializer, LicenceDecisionSerializer, LicenceDecisionType, + LicenceRefusalCriteriaSerializer, ) from api.licences.enums import LicenceStatus from api.licences.models import GoodOnLicence @@ -146,3 +150,17 @@ class GoodOnLicenceViewSet(viewsets.ReadOnlyModelViewSet): licence__case__status__status=CaseStatusEnum.DRAFT, licence__status=LicenceStatus.DRAFT, ) + + +class LicenceRefusalCriteriaViewSet(viewsets.ReadOnlyModelViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + serializer_class = LicenceRefusalCriteriaSerializer + queryset = ( + Advice.objects.filter( + case__licence_decisions__decision="refused", team_id="58e77e47-42c8-499f-a58d-94f94541f8c6" + ) + .values("denial_reasons__display_value", "case__licence_decisions__id") + .distinct() + ) From 527a4083633703637e64f9e35c6d28f83aee6781 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 14 Nov 2024 15:37:13 +0000 Subject: [PATCH 51/89] Add licence refusal criteria endpoint --- api/data_workspace/v2/views.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 61e3496cb..c6e71ef66 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -159,8 +159,12 @@ class LicenceRefusalCriteriaViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = LicenceRefusalCriteriaSerializer queryset = ( Advice.objects.filter( - case__licence_decisions__decision="refused", team_id="58e77e47-42c8-499f-a58d-94f94541f8c6" + case__licence_decisions__decision="refused", + team_id="58e77e47-42c8-499f-a58d-94f94541f8c6", # Just care about LU advice ) - .values("denial_reasons__display_value", "case__licence_decisions__id") + .only("denial_reasons__display_value", "case__licence_decisions__id") + .exclude(denial_reasons__display_value__isnull=True) # This removes refusals without any criteria + .values_list("denial_reasons__display_value", "case__licence_decisions__id") + .order_by() # We need to remove the order_by to make sure the distinct works .distinct() ) From 6419cf325ff3fa81744f597b2ea22e08cc583094 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 14 Nov 2024 15:58:32 +0000 Subject: [PATCH 52/89] Fix licence decision criteria --- api/data_workspace/v2/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index c6e71ef66..099c83450 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -164,7 +164,7 @@ class LicenceRefusalCriteriaViewSet(viewsets.ReadOnlyModelViewSet): ) .only("denial_reasons__display_value", "case__licence_decisions__id") .exclude(denial_reasons__display_value__isnull=True) # This removes refusals without any criteria - .values_list("denial_reasons__display_value", "case__licence_decisions__id") + .values("denial_reasons__display_value", "case__licence_decisions__id") .order_by() # We need to remove the order_by to make sure the distinct works .distinct() ) From 16681ffefb6f6f5ee50694e141a709ec556e16ff Mon Sep 17 00:00:00 2001 From: Henry Cooksley Date: Thu, 14 Nov 2024 16:36:53 +0000 Subject: [PATCH 53/89] Add excluded_from_statistics_reason to LicenceDecision model --- ...decision_excluded_from_statistics_reason.py | 18 ++++++++++++++++++ api/cases/models.py | 1 + 2 files changed, 19 insertions(+) create mode 100644 api/cases/migrations/0069_licencedecision_excluded_from_statistics_reason.py diff --git a/api/cases/migrations/0069_licencedecision_excluded_from_statistics_reason.py b/api/cases/migrations/0069_licencedecision_excluded_from_statistics_reason.py new file mode 100644 index 000000000..c23ec6d54 --- /dev/null +++ b/api/cases/migrations/0069_licencedecision_excluded_from_statistics_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-14 16:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0068_populate_licence_decisions"), + ] + + operations = [ + migrations.AddField( + model_name="licencedecision", + name="excluded_from_statistics_reason", + field=models.TextField(blank=True, default=None, null=True), + ), + ] diff --git a/api/cases/models.py b/api/cases/models.py index c927d9214..76a1030e6 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -810,6 +810,7 @@ class LicenceDecision(TimestampableModel): licence = models.ForeignKey( "licences.Licence", on_delete=models.DO_NOTHING, related_name="licence_decisions", null=True, blank=True ) + excluded_from_statistics_reason = models.TextField(default=None, blank=True, null=True) def __str__(self): return f"{self.case.reference_code} - {self.decision} ({self.created_at})" From 45acc58590a48fb88e4e6df0e3aeb4eebee862ab Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 14 Nov 2024 17:18:38 +0000 Subject: [PATCH 54/89] Create a base class for DW view sets --- api/data_workspace/v2/views.py | 45 +++++++++------------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 099c83450..67e6efbc9 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -46,10 +46,13 @@ def paginate_queryset(self, queryset, request, view=None): return super().paginate_queryset(queryset, request, view) -class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): +class BaseViewSet(viewsets.ReadOnlyModelViewSet): authentication_classes = (DataWorkspaceOnlyAuthentication,) pagination_class = DisableableLimitOffsetPagination renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + + +class LicenceDecisionViewSet(BaseViewSet): serializer_class = LicenceDecisionSerializer def get_queryset(self): @@ -80,10 +83,7 @@ def get_queryset(self): return queryset -class ApplicationViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) +class ApplicationViewSet(BaseViewSet): serializer_class = ApplicationSerializer queryset = ( StandardApplication.objects.exclude(status__status=CaseStatusEnum.DRAFT) @@ -92,18 +92,12 @@ class ApplicationViewSet(viewsets.ReadOnlyModelViewSet): ) -class CountryViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) +class CountryViewSet(BaseViewSet): serializer_class = CountrySerializer queryset = Country.objects.all().order_by("id", "name") -class DestinationViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) +class DestinationViewSet(BaseViewSet): serializer_class = DestinationSerializer queryset = ( PartyOnApplication.objects.filter(deleted_at__isnull=True) @@ -112,26 +106,17 @@ class DestinationViewSet(viewsets.ReadOnlyModelViewSet): ) -class GoodViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) +class GoodViewSet(BaseViewSet): serializer_class = GoodSerializer queryset = GoodOnApplication.objects.exclude(application__status__status=CaseStatusEnum.DRAFT) -class GoodRatingViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) +class GoodRatingViewSet(BaseViewSet): serializer_class = GoodRatingSerializer queryset = ControlListEntry.objects.annotate(good_id=F("goodonapplication__id")).exclude(good_id__isnull=True) -class GoodDescriptionViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) +class GoodDescriptionViewSet(BaseViewSet): serializer_class = GoodDescriptionSerializer queryset = ( ReportSummary.objects.select_related("prefix", "subject") @@ -141,10 +126,7 @@ class GoodDescriptionViewSet(viewsets.ReadOnlyModelViewSet): ) -class GoodOnLicenceViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) +class GoodOnLicenceViewSet(BaseViewSet): serializer_class = GoodOnLicenceSerializer queryset = GoodOnLicence.objects.exclude( licence__case__status__status=CaseStatusEnum.DRAFT, @@ -152,10 +134,7 @@ class GoodOnLicenceViewSet(viewsets.ReadOnlyModelViewSet): ) -class LicenceRefusalCriteriaViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) +class LicenceRefusalCriteriaViewSet(BaseViewSet): serializer_class = LicenceRefusalCriteriaSerializer queryset = ( Advice.objects.filter( From 9d0fc8c7297a6a8d46930d6f572a5f860cedbe85 Mon Sep 17 00:00:00 2001 From: Henry Cooksley Date: Thu, 14 Nov 2024 17:18:53 +0000 Subject: [PATCH 55/89] Update queryset to exclude records with excluded_from_statistics_reason --- api/data_workspace/v2/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index c6e71ef66..16ca16a3a 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -58,6 +58,7 @@ def get_queryset(self): Case.objects.filter( licence_decisions__decision__in=[LicenceDecisionType.ISSUED, LicenceDecisionType.REFUSED], ) + .exclude(licence_decisions__excluded_from_statistics_reason__isnull=False) .annotate( unique_decisions=ArrayAgg("licence_decisions__decision", distinct=True), ) @@ -68,6 +69,7 @@ def get_queryset(self): Case.objects.filter( licence_decisions__decision__in=[LicenceDecisionType.REVOKED], ) + .exclude(licence_decisions__excluded_from_statistics_reason__isnull=False) .annotate( unique_decisions=ArrayAgg("licence_decisions__decision", distinct=True), ) From 64dd5f193752087ac3ebf438dc51211b2e066235 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 14 Nov 2024 09:51:18 +0000 Subject: [PATCH 56/89] Attach licence to historical Licence decision instances When Licence decisions are back populated the licence field is not set for the issued decisions. Add migration to populate this field. As per requirement we take the first licence in case where there are multiple licences (eg re-issued case) --- ...0069_attach_licence_to_licence_decision.py | 42 +++++++++++++++++++ ...0069_attach_licence_to_licence_decision.py | 20 +++++++++ 2 files changed, 62 insertions(+) create mode 100644 api/cases/migrations/0069_attach_licence_to_licence_decision.py create mode 100644 api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py diff --git a/api/cases/migrations/0069_attach_licence_to_licence_decision.py b/api/cases/migrations/0069_attach_licence_to_licence_decision.py new file mode 100644 index 000000000..754296339 --- /dev/null +++ b/api/cases/migrations/0069_attach_licence_to_licence_decision.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.16 on 2024-11-13 17:19 + +from django.db import migrations, transaction + +from api.cases.enums import LicenceDecisionType +from api.licences.enums import LicenceStatus + + +def get_licence_for_decision(licence_decision): + if licence_decision.decision != LicenceDecisionType.ISSUED: + return None + + licences = licence_decision.case.licences.exclude(status=LicenceStatus.DRAFT) + + return licences.earliest("created_at") + + +@transaction.atomic +def attach_licence_to_licence_decisions(apps, schema_editor): + LicenceDecision = apps.get_model("cases", "LicenceDecision") + + licence_decisions_to_update = [] + + for item in LicenceDecision.objects.filter(decision=LicenceDecisionType.ISSUED): + item.licence = get_licence_for_decision(item) + licence_decisions_to_update.append(item) + + LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["licence"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0068_populate_licence_decisions"), + ] + + operations = [ + migrations.RunPython( + attach_licence_to_licence_decisions, + migrations.RunPython.noop, + ), + ] diff --git a/api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py b/api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py new file mode 100644 index 000000000..7039be5c3 --- /dev/null +++ b/api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py @@ -0,0 +1,20 @@ +import pytest + +from api.cases.enums import LicenceDecisionType + +INITIAL_MIGRATION = "0068_populate_licence_decisions" +MIGRATION_UNDER_TEST = "0069_attach_licence_to_licence_decision" + + +@pytest.mark.django_db() +def test_attach_licence_to_licence_decisions(migrator): + + old_state = migrator.apply_initial_migration(("cases", INITIAL_MIGRATION)) + new_state = migrator.apply_tested_migration(("cases", MIGRATION_UNDER_TEST)) + + LicenceDecision = new_state.apps.get_model("cases", "LicenceDecision") + + assert all( + item.licence == item.case.licences.earliest("created_at") + for item in LicenceDecision.objects.filter(decision=LicenceDecisionType.ISSUED) + ) From 5ecf3ff34bd7ee5051468f90a38e8ea1d74935ed Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 15 Nov 2024 15:47:47 +0000 Subject: [PATCH 57/89] Update migration to populate with the actual licence at that time When a new licence decision is created we are now going to populate licence also. This was missing in the back populated data. Previously we added this migration to back populate but it was done to match with report data which would populate with the earliest licence for that decision but in reality that would not be the case. Update migration to populate with actual licence at that time. --- ...0069_attach_licence_to_licence_decision.py | 42 -------- ...0070_attach_licence_to_licence_decision.py | 96 +++++++++++++++++++ ...0069_attach_licence_to_licence_decision.py | 5 +- 3 files changed, 97 insertions(+), 46 deletions(-) delete mode 100644 api/cases/migrations/0069_attach_licence_to_licence_decision.py create mode 100644 api/cases/migrations/0070_attach_licence_to_licence_decision.py diff --git a/api/cases/migrations/0069_attach_licence_to_licence_decision.py b/api/cases/migrations/0069_attach_licence_to_licence_decision.py deleted file mode 100644 index 754296339..000000000 --- a/api/cases/migrations/0069_attach_licence_to_licence_decision.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 4.2.16 on 2024-11-13 17:19 - -from django.db import migrations, transaction - -from api.cases.enums import LicenceDecisionType -from api.licences.enums import LicenceStatus - - -def get_licence_for_decision(licence_decision): - if licence_decision.decision != LicenceDecisionType.ISSUED: - return None - - licences = licence_decision.case.licences.exclude(status=LicenceStatus.DRAFT) - - return licences.earliest("created_at") - - -@transaction.atomic -def attach_licence_to_licence_decisions(apps, schema_editor): - LicenceDecision = apps.get_model("cases", "LicenceDecision") - - licence_decisions_to_update = [] - - for item in LicenceDecision.objects.filter(decision=LicenceDecisionType.ISSUED): - item.licence = get_licence_for_decision(item) - licence_decisions_to_update.append(item) - - LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["licence"]) - - -class Migration(migrations.Migration): - - dependencies = [ - ("cases", "0068_populate_licence_decisions"), - ] - - operations = [ - migrations.RunPython( - attach_licence_to_licence_decisions, - migrations.RunPython.noop, - ), - ] diff --git a/api/cases/migrations/0070_attach_licence_to_licence_decision.py b/api/cases/migrations/0070_attach_licence_to_licence_decision.py new file mode 100644 index 000000000..0e6619775 --- /dev/null +++ b/api/cases/migrations/0070_attach_licence_to_licence_decision.py @@ -0,0 +1,96 @@ +# Generated by Django 4.2.16 on 2024-11-13 17:19 +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 + + +@transaction.atomic +def attach_licence_to_licence_decisions(apps, schema_editor): + LicenceDecision = apps.get_model("cases", "LicenceDecision") + + Audit = apps.get_model("audit_trail", "Audit") + GeneratedCaseDocument = apps.get_model("generated_documents", "GeneratedCaseDocument") + LicenceDecision = apps.get_model("cases", "LicenceDecision") + Licence = apps.get_model("licences", "Licence") + + licence_decisions_to_update = [] + + 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=AdviceType.APPROVE, + 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 != AdviceType.APPROVE: + continue + + decision = LicenceDecisionType.advice_type_to_decision(advice_type) + obj = LicenceDecision.objects.get( + case_id=str(audit_log.target_object_id), + decision=decision, + created_at=audit_log.created_at, + ) + obj.licence = Licence.objects.get(reference_code=audit_log.payload["licence_reference"]) + licence_decisions_to_update.append(obj) + + for document in document_qs: + obj = LicenceDecision.objects.get( + case_id=str(document.case_id), + decision=document.decision, + created_at=document.created_at, + ) + obj.licence = document.licence + licence_decisions_to_update.append(obj) + + LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["licence"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0069_licencedecision_excluded_from_statistics_reason"), + ] + + operations = [ + migrations.RunPython( + attach_licence_to_licence_decisions, + migrations.RunPython.noop, + ), + ] diff --git a/api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py b/api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py index 7039be5c3..404388b09 100644 --- a/api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py +++ b/api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py @@ -14,7 +14,4 @@ def test_attach_licence_to_licence_decisions(migrator): LicenceDecision = new_state.apps.get_model("cases", "LicenceDecision") - assert all( - item.licence == item.case.licences.earliest("created_at") - for item in LicenceDecision.objects.filter(decision=LicenceDecisionType.ISSUED) - ) + assert LicenceDecision.objects.filter(decision=LicenceDecisionType.ISSUED, licence__isnull=True).count() == 0 From e76c1e7b218e6ce99d9711ca7ed8e37257439967 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 18 Nov 2024 13:18:37 +0000 Subject: [PATCH 58/89] Add previous decision to Licence decision to save previous version of this decision A case can have multiple licence decisions (outcomes) and when reporting if the decision is same in all cases we report on the initial decision date. If the decision changes then we still report on the initial decision date but also subsequent changes. To achieve this effectively without adding too many fields a self referencing field 'previous_decision' is added to this model. This is populated such that it gets reset whenever there is a change in decision otherwise points to previous decision. When exporting data this is used to filter decisions we want to ignore and pick the correct decision date. --- .../0071_licencedecision_previous_decision.py | 79 +++++++++++++++++++ api/cases/models.py | 3 + 2 files changed, 82 insertions(+) create mode 100644 api/cases/migrations/0071_licencedecision_previous_decision.py diff --git a/api/cases/migrations/0071_licencedecision_previous_decision.py b/api/cases/migrations/0071_licencedecision_previous_decision.py new file mode 100644 index 000000000..d028cd138 --- /dev/null +++ b/api/cases/migrations/0071_licencedecision_previous_decision.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.16 on 2024-11-18 13:14 + +from django.db import migrations, models, transaction +from django.db.models import Count +import django.db.models.deletion + + +@transaction.atomic +def populate_previous_decisions(apps, schema_editor): + Case = apps.get_model("cases", "Case") + LicenceDecision = apps.get_model("cases", "LicenceDecision") + + licence_decisions_to_update = [] + + # We only need to update cases where there are multiple decisions + # In case of single decision then previous_decision field of licence decision is + # not set by default so nothing to update + case_qs = ( + Case.objects.all() + .annotate( + num_decisions=Count("licence_decisions"), + ) + .filter( + num_decisions__gt=1, + ) + ) + + # By default previous_decision is not set for all decisions. + # Previous_decision is reset whenever there is a change in decision otherwise it + # points to the decision in previous instance. + # + # When exporting we just filter all decisions where previous_decision is None which + # gives us the earliest decision time as required + # --------------------------------------------------- + # [ld1, ld2, ..., ldn] | [previous_decision field value] + # --------------------------------------------------- + # [ISSUED, ISSUED] | [None, ld1] + # [ISSUED, ISSUED, ISSUED] | [None, ld1, ld2] + # [ISSUED, REFUSED] | [None, None] + # [REFUSED, ISSUED] | [None, None] + # [ISSUED, REVOKED] | [None, None] + # [ISSUED, REVOKED, ISSUED] | [None, None, None] + # --------------------------------------------------- + # + for case in case_qs: + previous_decision = None + for item in case.licence_decisions.order_by("created_at"): + if previous_decision and item.decision == previous_decision.decision: + item.previous_decision = previous_decision + licence_decisions_to_update.append(item) + + previous_decision = item + + LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["previous_decision"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0070_attach_licence_to_licence_decision"), + ] + + operations = [ + migrations.AddField( + model_name="licencedecision", + name="previous_decision", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="previous_decisions", + to="cases.licencedecision", + ), + ), + migrations.RunPython( + populate_previous_decisions, + migrations.RunPython.noop, + ), + ] diff --git a/api/cases/models.py b/api/cases/models.py index 76a1030e6..27b4f5703 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -811,6 +811,9 @@ class LicenceDecision(TimestampableModel): "licences.Licence", on_delete=models.DO_NOTHING, related_name="licence_decisions", null=True, blank=True ) excluded_from_statistics_reason = models.TextField(default=None, blank=True, null=True) + previous_decision = models.ForeignKey( + "self", related_name="previous_decisions", default=None, null=True, on_delete=models.DO_NOTHING + ) def __str__(self): return f"{self.case.reference_code} - {self.decision} ({self.created_at})" From c2ba347003a067af97cd9d9adbff74c14b498e4d Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 18 Nov 2024 13:29:03 +0000 Subject: [PATCH 59/89] Use Licence decision model to export list of licence decisions --- api/data_workspace/v2/serializers.py | 47 +++++----------------------- api/data_workspace/v2/views.py | 46 ++++++--------------------- 2 files changed, 17 insertions(+), 76 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 225dbef3b..9b74d2e7b 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -6,28 +6,20 @@ PartyOnApplication, StandardApplication, ) -from api.cases.enums import LicenceDecisionType -from api.cases.models import ( - Case, -) -from api.licences.models import ( - GoodOnLicence, - Licence, -) +from api.cases.models import LicenceDecision +from api.licences.models import GoodOnLicence from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country from api.staticdata.report_summaries.models import ReportSummary class LicenceDecisionSerializer(serializers.ModelSerializer): - id = serializers.SerializerMethodField() - application_id = serializers.UUIDField(source="id") - decision = serializers.CharField() - decision_made_at = serializers.SerializerMethodField() + application_id = serializers.CharField(source="case.id") + decision_made_at = serializers.CharField(source="created_at") licence_id = serializers.SerializerMethodField() class Meta: - model = Case + model = LicenceDecision fields = ( "id", "application_id", @@ -36,33 +28,8 @@ class Meta: "licence_id", ) - def get_licence_decision(self, case): - if case.decision not in LicenceDecisionType.decisions(): - raise ValueError(f"Unknown decision type `{case.decision}`") - - return case.licence_decisions.filter( - decision=case.decision, - ).earliest("created_at") - - def get_id(self, case): - return self.get_licence_decision(case).pk - - def get_decision_made_at(self, case): - return self.get_licence_decision(case).created_at - - def get_licence_id(self, case): - licence_decision = self.get_licence_decision(case) - if licence_decision.decision != LicenceDecisionType.ISSUED: - return None - - licences = licence_decision.case.licences.exclude(status="draft").order_by("created_at") - try: - return licences.get().pk - except Licence.MultipleObjectsReturned: - pass - - licences = licences.filter(status="cancelled") - return licences.first().pk + def get_licence_id(self, licence_decision): + return licence_decision.licence.id if licence_decision.licence else "" class ApplicationSerializer(serializers.ModelSerializer): diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index b5670de5c..3f9c28d18 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -4,7 +4,6 @@ from rest_framework_csv.renderers import PaginatedCSVRenderer -from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import F from api.applications.models import ( @@ -12,10 +11,7 @@ PartyOnApplication, StandardApplication, ) -from api.cases.models import ( - Advice, - Case, -) +from api.cases.models import Advice, LicenceDecision from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool from api.data_workspace.v2.serializers import ( @@ -27,7 +23,6 @@ GoodSerializer, GoodRatingSerializer, LicenceDecisionSerializer, - LicenceDecisionType, LicenceRefusalCriteriaSerializer, ) from api.licences.enums import LicenceStatus @@ -52,37 +47,16 @@ class BaseViewSet(viewsets.ReadOnlyModelViewSet): renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) -class LicenceDecisionViewSet(BaseViewSet): +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], - ) - .exclude(licence_decisions__excluded_from_statistics_reason__isnull=False) - .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], - ) - .exclude(licence_decisions__excluded_from_statistics_reason__isnull=False) - .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 + queryset = ( + LicenceDecision.objects.filter(parent__isnull=True) + .exclude(excluded_from_statistics_reason__isnull=False) + .order_by("-case__reference_code") + ) class ApplicationViewSet(BaseViewSet): From e964b6162dd2eb2ab52c610aa4dc352339da85e6 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Mon, 18 Nov 2024 13:48:18 +0000 Subject: [PATCH 60/89] Add footnotes table --- api/data_workspace/v2/serializers.py | 7 +++++++ api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 16 +++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 225dbef3b..8de91f5d3 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -158,3 +158,10 @@ class Meta: class LicenceRefusalCriteriaSerializer(serializers.Serializer): criteria = serializers.CharField(source="denial_reasons__display_value") licence_decision_id = serializers.UUIDField(source="case__licence_decisions__id") + + +class FootnoteSerializer(serializers.Serializer): + footnote = serializers.CharField() + team_name = serializers.CharField(source="team__name") + application_id = serializers.CharField(source="case__pk") + type = serializers.CharField() diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index db9e73a33..63d3dcaa5 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -58,3 +58,9 @@ views.LicenceRefusalCriteriaViewSet, basename="dw-licences-refusals-criteria", ) + +router_v2.register( + "footnotes", + views.FootnoteViewSet, + basename="dw-footnotes", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index b5670de5c..c7918ea22 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -5,7 +5,10 @@ from rest_framework_csv.renderers import PaginatedCSVRenderer from django.contrib.postgres.aggregates import ArrayAgg -from django.db.models import F +from django.db.models import ( + F, + Q, +) from api.applications.models import ( GoodOnApplication, @@ -22,6 +25,7 @@ ApplicationSerializer, CountrySerializer, DestinationSerializer, + FootnoteSerializer, GoodDescriptionSerializer, GoodOnLicenceSerializer, GoodSerializer, @@ -149,3 +153,13 @@ class LicenceRefusalCriteriaViewSet(BaseViewSet): .order_by() # We need to remove the order_by to make sure the distinct works .distinct() ) + + +class FootnoteViewSet(BaseViewSet): + serializer_class = FootnoteSerializer + queryset = ( + Advice.objects.exclude(Q(footnote="") | Q(footnote__isnull=True)) + .values("footnote", "team__name", "case__pk", "type") + .order_by("case__pk") + .distinct() + ) From 31e3ef51b4b26a8e53f467e33816f04c31d65523 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 18 Nov 2024 13:49:28 +0000 Subject: [PATCH 61/89] Populate previous decision when creating new licence decisions --- api/cases/models.py | 14 ++++++++++++++ api/data_workspace/v2/views.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/api/cases/models.py b/api/cases/models.py index 27b4f5703..30c41afb3 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -386,10 +386,24 @@ def finalise(self, request, decisions): # NLR is not considered as licence decision if advice_type in [AdviceType.APPROVE, AdviceType.REFUSE]: + # In case of ISSUED check previous decision exists and accordingly + # populate previous_decision field + current_decision = LicenceDecisionType.advice_type_to_decision(advice_type) + previous_licence_decision = self.licence_decisions.order_by("created_at").last() + previous_decision = None + + if ( + previous_licence_decision + and current_decision == LicenceDecisionType.ISSUED + and previous_licence_decision.decision == LicenceDecisionType.ISSUED + ): + previous_decision = previous_licence_decision + LicenceDecision.objects.create( case=self, decision=LicenceDecisionType.advice_type_to_decision(advice_type), licence=licence, + previous_decision=previous_decision, ) licence_reference = licence.reference_code if licence and advice_type == AdviceType.APPROVE else "" diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 3f9c28d18..c321bda0f 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -53,7 +53,7 @@ class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) serializer_class = LicenceDecisionSerializer queryset = ( - LicenceDecision.objects.filter(parent__isnull=True) + LicenceDecision.objects.filter(previous_decision__isnull=True) .exclude(excluded_from_statistics_reason__isnull=False) .order_by("-case__reference_code") ) From aa7fc20c3143fa250206e3e92fb4dfa1ddf3555f Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Mon, 18 Nov 2024 16:36:27 +0000 Subject: [PATCH 62/89] Report latest licence details where there are multiple decisions If the decision has not changed and there are multiple licences then we report on the first creation date but the requirement is to provide details from the latest licence. --- api/data_workspace/v2/serializers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 9b74d2e7b..5baa9e530 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -6,6 +6,7 @@ PartyOnApplication, StandardApplication, ) +from api.cases.enums import LicenceDecisionType from api.cases.models import LicenceDecision from api.licences.models import GoodOnLicence from api.staticdata.control_list_entries.models import ControlListEntry @@ -29,7 +30,12 @@ class Meta: ) def get_licence_id(self, licence_decision): - return licence_decision.licence.id if licence_decision.licence else "" + if licence_decision.decision in [LicenceDecisionType.REFUSED, LicenceDecisionType.REVOKED]: + return "" + + latest_decision = licence_decision.case.licence_decisions.order_by("created_at").last() + + return latest_decision.licence.id if latest_decision.licence else "" class ApplicationSerializer(serializers.ModelSerializer): From 2525d250870acd8e424c22b44467b1dfc75c47f3 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 19 Nov 2024 12:42:03 +0000 Subject: [PATCH 63/89] Add quantity and unit to good serializer --- api/data_workspace/v2/serializers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 8de91f5d3..4f57e633a 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -118,6 +118,8 @@ class Meta: fields = ( "id", "application_id", + "quantity", + "unit", "value", ) From ba77c90d0933648fc62f2c99e2eaef218c77c6a1 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 19 Nov 2024 13:13:14 +0000 Subject: [PATCH 64/89] Add unit viewset --- api/data_workspace/v2/serializers.py | 5 +++++ api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 4f57e633a..66c47740c 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -167,3 +167,8 @@ class FootnoteSerializer(serializers.Serializer): team_name = serializers.CharField(source="team__name") application_id = serializers.CharField(source="case__pk") type = serializers.CharField() + + +class UnitSerializer(serializers.Serializer): + code = serializers.CharField() + description = serializers.CharField() diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 63d3dcaa5..0a4f98069 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -64,3 +64,9 @@ views.FootnoteViewSet, basename="dw-footnotes", ) + +router_v2.register( + "units", + views.UnitViewSet, + basename="dw-units", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index c7918ea22..4d93185e8 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -1,5 +1,6 @@ from rest_framework import viewsets from rest_framework.pagination import LimitOffsetPagination +from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework_csv.renderers import PaginatedCSVRenderer @@ -33,6 +34,7 @@ LicenceDecisionSerializer, LicenceDecisionType, LicenceRefusalCriteriaSerializer, + UnitSerializer, ) from api.licences.enums import LicenceStatus from api.licences.models import GoodOnLicence @@ -40,6 +42,7 @@ from api.staticdata.countries.models import Country from api.staticdata.report_summaries.models import ReportSummary from api.staticdata.statuses.enums import CaseStatusEnum +from api.staticdata.units.enums import Units class DisableableLimitOffsetPagination(LimitOffsetPagination): @@ -163,3 +166,18 @@ class FootnoteViewSet(BaseViewSet): .order_by("case__pk") .distinct() ) + + +class UnitViewSet(viewsets.ViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + + def list(self, request): + units = [{"code": code, "description": description} for code, description in Units.choices] + return Response(UnitSerializer(units, many=True).data) + + def retrieve(self, request, pk): + units = dict(Units.choices) + description = units[pk] + return Response(UnitSerializer({"code": pk, "description": description}).data) From 9b7e290edcb4179dc57dc501aec9c51a855a0936 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 19 Nov 2024 13:18:04 +0000 Subject: [PATCH 65/89] Add proper 404 for unfound unit --- api/data_workspace/v2/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 4d93185e8..b96f9812a 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -10,6 +10,7 @@ F, Q, ) +from django.http import Http404 from api.applications.models import ( GoodOnApplication, @@ -179,5 +180,8 @@ def list(self, request): def retrieve(self, request, pk): units = dict(Units.choices) - description = units[pk] + try: + description = units[pk] + except KeyError: + raise Http404() return Response(UnitSerializer({"code": pk, "description": description}).data) From 61b4ddc2fb0c32293999d240e2b6d0a0db6614ec Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 19 Nov 2024 14:21:23 +0000 Subject: [PATCH 66/89] Update the migration name in the test --- ...ion.py => test_0070_attach_licence_to_licence_decision.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename api/cases/migrations/tests/{test_0069_attach_licence_to_licence_decision.py => test_0070_attach_licence_to_licence_decision.py} (78%) diff --git a/api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py b/api/cases/migrations/tests/test_0070_attach_licence_to_licence_decision.py similarity index 78% rename from api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py rename to api/cases/migrations/tests/test_0070_attach_licence_to_licence_decision.py index 404388b09..82b4b0077 100644 --- a/api/cases/migrations/tests/test_0069_attach_licence_to_licence_decision.py +++ b/api/cases/migrations/tests/test_0070_attach_licence_to_licence_decision.py @@ -2,8 +2,8 @@ from api.cases.enums import LicenceDecisionType -INITIAL_MIGRATION = "0068_populate_licence_decisions" -MIGRATION_UNDER_TEST = "0069_attach_licence_to_licence_decision" +INITIAL_MIGRATION = "0069_licencedecision_excluded_from_statistics_reason" +MIGRATION_UNDER_TEST = "0070_attach_licence_to_licence_decision" @pytest.mark.django_db() From 1949af2435df4b0f0207977bcc5868baf61b3d92 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 19 Nov 2024 15:08:18 +0000 Subject: [PATCH 67/89] Optimise licence decisions endpoint --- api/data_workspace/v2/serializers.py | 10 +++++++--- api/data_workspace/v2/views.py | 12 +++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index ea9b1585f..dfa51dab9 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -31,11 +31,15 @@ class Meta: def get_licence_id(self, licence_decision): if licence_decision.decision in [LicenceDecisionType.REFUSED, LicenceDecisionType.REVOKED]: - return "" + return None - latest_decision = licence_decision.case.licence_decisions.order_by("created_at").last() + licence_decisions = sorted(licence_decision.case.licence_decisions.all(), key=lambda ld: ld.created_at) + licence_decision = licence_decisions[-1] - return latest_decision.licence.id if latest_decision.licence else "" + if not licence_decision.licence: + return None + + return licence_decision.licence.pk class ApplicationSerializer(serializers.ModelSerializer): diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index fb39f4b6d..cb48ba5a4 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -16,7 +16,10 @@ PartyOnApplication, StandardApplication, ) -from api.cases.models import Advice, LicenceDecision +from api.cases.models import ( + Advice, + LicenceDecision, +) from api.core.authentication import DataWorkspaceOnlyAuthentication from api.core.helpers import str_to_bool from api.data_workspace.v2.serializers import ( @@ -55,14 +58,13 @@ class BaseViewSet(viewsets.ReadOnlyModelViewSet): renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) -class LicenceDecisionViewSet(viewsets.ReadOnlyModelViewSet): - authentication_classes = (DataWorkspaceOnlyAuthentication,) - pagination_class = DisableableLimitOffsetPagination - renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) +class LicenceDecisionViewSet(BaseViewSet): serializer_class = LicenceDecisionSerializer queryset = ( LicenceDecision.objects.filter(previous_decision__isnull=True) .exclude(excluded_from_statistics_reason__isnull=False) + .prefetch_related("case__licence_decisions", "case__licence_decisions__licence") + .select_related("case") .order_by("-case__reference_code") ) From 4de7eff25ef3530cefe2f0d3feabef732850ee6c Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 19 Nov 2024 15:13:48 +0000 Subject: [PATCH 68/89] Return licence id for revoked licence decision --- api/data_workspace/v2/serializers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index dfa51dab9..2ec99be65 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -30,10 +30,14 @@ class Meta: ) def get_licence_id(self, licence_decision): - if licence_decision.decision in [LicenceDecisionType.REFUSED, LicenceDecisionType.REVOKED]: + if licence_decision.decision in [LicenceDecisionType.REFUSED]: return None - licence_decisions = sorted(licence_decision.case.licence_decisions.all(), key=lambda ld: ld.created_at) + licence_decisions = licence_decision.case.licence_decisions.all() + licence_decisions = [ + ld for ld in licence_decision.case.licence_decisions.all() if ld.decision == LicenceDecisionType.ISSUED + ] + licence_decisions = sorted(licence_decisions, key=lambda ld: ld.created_at) licence_decision = licence_decisions[-1] if not licence_decision.licence: From 330a17d0fc46d072eda4b122b1b5809eb3e2baf2 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 19 Nov 2024 15:28:52 +0000 Subject: [PATCH 69/89] Add issued_on_appeal --- api/data_workspace/v2/serializers.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 2ec99be65..6d2bc1fe8 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -16,6 +16,7 @@ class LicenceDecisionSerializer(serializers.ModelSerializer): application_id = serializers.CharField(source="case.id") + decision = serializers.SerializerMethodField() decision_made_at = serializers.CharField(source="created_at") licence_id = serializers.SerializerMethodField() @@ -29,6 +30,28 @@ class Meta: "licence_id", ) + def get_decision(self, licence_decision): + if licence_decision.decision != LicenceDecisionType.ISSUED: + return licence_decision.decision + + all_issued = all( + ld.decision == LicenceDecisionType.ISSUED + for ld in licence_decision.case.licence_decisions.all() + if not ld.excluded_from_statistics_reason + ) + if all_issued: + return licence_decision.decision + + licence_decisions = sorted( + [ld for ld in licence_decision.case.licence_decisions.all()], key=lambda ld: ld.created_at + ) + presumed_licence_decision = licence_decisions[-1] + + if presumed_licence_decision.decision == LicenceDecisionType.ISSUED: + return "issued_on_appeal" + + return licence_decision.decision + def get_licence_id(self, licence_decision): if licence_decision.decision in [LicenceDecisionType.REFUSED]: return None From 3dc265cd3e8fd3e29cf2e94694b57e996e09c3e1 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 19 Nov 2024 16:23:54 +0000 Subject: [PATCH 70/89] Add issued_on_appeal decision type --- api/cases/enums.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/cases/enums.py b/api/cases/enums.py index aeee7b6b0..361ad0a1b 100644 --- a/api/cases/enums.py +++ b/api/cases/enums.py @@ -417,11 +417,13 @@ class LicenceDecisionType: ISSUED = "issued" REFUSED = "refused" REVOKED = "revoked" + ISSUED_ON_APPEAL = "issued_on_appeal" choices = [ (ISSUED, "issued"), (REFUSED, "refused"), (REVOKED, "revoked"), + (ISSUED_ON_APPEAL, "issued_on_appeal"), ] decision_map = { @@ -439,6 +441,7 @@ def templates(cls): cls.ISSUED: SIEL_LICENCE_TEMPLATE_ID, cls.REFUSED: SIEL_REFUSAL_TEMPLATE_ID, cls.REVOKED: None, + cls.ISSUED_ON_APPEAL: None, } @classmethod From 3f505c3c477b1189b508b8fa25a2e962e6fea6a1 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 19 Nov 2024 16:27:41 +0000 Subject: [PATCH 71/89] Add migration to populate issued on appeal decision For those licences that are issued on appeal we want the decision to be updated as 'issued_on_appeal' --- ...lter_decision_populate_issued_on_appeal.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 api/cases/migrations/0072_alter_decision_populate_issued_on_appeal.py diff --git a/api/cases/migrations/0072_alter_decision_populate_issued_on_appeal.py b/api/cases/migrations/0072_alter_decision_populate_issued_on_appeal.py new file mode 100644 index 000000000..f5e601540 --- /dev/null +++ b/api/cases/migrations/0072_alter_decision_populate_issued_on_appeal.py @@ -0,0 +1,62 @@ +# Generated by Django 4.2.16 on 2024-11-19 16:52 + +from django.db import migrations, models, transaction + +from django.db.models import Count + +from api.cases.enums import LicenceDecisionType + + +@transaction.atomic +def populate_issued_on_appeal(apps, schema_editor): + Case = apps.get_model("cases", "Case") + LicenceDecision = apps.get_model("cases", "LicenceDecision") + + licence_decisions_to_update = [] + + # Filter cases that have two decisions because for appeals + # the first decision will be refused which is issued on appeal later + case_qs = ( + Case.objects.all() + .annotate( + num_decisions=Count("licence_decisions"), + ) + .filter( + num_decisions=2, + ) + ) + + for case in case_qs: + prev, current = case.licence_decisions.all() + if prev.decision == LicenceDecisionType.REFUSED and current.decision == LicenceDecisionType.ISSUED: + current.decision = LicenceDecisionType.ISSUED_ON_APPEAL + licence_decisions_to_update.append(current) + + LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["decision"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0071_licencedecision_previous_decision"), + ] + + operations = [ + migrations.AlterField( + model_name="licencedecision", + name="decision", + field=models.CharField( + choices=[ + ("issued", "issued"), + ("refused", "refused"), + ("revoked", "revoked"), + ("issued_on_appeal", "issued_on_appeal"), + ], + max_length=50, + ), + ), + migrations.RunPython( + populate_issued_on_appeal, + migrations.RunPython.noop, + ), + ] From 88a88e0552e9b9e5a3c9b5d152c839a706b067c7 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 19 Nov 2024 16:34:42 +0000 Subject: [PATCH 72/89] Record Licence decision as issued_on_appeal for successful appeals --- api/cases/models.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/cases/models.py b/api/cases/models.py index 30c41afb3..62b489fe3 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -399,9 +399,15 @@ def finalise(self, request, decisions): ): previous_decision = previous_licence_decision + # In case if it is being issued after an appeal then we want to reflect that in the decision + if current_decision == LicenceDecisionType.ISSUED and self.licence_decisions.count() == 2: + prev, current = self.licence_decisions.all() + if prev.decision == LicenceDecisionType.REFUSED and current.decision == LicenceDecisionType.ISSUED: + current_decision = LicenceDecisionType.ISSUED_ON_APPEAL + LicenceDecision.objects.create( case=self, - decision=LicenceDecisionType.advice_type_to_decision(advice_type), + decision=current_decision, licence=licence, previous_decision=previous_decision, ) From 211bc3c39c1027a7b265f2b08c7e63c6bae60a23 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 19 Nov 2024 18:52:22 +0000 Subject: [PATCH 73/89] Add statuses table --- api/data_workspace/v2/serializers.py | 10 ++++++++++ api/data_workspace/v2/urls.py | 6 ++++++ api/data_workspace/v2/views.py | 19 +++++++++++++++++++ api/staticdata/statuses/enums.py | 14 ++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 6d2bc1fe8..79e69707f 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -12,6 +12,7 @@ from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country from api.staticdata.report_summaries.models import ReportSummary +from api.staticdata.statuses.enums import CaseStatusEnum class LicenceDecisionSerializer(serializers.ModelSerializer): @@ -176,3 +177,12 @@ class FootnoteSerializer(serializers.Serializer): class UnitSerializer(serializers.Serializer): code = serializers.CharField() description = serializers.CharField() + + +class StatusSerializer(serializers.Serializer): + status = serializers.CharField() + name = serializers.CharField() + is_closed = serializers.SerializerMethodField() + + def get_is_closed(self, status): + return CaseStatusEnum.is_closed(status["status"]) diff --git a/api/data_workspace/v2/urls.py b/api/data_workspace/v2/urls.py index 0a4f98069..f8c3c71e1 100644 --- a/api/data_workspace/v2/urls.py +++ b/api/data_workspace/v2/urls.py @@ -70,3 +70,9 @@ views.UnitViewSet, basename="dw-units", ) + +router_v2.register( + "statuses", + views.StatusViewSet, + basename="dw-statuses", +) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index cb48ba5a4..3b17d7b18 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -33,6 +33,7 @@ GoodRatingSerializer, LicenceDecisionSerializer, LicenceRefusalCriteriaSerializer, + StatusSerializer, UnitSerializer, ) from api.licences.enums import LicenceStatus @@ -161,3 +162,21 @@ def retrieve(self, request, pk): except KeyError: raise Http404() return Response(UnitSerializer({"code": pk, "description": description}).data) + + +class StatusViewSet(viewsets.ViewSet): + authentication_classes = (DataWorkspaceOnlyAuthentication,) + pagination_class = DisableableLimitOffsetPagination + renderer_classes = tuple(api_settings.DEFAULT_RENDERER_CLASSES) + (PaginatedCSVRenderer,) + + def list(self, request): + statuses = [{"status": status, "name": name} for status, name in CaseStatusEnum.choices] + return Response(StatusSerializer(statuses, many=True).data) + + def retrieve(self, request, pk): + statuses = dict(CaseStatusEnum.choices) + try: + name = statuses[pk] + except KeyError: + raise Http404() + return Response(StatusSerializer({"status": pk, "name": name}).data) diff --git a/api/staticdata/statuses/enums.py b/api/staticdata/statuses/enums.py index 4fb7f5af9..44d088b32 100644 --- a/api/staticdata/statuses/enums.py +++ b/api/staticdata/statuses/enums.py @@ -60,6 +60,16 @@ class CaseStatusEnum: SUPERSEDED_BY_EXPORTER_EDIT, ] + _closed_statuses = [ + CLOSED, + DEREGISTERED, + FINALISED, + REGISTERED, + REVOKED, + SURRENDERED, + WITHDRAWN, + ] + # Cases with these statuses can be operated upon by caseworkers _caseworker_operable_statuses = [ APPEAL_FINAL_REVIEW, @@ -225,6 +235,10 @@ def is_editable(cls, status): def is_terminal(cls, status): return status in cls._terminal_statuses + @classmethod + def is_closed(cls, status): + return status in cls._closed_statuses + @classmethod def is_system_status(cls, status): return status in cls._system_status From e54b3c1fa1588e5ccee873e8dff50046e699a6fb Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 19 Nov 2024 19:11:09 +0000 Subject: [PATCH 74/89] Add status to application --- api/data_workspace/v2/serializers.py | 2 ++ api/data_workspace/v2/views.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 79e69707f..0676004a2 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -73,6 +73,7 @@ def get_licence_id(self, licence_decision): class ApplicationSerializer(serializers.ModelSerializer): licence_type = serializers.CharField(source="case_type.reference") sub_type = serializers.SerializerMethodField() + status = serializers.CharField(source="status.status") class Meta: model = StandardApplication @@ -81,6 +82,7 @@ class Meta: "licence_type", "reference_code", "sub_type", + "status", ) def get_sub_type(self, application): diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 3b17d7b18..8a3ab2f21 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -74,7 +74,7 @@ class ApplicationViewSet(BaseViewSet): serializer_class = ApplicationSerializer queryset = ( StandardApplication.objects.exclude(status__status=CaseStatusEnum.DRAFT) - .select_related("case_type") + .select_related("case_type", "status") .prefetch_related("goods") ) From f4921625074cb7d7b938e256d5852fbc0224e53e Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Tue, 19 Nov 2024 19:15:26 +0000 Subject: [PATCH 75/89] Add sla days to application --- api/data_workspace/v2/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 0676004a2..34326f69f 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -83,6 +83,7 @@ class Meta: "reference_code", "sub_type", "status", + "sla_days", ) def get_sub_type(self, application): From 610c533856e6bb710dfddfca5b28b2d6e9c806bf Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Tue, 19 Nov 2024 21:03:49 +0000 Subject: [PATCH 76/89] Fix issue in handling appeal case and record the decision correctly --- api/cases/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/cases/models.py b/api/cases/models.py index 62b489fe3..0e4f956e0 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -400,9 +400,9 @@ def finalise(self, request, decisions): previous_decision = previous_licence_decision # In case if it is being issued after an appeal then we want to reflect that in the decision - if current_decision == LicenceDecisionType.ISSUED and self.licence_decisions.count() == 2: - prev, current = self.licence_decisions.all() - if prev.decision == LicenceDecisionType.REFUSED and current.decision == LicenceDecisionType.ISSUED: + if current_decision == LicenceDecisionType.ISSUED and self.licence_decisions.count() == 1: + prev = self.licence_decisions.last() + if prev.decision == LicenceDecisionType.REFUSED: current_decision = LicenceDecisionType.ISSUED_ON_APPEAL LicenceDecision.objects.create( From 06480caaef25d0803297c786ced45db8b0132308 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Wed, 20 Nov 2024 16:42:02 +0000 Subject: [PATCH 77/89] Add bdd tests for issued_on_appeal licence decision case --- api/data_workspace/v2/tests/bdd/conftest.py | 11 +- .../bdd/licences/test_licence_decisions.py | 102 ++++++++++++++++-- .../bdd/scenarios/licence_decisions.feature | 39 +++++++ 3 files changed, 141 insertions(+), 11 deletions(-) diff --git a/api/data_workspace/v2/tests/bdd/conftest.py b/api/data_workspace/v2/tests/bdd/conftest.py index 2e255702d..a8fcb88ba 100644 --- a/api/data_workspace/v2/tests/bdd/conftest.py +++ b/api/data_workspace/v2/tests/bdd/conftest.py @@ -76,7 +76,11 @@ def gov_user_permissions(): 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] + [ + GovPermissions.MANAGE_LICENCE_FINAL_ADVICE.name, + GovPermissions.MANAGE_LICENCE_DURATION.name, + GovPermissions.REOPEN_CLOSED_CASES.name, + ] ) gov_user.save() return gov_user @@ -99,6 +103,11 @@ def gov_headers(gov_user): return {"HTTP_GOV_USER_TOKEN": user_to_token(gov_user.baseuser_ptr)} +@pytest.fixture() +def lu_case_officer_headers(lu_case_officer): + return {"HTTP_GOV_USER_TOKEN": user_to_token(lu_case_officer.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)} 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 index 231eb96d3..b34ecd393 100644 --- a/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py +++ b/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py @@ -10,11 +10,13 @@ ) from unittest import mock -from api.cases.enums import AdviceType, LicenceDecisionType +from api.applications.models import StandardApplication +from api.cases.enums import AdviceLevel, 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 +from api.staticdata.statuses.models import CaseStatus scenarios("../scenarios/licence_decisions.feature") @@ -74,20 +76,21 @@ def refused_case_included_in_extract(refused_case, unpage_data, licence_decision @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 + assert standard_case_with_final_advice.status == CaseStatus.objects.get(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 + assert standard_case_with_refused_advice.status == CaseStatus.objects.get(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): + application = StandardApplication.objects.get(id=case_with_final_advice.id) data = {"action": AdviceType.APPROVE, "duration": 24} - for good_on_app in case_with_final_advice.goods.all(): + for good_on_app in application.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) @@ -131,22 +134,25 @@ def case_officer_issues_licence(client, gov_headers, case_with_final_advice): 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.status == CaseStatus.objects.get(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 licence.status in [LicenceStatus.ISSUED, LicenceStatus.REINSTATED] + return licence + + +@then("a licence decision with an issued decision is created") +def licence_decision_issued_created(issued_licence): assert LicenceDecision.objects.filter( - case=case_with_final_advice, + case=issued_licence.case, 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): @@ -184,7 +190,7 @@ def licence_for_case_is_refused(client, gov_headers, case_with_refused_advice): 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.status == CaseStatus.objects.get(status=CaseStatusEnum.FINALISED) assert case_with_refused_advice.sub_status.name == "Refused" assert LicenceDecision.objects.filter( @@ -195,6 +201,14 @@ def licence_for_case_is_refused(client, gov_headers, case_with_refused_advice): return case_with_refused_advice +@then("a licence decision with refused decision is created") +def licence_decision_refused_created(refused_case): + assert LicenceDecision.objects.filter( + case=refused_case, + decision=LicenceDecisionType.REFUSED, + ).exists() + + @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)}) @@ -221,3 +235,71 @@ def revoked_licence_decision_included_in_extract(licence_decisions, revoked_lice all_revoked_licences = [item for item in licence_decisions if item["decision"] == "revoked"] assert str(revoked_licence.case.id) in [item["application_id"] for item in all_revoked_licences] + + +def case_reopen_prepare_to_finalise(client, lu_case_officer_headers, case): + url = reverse( + "caseworker_applications:change_status", + kwargs={ + "pk": str(case.pk), + }, + ) + response = client.post( + url, {"status": CaseStatusEnum.REOPENED_FOR_CHANGES}, content_type="application/json", **lu_case_officer_headers + ) + assert response.status_code == 200 + case.refresh_from_db() + assert case.status == CaseStatus.objects.get(status=CaseStatusEnum.REOPENED_FOR_CHANGES) + + response = client.post( + url, {"status": CaseStatusEnum.UNDER_FINAL_REVIEW}, content_type="application/json", **lu_case_officer_headers + ) + assert response.status_code == 200 + case.refresh_from_db() + assert case.status == CaseStatus.objects.get(status=CaseStatusEnum.UNDER_FINAL_REVIEW) + + return case + + +@when("an appeal is successful and case is ready to be finalised", target_fixture="case_with_final_advice") +def case_ready_to_be_finalised_after_an_appeal(client, lu_case_officer_headers, refused_case): + # Appeal handling is a manual process and we need to remove previous final advice + # before the case can be finalised again + assert refused_case.status.status == CaseStatusEnum.FINALISED + + refused_case.advice.filter(level=AdviceLevel.FINAL).update( + type=AdviceType.APPROVE, + text="issued on appeal", + ) + + successful_appeal_case = case_reopen_prepare_to_finalise(client, lu_case_officer_headers, refused_case) + + return successful_appeal_case + + +@when("a licence needs amending and case is ready to be finalised", target_fixture="case_with_final_advice") +def case_ready_to_be_finalised_after_amending_licence(client, lu_case_officer_headers, issued_licence): + case_with_final_advice = issued_licence.case + assert case_with_final_advice.status == CaseStatus.objects.get(status=CaseStatusEnum.FINALISED) + + case_with_final_advice.advice.filter(level=AdviceLevel.FINAL).update( + type=AdviceType.APPROVE, + text="re-issuing licence", + ) + + case_with_final_advice = case_reopen_prepare_to_finalise(client, lu_case_officer_headers, case_with_final_advice) + + return case_with_final_advice + + +@then("a licence decision with an issued_on_appeal decision is created") +def licence_decision_issued_on_appeal_created(issued_licence): + assert LicenceDecision.objects.filter( + case=issued_licence.case, + decision=LicenceDecisionType.REFUSED, + ).exists() + + assert LicenceDecision.objects.filter( + case=issued_licence.case, + decision=LicenceDecisionType.ISSUED_ON_APPEAL, + ).exists() diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature b/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature index a283968a3..7111836b2 100644 --- a/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature +++ b/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature @@ -14,6 +14,7 @@ Scenario: Issued licence decision is created when licence is issued When the licence for the case is approved And case officer generates licence documents And case officer issues licence for this case + Then a licence decision with an issued decision is created When I fetch all licence decisions Then I see issued licence is included in the extract @@ -22,6 +23,7 @@ Scenario: Refused licence decision is created when licence is refused When the licence for the case is refused And case officer generates refusal documents And case officer refuses licence for this case + Then a licence decision with refused decision is created When I fetch all licence decisions Then I see refused case is included in the extract @@ -34,3 +36,40 @@ Scenario: Revoked licence decision is created when licence is revoked When case officer revokes issued licence And I fetch all licence decisions Then I see revoked licence is included in the extract + +Scenario: Licence issued after an appeal is recorded as issued_on_appeal + 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 + When an appeal is successful and case is ready to be finalised + And the licence for the case is approved + And case officer generates licence documents + And case officer issues licence for this case + Then a licence decision with an issued_on_appeal decision is created + When I fetch all licence decisions + Then I see issued licence is included in the extract + +Scenario: Licence issued after an appeal and re-issued again + 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 + When an appeal is successful and case is ready to be finalised + And the licence for the case is approved + And case officer generates licence documents + And case officer issues licence for this case + Then a licence decision with an issued_on_appeal decision is created + When I fetch all licence decisions + Then I see issued licence is included in the extract + When a licence needs amending and case is ready to be finalised + And the licence for the case is approved + And case officer generates licence documents + And case officer issues licence for this case + Then a licence decision with an issued decision is created + When I fetch all licence decisions + Then I see issued licence is included in the extract From d0b2b27a7ac409f2e7a1b28deefc3e6f850de133 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 21 Nov 2024 08:23:57 +0000 Subject: [PATCH 78/89] Change sla_days to processing_time --- api/data_workspace/v2/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 34326f69f..73430727c 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -74,6 +74,7 @@ class ApplicationSerializer(serializers.ModelSerializer): licence_type = serializers.CharField(source="case_type.reference") sub_type = serializers.SerializerMethodField() status = serializers.CharField(source="status.status") + processing_time = serializers.IntegerField(source="sla_days") class Meta: model = StandardApplication @@ -83,7 +84,7 @@ class Meta: "reference_code", "sub_type", "status", - "sla_days", + "processing_time", ) def get_sub_type(self, application): From 07e7b38f7d74d43f92246810b2e1c1d10153f628 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 21 Nov 2024 09:12:29 +0000 Subject: [PATCH 79/89] Add first closed at --- api/data_workspace/v2/serializers.py | 32 +++++++++++++++++++++++++++- api/data_workspace/v2/views.py | 2 +- api/staticdata/statuses/enums.py | 4 ++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 73430727c..c6e785a03 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,11 +1,14 @@ -from rest_framework import serializers +import itertools +from rest_framework import serializers from api.applications.models import ( GoodOnApplication, PartyOnApplication, StandardApplication, ) +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit from api.cases.enums import LicenceDecisionType from api.cases.models import LicenceDecision from api.licences.models import GoodOnLicence @@ -75,6 +78,7 @@ class ApplicationSerializer(serializers.ModelSerializer): sub_type = serializers.SerializerMethodField() status = serializers.CharField(source="status.status") processing_time = serializers.IntegerField(source="sla_days") + first_closed_at = serializers.SerializerMethodField() class Meta: model = StandardApplication @@ -85,6 +89,7 @@ class Meta: "sub_type", "status", "processing_time", + "first_closed_at", ) def get_sub_type(self, application): @@ -96,6 +101,31 @@ def get_sub_type(self, application): raise Exception("Unknown sub-type") + def get_first_closed_at(self, application): + if application.licence_decisions.exists(): + earliest = None + for licence_decision in application.licence_decisions.all(): + if not earliest: + earliest = licence_decision.created_at + continue + if licence_decision.created_at < earliest: + earliest = licence_decision.created_at + return earliest + + status_map = dict(CaseStatusEnum.choices) + closed_statuses = itertools.chain.from_iterable( + (status, status_map[status]) for status in CaseStatusEnum.closed_statuses() + ) + closed_status_updates = Audit.objects.filter( + target_object_id=application.pk, + verb=AuditType.UPDATED_STATUS, + payload__status__new__in=closed_statuses, + ) + if closed_status_updates.exists(): + return closed_status_updates.earliest("created_at").created_at + + return None + class CountrySerializer(serializers.ModelSerializer): code = serializers.CharField(source="id") diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 8a3ab2f21..89a6820e8 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -75,7 +75,7 @@ class ApplicationViewSet(BaseViewSet): queryset = ( StandardApplication.objects.exclude(status__status=CaseStatusEnum.DRAFT) .select_related("case_type", "status") - .prefetch_related("goods") + .prefetch_related("goods", "licence_decisions") ) diff --git a/api/staticdata/statuses/enums.py b/api/staticdata/statuses/enums.py index 44d088b32..a8cfe7011 100644 --- a/api/staticdata/statuses/enums.py +++ b/api/staticdata/statuses/enums.py @@ -283,6 +283,10 @@ def can_invoke_major_edit(cls, status): def terminal_statuses(cls): return cls._terminal_statuses + @classmethod + def closed_statuses(cls): + return cls._closed_statuses + @classmethod def as_list(cls): from api.staticdata.statuses.models import CaseStatus From 1c937d45be1e384958a27b2a9af267d3a0da3186 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 21 Nov 2024 10:04:38 +0000 Subject: [PATCH 80/89] Optimise getting first closed status --- api/data_workspace/v2/serializers.py | 18 ++---------- api/data_workspace/v2/views.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index c6e785a03..1effda758 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -1,5 +1,3 @@ -import itertools - from rest_framework import serializers from api.applications.models import ( @@ -7,8 +5,6 @@ PartyOnApplication, StandardApplication, ) -from api.audit_trail.enums import AuditType -from api.audit_trail.models import Audit from api.cases.enums import LicenceDecisionType from api.cases.models import LicenceDecision from api.licences.models import GoodOnLicence @@ -112,17 +108,9 @@ def get_first_closed_at(self, application): earliest = licence_decision.created_at return earliest - status_map = dict(CaseStatusEnum.choices) - closed_statuses = itertools.chain.from_iterable( - (status, status_map[status]) for status in CaseStatusEnum.closed_statuses() - ) - closed_status_updates = Audit.objects.filter( - target_object_id=application.pk, - verb=AuditType.UPDATED_STATUS, - payload__status__new__in=closed_statuses, - ) - if closed_status_updates.exists(): - return closed_status_updates.earliest("created_at").created_at + first_closed_status = self.context["first_closed_statuses"].get(str(application.pk)) + if first_closed_status: + return first_closed_status return None diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 89a6820e8..9403a86e1 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -1,3 +1,5 @@ +import itertools + from rest_framework import viewsets from rest_framework.pagination import LimitOffsetPagination from rest_framework.response import Response @@ -7,8 +9,10 @@ from django.db.models import ( F, + Min, Q, ) +from django.db.models.query import QuerySet from django.http import Http404 from api.applications.models import ( @@ -16,6 +20,8 @@ PartyOnApplication, StandardApplication, ) +from api.audit_trail.enums import AuditType +from api.audit_trail.models import Audit from api.cases.models import ( Advice, LicenceDecision, @@ -78,6 +84,42 @@ class ApplicationViewSet(BaseViewSet): .prefetch_related("goods", "licence_decisions") ) + def get_first_closed_statuses(self, queryset): + status_map = dict(CaseStatusEnum.choices) + closed_statuses = itertools.chain.from_iterable( + (status, status_map[status]) for status in CaseStatusEnum.closed_statuses() + ) + application_ids = [] + if isinstance(queryset, list): + application_ids = [str(s.pk) for s in queryset] + elif isinstance(queryset, QuerySet): + application_ids = [str(pk) for pk in queryset.values_list("pk", flat=True)] + + first_closed_status_updates = ( + Audit.objects.filter( + target_object_id__in=application_ids, + verb=AuditType.UPDATED_STATUS, + payload__status__new__in=closed_statuses, + ) + .annotate(first_closed_date=Min("created_at")) + .values_list("target_object_id", "first_closed_date") + ) + + return dict(first_closed_status_updates) + + def get_serializer(self, *args, **kwargs): + serializer_class = self.get_serializer_class() + + context = self.get_serializer_context() + + if args and isinstance(args[0], (QuerySet, list)) and kwargs.get("many", False): + context["first_closed_statuses"] = self.get_first_closed_statuses(args[0]) + elif args and isinstance(args[0], StandardApplication): + context["first_closed_statuses"] = self.get_first_closed_statuses([args[0]]) + kwargs.setdefault("context", context) + + return serializer_class(*args, **kwargs) + class CountryViewSet(BaseViewSet): serializer_class = CountrySerializer From a983d0a6ae5d627f8e2459a17f9b1147b7a21863 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 21 Nov 2024 11:22:50 +0000 Subject: [PATCH 81/89] Make iterator a list --- api/data_workspace/v2/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index 9403a86e1..c833aeab3 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -86,8 +86,8 @@ class ApplicationViewSet(BaseViewSet): def get_first_closed_statuses(self, queryset): status_map = dict(CaseStatusEnum.choices) - closed_statuses = itertools.chain.from_iterable( - (status, status_map[status]) for status in CaseStatusEnum.closed_statuses() + closed_statuses = list( + itertools.chain.from_iterable((status, status_map[status]) for status in CaseStatusEnum.closed_statuses()) ) application_ids = [] if isinstance(queryset, list): From 20edd44c1231c77fa15306cdc823b6da9a30de97 Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 21 Nov 2024 12:54:45 +0000 Subject: [PATCH 82/89] Add denial reasons to licence decision object --- .../0072_licencedecision_denial_reasons.py | 19 +++++++++++++++++++ api/cases/models.py | 13 ++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 api/cases/migrations/0072_licencedecision_denial_reasons.py diff --git a/api/cases/migrations/0072_licencedecision_denial_reasons.py b/api/cases/migrations/0072_licencedecision_denial_reasons.py new file mode 100644 index 000000000..377c0eb57 --- /dev/null +++ b/api/cases/migrations/0072_licencedecision_denial_reasons.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.16 on 2024-11-21 12:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("denial_reasons", "0006_populate_uuid_field"), + ("cases", "0071_licencedecision_previous_decision"), + ] + + operations = [ + migrations.AddField( + model_name="licencedecision", + name="denial_reasons", + field=models.ManyToManyField(to="denial_reasons.denialreason"), + ), + ] diff --git a/api/cases/models.py b/api/cases/models.py index 30c41afb3..535d8af08 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -399,13 +399,23 @@ def finalise(self, request, decisions): ): previous_decision = previous_licence_decision - LicenceDecision.objects.create( + licence_decision = LicenceDecision.objects.create( case=self, decision=LicenceDecisionType.advice_type_to_decision(advice_type), licence=licence, previous_decision=previous_decision, ) + if advice_type == AdviceType.REFUSE: + denial_reasons = ( + self.advice.filter(team_id="58e77e47-42c8-499f-a58d-94f94541f8c6") + .only("denial_reasons__id") + .order_by() + .distinct() + .values_list("denial_reasons__id", flat=True) + ) + licence_decision.denial_reasons.set(denial_reasons) + licence_reference = licence.reference_code if licence and advice_type == AdviceType.APPROVE else "" audit_trail_service.create( actor=request.user, @@ -828,6 +838,7 @@ class LicenceDecision(TimestampableModel): previous_decision = models.ForeignKey( "self", related_name="previous_decisions", default=None, null=True, on_delete=models.DO_NOTHING ) + denial_reasons = models.ManyToManyField(DenialReason) def __str__(self): return f"{self.case.reference_code} - {self.decision} ({self.created_at})" From ad891f7bdd7b2c0865c67fba956820bb4ac2c64e Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 21 Nov 2024 14:24:09 +0000 Subject: [PATCH 83/89] Handle re-issue of issued on appeal case If a licence that is issued on appeal needs amending and it is reissued again then the new licence decision is also recorded as issued on appeal (decision remains the same which is issue). --- api/cases/models.py | 32 +++++++++---------- .../bdd/licences/test_licence_decisions.py | 11 ++----- .../bdd/scenarios/licence_decisions.feature | 2 +- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/api/cases/models.py b/api/cases/models.py index 0e4f956e0..54524f04a 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -382,29 +382,29 @@ def finalise(self, request, decisions): 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]: - # In case of ISSUED check previous decision exists and accordingly - # populate previous_decision field - current_decision = LicenceDecisionType.advice_type_to_decision(advice_type) - previous_licence_decision = self.licence_decisions.order_by("created_at").last() - previous_decision = None + decision = LicenceDecisionType.advice_type_to_decision(advice_type) + all_licence_decisions = self.licence_decisions.all().order_by("created_at") + previous_licence_decision = all_licence_decisions.last() - if ( - previous_licence_decision - and current_decision == LicenceDecisionType.ISSUED - and previous_licence_decision.decision == LicenceDecisionType.ISSUED - ): - previous_decision = previous_licence_decision - - # In case if it is being issued after an appeal then we want to reflect that in the decision - if current_decision == LicenceDecisionType.ISSUED and self.licence_decisions.count() == 1: - prev = self.licence_decisions.last() - if prev.decision == LicenceDecisionType.REFUSED: + previous_decision = None + current_decision = decision + if previous_licence_decision and decision == LicenceDecisionType.ISSUED: + # In case if it is being issued after an appeal then we want to reflect that in the decision + if previous_licence_decision.decision in [ + LicenceDecisionType.REFUSED, + LicenceDecisionType.ISSUED_ON_APPEAL, + ]: current_decision = LicenceDecisionType.ISSUED_ON_APPEAL + # link up to previous instance if the decision remains same + if previous_licence_decision.decision == current_decision: + previous_decision = previous_licence_decision + LicenceDecision.objects.create( case=self, decision=current_decision, 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 index b34ecd393..432b59a89 100644 --- a/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py +++ b/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py @@ -294,12 +294,7 @@ def case_ready_to_be_finalised_after_amending_licence(client, lu_case_officer_he @then("a licence decision with an issued_on_appeal decision is created") def licence_decision_issued_on_appeal_created(issued_licence): - assert LicenceDecision.objects.filter( - case=issued_licence.case, - decision=LicenceDecisionType.REFUSED, - ).exists() + all_licence_decisions = LicenceDecision.objects.filter(case=issued_licence.case).order_by("created_at") - assert LicenceDecision.objects.filter( - case=issued_licence.case, - decision=LicenceDecisionType.ISSUED_ON_APPEAL, - ).exists() + assert all_licence_decisions.first().decision == LicenceDecisionType.REFUSED + assert all(item.decision == LicenceDecisionType.ISSUED_ON_APPEAL for item in all_licence_decisions[1:]) diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature b/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature index 7111836b2..13f45da9f 100644 --- a/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature +++ b/api/data_workspace/v2/tests/bdd/scenarios/licence_decisions.feature @@ -70,6 +70,6 @@ Scenario: Licence issued after an appeal and re-issued again And the licence for the case is approved And case officer generates licence documents And case officer issues licence for this case - Then a licence decision with an issued decision is created + Then a licence decision with an issued_on_appeal decision is created When I fetch all licence decisions Then I see issued licence is included in the extract From 8b04198e1b117c8c9d68ce41218f130d27e1377a Mon Sep 17 00:00:00 2001 From: Kevin Carrogan Date: Thu, 21 Nov 2024 14:45:03 +0000 Subject: [PATCH 84/89] Add migration to backpopulate licence decisions --- ...3_update_licencedecision_denial_reasons.py | 56 +++++++++++++++++++ api/data_workspace/v2/serializers.py | 11 +++- api/data_workspace/v2/views.py | 13 +---- 3 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 api/cases/migrations/0073_update_licencedecision_denial_reasons.py diff --git a/api/cases/migrations/0073_update_licencedecision_denial_reasons.py b/api/cases/migrations/0073_update_licencedecision_denial_reasons.py new file mode 100644 index 000000000..817472905 --- /dev/null +++ b/api/cases/migrations/0073_update_licencedecision_denial_reasons.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.16 on 2024-11-21 13:24 + +from django.db import migrations + +from api.audit_trail.enums import AuditType + + +def update_licencedecision_denial_reasons(apps, schema_editor): + LicenceDecision = apps.get_model("cases", "LicenceDecision") + Advice = apps.get_model("cases", "Advice") + + denial_reasons = ( + Advice.objects.filter( + case__licence_decisions__decision="refused", + team_id="58e77e47-42c8-499f-a58d-94f94541f8c6", # Just care about LU advice + ) + .only("denial_reasons__display_value", "case__licence_decisions__id") + .exclude(denial_reasons__display_value__isnull=True) # This removes refusals without any criteria + .values_list("denial_reasons__display_value", "case__licence_decisions__id") + .order_by() # We need to remove the order_by to make sure the distinct works + .distinct() + ) + + updated_cases = set() + for denial_reason, licence_decision_id in denial_reasons: + licence_decision = LicenceDecision.objects.get(pk=licence_decision_id) + licence_decision.denial_reasons.add(denial_reason) + updated_cases.add(licence_decision.case.pk) + + Audit = apps.get_model("audit_trail", "Audit") + Case = apps.get_model("cases", "Case") + refusal_criteria_audits = Audit.objects.exclude(target_object_id__in=updated_cases).filter( + verb=AuditType.CREATE_REFUSAL_CRITERIA + ) + for audit in refusal_criteria_audits: + case = Case.objects.get(pk=audit.target_object_id) + refusal_licence_decisions = case.licence_decisions.filter(decision="refused") + if not refusal_licence_decisions.exists(): + continue + for licence_decision in refusal_licence_decisions: + denial_reasons = audit.payload["additional_text"].replace(".", "").replace(" ", "").split(",") + licence_decision.denial_reasons.set(denial_reasons) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0072_licencedecision_denial_reasons"), + ] + + operations = [ + migrations.RunPython( + update_licencedecision_denial_reasons, + migrations.RunPython.noop, + ), + ] diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 1effda758..75b757f2a 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -10,6 +10,7 @@ from api.licences.models import GoodOnLicence from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country +from api.staticdata.denial_reasons.models import DenialReason from api.staticdata.report_summaries.models import ReportSummary from api.staticdata.statuses.enums import CaseStatusEnum @@ -184,9 +185,13 @@ class Meta: ) -class LicenceRefusalCriteriaSerializer(serializers.Serializer): - criteria = serializers.CharField(source="denial_reasons__display_value") - licence_decision_id = serializers.UUIDField(source="case__licence_decisions__id") +class LicenceRefusalCriteriaSerializer(serializers.ModelSerializer): + criteria = serializers.CharField(source="display_value") + licence_decision_id = serializers.UUIDField(source="licence_decisions_id") + + class Meta: + model = DenialReason + fields = ("criteria", "licence_decision_id") class FootnoteSerializer(serializers.Serializer): diff --git a/api/data_workspace/v2/views.py b/api/data_workspace/v2/views.py index c833aeab3..1e3c9ef1e 100644 --- a/api/data_workspace/v2/views.py +++ b/api/data_workspace/v2/views.py @@ -46,6 +46,7 @@ from api.licences.models import GoodOnLicence from api.staticdata.control_list_entries.models import ControlListEntry from api.staticdata.countries.models import Country +from api.staticdata.denial_reasons.models import DenialReason from api.staticdata.report_summaries.models import ReportSummary from api.staticdata.statuses.enums import CaseStatusEnum from api.staticdata.units.enums import Units @@ -165,16 +166,8 @@ class GoodOnLicenceViewSet(BaseViewSet): class LicenceRefusalCriteriaViewSet(BaseViewSet): serializer_class = LicenceRefusalCriteriaSerializer - queryset = ( - Advice.objects.filter( - case__licence_decisions__decision="refused", - team_id="58e77e47-42c8-499f-a58d-94f94541f8c6", # Just care about LU advice - ) - .only("denial_reasons__display_value", "case__licence_decisions__id") - .exclude(denial_reasons__display_value__isnull=True) # This removes refusals without any criteria - .values("denial_reasons__display_value", "case__licence_decisions__id") - .order_by() # We need to remove the order_by to make sure the distinct works - .distinct() + queryset = DenialReason.objects.exclude(licencedecision__denial_reasons__isnull=True).annotate( + licence_decisions_id=F("licencedecision__id") ) From 81d60c62be1692274cfc6e12a79e9e55adabd1de Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 21 Nov 2024 23:23:04 +0000 Subject: [PATCH 85/89] Fix conflicting migrations in cases --- ...lter_decision_populate_issued_on_appeal.py | 62 ------------------- api/cases/models.py | 3 + 2 files changed, 3 insertions(+), 62 deletions(-) delete mode 100644 api/cases/migrations/0072_alter_decision_populate_issued_on_appeal.py diff --git a/api/cases/migrations/0072_alter_decision_populate_issued_on_appeal.py b/api/cases/migrations/0072_alter_decision_populate_issued_on_appeal.py deleted file mode 100644 index f5e601540..000000000 --- a/api/cases/migrations/0072_alter_decision_populate_issued_on_appeal.py +++ /dev/null @@ -1,62 +0,0 @@ -# Generated by Django 4.2.16 on 2024-11-19 16:52 - -from django.db import migrations, models, transaction - -from django.db.models import Count - -from api.cases.enums import LicenceDecisionType - - -@transaction.atomic -def populate_issued_on_appeal(apps, schema_editor): - Case = apps.get_model("cases", "Case") - LicenceDecision = apps.get_model("cases", "LicenceDecision") - - licence_decisions_to_update = [] - - # Filter cases that have two decisions because for appeals - # the first decision will be refused which is issued on appeal later - case_qs = ( - Case.objects.all() - .annotate( - num_decisions=Count("licence_decisions"), - ) - .filter( - num_decisions=2, - ) - ) - - for case in case_qs: - prev, current = case.licence_decisions.all() - if prev.decision == LicenceDecisionType.REFUSED and current.decision == LicenceDecisionType.ISSUED: - current.decision = LicenceDecisionType.ISSUED_ON_APPEAL - licence_decisions_to_update.append(current) - - LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["decision"]) - - -class Migration(migrations.Migration): - - dependencies = [ - ("cases", "0071_licencedecision_previous_decision"), - ] - - operations = [ - migrations.AlterField( - model_name="licencedecision", - name="decision", - field=models.CharField( - choices=[ - ("issued", "issued"), - ("refused", "refused"), - ("revoked", "revoked"), - ("issued_on_appeal", "issued_on_appeal"), - ], - max_length=50, - ), - ), - migrations.RunPython( - populate_issued_on_appeal, - migrations.RunPython.noop, - ), - ] diff --git a/api/cases/models.py b/api/cases/models.py index 54518c2cc..39529b8c8 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -846,5 +846,8 @@ class LicenceDecision(TimestampableModel): ) denial_reasons = models.ManyToManyField(DenialReason) + class Meta: + ordering = ("created_at",) + def __str__(self): return f"{self.case.reference_code} - {self.decision} ({self.created_at})" From 7d1547b36e41c7e0c350768ce160673d78555360 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Thu, 21 Nov 2024 23:29:18 +0000 Subject: [PATCH 86/89] Default ordering is specified in model --- api/cases/models.py | 3 +-- api/data_workspace/v2/serializers.py | 1 - .../v2/tests/bdd/licences/test_licence_decisions.py | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/api/cases/models.py b/api/cases/models.py index 39529b8c8..8f303b9da 100644 --- a/api/cases/models.py +++ b/api/cases/models.py @@ -388,8 +388,7 @@ def finalise(self, request, decisions): # NLR is not considered as licence decision if advice_type in [AdviceType.APPROVE, AdviceType.REFUSE]: decision = LicenceDecisionType.advice_type_to_decision(advice_type) - all_licence_decisions = self.licence_decisions.all().order_by("created_at") - previous_licence_decision = all_licence_decisions.last() + previous_licence_decision = self.licence_decisions.last() previous_decision = None current_decision = decision diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index 9f4688573..dc912aef2 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -36,7 +36,6 @@ def get_licence_id(self, licence_decision): latest_decision = ( licence_decision.case.licence_decisions.exclude(excluded_from_statistics_reason__isnull=False) - .order_by("created_at") .last() ) 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 index 432b59a89..0163de1f7 100644 --- a/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py +++ b/api/data_workspace/v2/tests/bdd/licences/test_licence_decisions.py @@ -294,7 +294,7 @@ def case_ready_to_be_finalised_after_amending_licence(client, lu_case_officer_he @then("a licence decision with an issued_on_appeal decision is created") def licence_decision_issued_on_appeal_created(issued_licence): - all_licence_decisions = LicenceDecision.objects.filter(case=issued_licence.case).order_by("created_at") + all_licence_decisions = LicenceDecision.objects.filter(case=issued_licence.case) assert all_licence_decisions.first().decision == LicenceDecisionType.REFUSED assert all(item.decision == LicenceDecisionType.ISSUED_ON_APPEAL for item in all_licence_decisions[1:]) From 5026a4a465cdd9ccb4d1687d9966a034a5cb37a1 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 22 Nov 2024 09:52:59 +0000 Subject: [PATCH 87/89] Actually commit migration file --- ..._alter_licencedecision_options_and_more.py | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 api/cases/migrations/0074_alter_licencedecision_options_and_more.py diff --git a/api/cases/migrations/0074_alter_licencedecision_options_and_more.py b/api/cases/migrations/0074_alter_licencedecision_options_and_more.py new file mode 100644 index 000000000..a4473967f --- /dev/null +++ b/api/cases/migrations/0074_alter_licencedecision_options_and_more.py @@ -0,0 +1,66 @@ +# Generated by Django 4.2.16 on 2024-11-21 23:21 + +from django.db import migrations, models, transaction + +from django.db.models import Count + +from api.cases.enums import LicenceDecisionType + + +@transaction.atomic +def populate_issued_on_appeal(apps, schema_editor): + Case = apps.get_model("cases", "Case") + LicenceDecision = apps.get_model("cases", "LicenceDecision") + + licence_decisions_to_update = [] + + # Filter cases that have two decisions because for appeals + # the first decision will be refused which is issued on appeal later + case_qs = ( + Case.objects.all() + .annotate( + num_decisions=Count("licence_decisions"), + ) + .filter( + num_decisions=2, + ) + ) + + for case in case_qs: + prev, current = case.licence_decisions.all() + if prev.decision == LicenceDecisionType.REFUSED and current.decision == LicenceDecisionType.ISSUED: + current.decision = LicenceDecisionType.ISSUED_ON_APPEAL + licence_decisions_to_update.append(current) + + LicenceDecision.objects.bulk_update(licence_decisions_to_update, ["decision"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cases", "0073_update_licencedecision_denial_reasons"), + ] + + operations = [ + migrations.AlterModelOptions( + name="licencedecision", + options={"ordering": ("created_at",)}, + ), + migrations.AlterField( + model_name="licencedecision", + name="decision", + field=models.CharField( + choices=[ + ("issued", "issued"), + ("refused", "refused"), + ("revoked", "revoked"), + ("issued_on_appeal", "issued_on_appeal"), + ], + max_length=50, + ), + ), + migrations.RunPython( + populate_issued_on_appeal, + migrations.RunPython.noop, + ), + ] From d8e0b82fb5efa0a22d25e97b11b6c07d422c72df Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 22 Nov 2024 09:54:30 +0000 Subject: [PATCH 88/89] Fix linter errors --- api/data_workspace/v2/serializers.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/api/data_workspace/v2/serializers.py b/api/data_workspace/v2/serializers.py index dc912aef2..0454a0506 100644 --- a/api/data_workspace/v2/serializers.py +++ b/api/data_workspace/v2/serializers.py @@ -34,10 +34,9 @@ def get_licence_id(self, licence_decision): if licence_decision.decision in [LicenceDecisionType.REFUSED, LicenceDecisionType.REVOKED]: return "" - latest_decision = ( - licence_decision.case.licence_decisions.exclude(excluded_from_statistics_reason__isnull=False) - .last() - ) + latest_decision = licence_decision.case.licence_decisions.exclude( + excluded_from_statistics_reason__isnull=False + ).last() return latest_decision.licence.id if latest_decision.licence else None From 8f8de7970e7ebbe14676f4ec6310e448a5637052 Mon Sep 17 00:00:00 2001 From: Arun Siluvery Date: Fri, 22 Nov 2024 10:22:26 +0000 Subject: [PATCH 89/89] Remove duplicated files These are renamed as licence_decisions.feature --- .../v2/tests/bdd/licences/test_licences.py | 176 ------------------ .../v2/tests/bdd/scenarios/licences.feature | 25 --- 2 files changed, 201 deletions(-) delete mode 100644 api/data_workspace/v2/tests/bdd/licences/test_licences.py delete mode 100644 api/data_workspace/v2/tests/bdd/scenarios/licences.feature diff --git a/api/data_workspace/v2/tests/bdd/licences/test_licences.py b/api/data_workspace/v2/tests/bdd/licences/test_licences.py deleted file mode 100644 index 455365301..000000000 --- a/api/data_workspace/v2/tests/bdd/licences/test_licences.py +++ /dev/null @@ -1,176 +0,0 @@ -import pytest - -from django.urls import reverse -from django.utils import timezone -from pytest_bdd import ( - given, - then, - when, - scenarios, -) - -from api.cases.enums import AdviceType -from api.licences.enums import LicenceStatus -from api.licences.models import Licence -from api.staticdata.statuses.enums import CaseStatusEnum - - -scenarios("../scenarios/licences.feature") - - -@pytest.fixture() -def licences_list_url(): - return reverse("data_workspace:v2:dw-licence-decisions-list") - - -@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, licences_list_url): - licences = unpage_data(licences_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, licences_list_url): - licences = unpage_data(licences_list_url) - - assert cancelled_licence.reference_code not in [item["reference_code"] for item in licences] - - -@then("the issued licence is included in the extract") -def licence_included_in_extract(issued_licence, unpage_data, licences_list_url): - licences = unpage_data(licences_list_url) - - assert issued_licence.reference_code in [item["reference_code"] for item in licences] - - -@then("the refused case is included in the extract") -def refused_case_included_in_extract(refused_case, unpage_data, licences_list_url): - licences = unpage_data(licences_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 licence_for_case_is_approved(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)}, - ) - 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 licence_for_case_is_approved(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 - - 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)}, - ) - 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" - - return case_with_refused_advice diff --git a/api/data_workspace/v2/tests/bdd/scenarios/licences.feature b/api/data_workspace/v2/tests/bdd/scenarios/licences.feature deleted file mode 100644 index eff790c6e..000000000 --- a/api/data_workspace/v2/tests/bdd/scenarios/licences.feature +++ /dev/null @@ -1,25 +0,0 @@ -@db -Feature: Licences - -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: Licence document is generated 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 - Then the issued licence is included in the extract - -@refusal -Scenario: Refusal letter is generated 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 - Then the refused case is included in the extract