diff --git a/Pipfile b/Pipfile index 99b8042ddb..ed01b67fa1 100644 --- a/Pipfile +++ b/Pipfile @@ -53,6 +53,7 @@ pandas = "<3.0" scipy = ">=1.16" boto3 = "*" sqlalchemy = ">=2.0.0" +Babel = "*" [dev-packages] argh = ">=0.26.2" diff --git a/Pipfile.lock b/Pipfile.lock index 21e9ababee..93492cf498 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8355828638d3b0648e8f215a63312b8cc8edc9f80fd3dfb07e960556aa6086a0" + "sha256": "076741f1d4c52a3ea84a9a18600a5de48c6106574cacae7db51b8e50808f33a1" }, "pipfile-spec": 6, "requires": { @@ -56,6 +56,14 @@ "markers": "python_version >= '3.9'", "version": "==3.11.1" }, + "async-timeout": { + "hashes": [ + "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", + "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3" + ], + "markers": "python_version >= '3.8'", + "version": "==5.0.1" + }, "azure-common": { "hashes": [ "sha256:4ac0cd3214e36b6a1b6a442686722a5d8cc449603aa833f3f0f40bda836704a3", @@ -115,6 +123,15 @@ "markers": "python_version >= '3.9'", "version": "==12.28.0" }, + "babel": { + "hashes": [ + "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", + "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.18.0" + }, "beautifulsoup4": { "hashes": [ "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", @@ -134,29 +151,29 @@ }, "boto3": { "hashes": [ - "sha256:15cc1404b3ccbcfe17bd5834d467b1b28d53d9aca44e3798dc44876ac57362e6", - "sha256:b5b86a826f8f12c7d38679f35bd0135807a6867a21eb8be6dea7c27aeb14ec14" + "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", + "sha256:966e49f0510af9a64057a902b7df53d4348c447de0d3df4cc855dfd85e058fcd" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==1.42.87" + "version": "==1.42.97" }, "botocore": { "hashes": [ - "sha256:1c6cc9555c1feec50b290a42de70ba6f04826c009562c2c12bb9990d0258482d", - "sha256:32832df27c039cc1a518289afe0f6006296d3df26603b5f66e911457e67f0146" + "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", + "sha256:77d2c8ce1bc592d3fbd7c01c35836f4a5b0cac2ca03ccdf6ffc60faa16b5fadc" ], "markers": "python_version >= '3.9'", - "version": "==1.42.87" + "version": "==1.42.97" }, "cachetools": { "hashes": [ - "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", - "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114" + "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b", + "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==7.0.5" + "version": "==7.0.6" }, "celery": { "hashes": [ @@ -169,11 +186,11 @@ }, "certifi": { "hashes": [ - "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", - "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" + "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", + "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580" ], "markers": "python_version >= '3.7'", - "version": "==2026.2.25" + "version": "==2026.4.22" }, "cffi": { "hashes": [ @@ -262,7 +279,7 @@ "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf" ], - "markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'", + "markers": "python_version >= '3.9'", "version": "==2.0.0" }, "charset-normalizer": { @@ -473,11 +490,11 @@ }, "click": { "hashes": [ - "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", - "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" + "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", + "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613" ], "markers": "python_version >= '3.10'", - "version": "==8.3.2" + "version": "==8.3.3" }, "click-didyoumean": { "hashes": [ @@ -547,58 +564,58 @@ }, "cryptography": { "hashes": [ - "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", - "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", - "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", - "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", - "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", - "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", - "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", - "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", - "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", - "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", - "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", - "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", - "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", - "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", - "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", - "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", - "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", - "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", - "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", - "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", - "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", - "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", - "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", - "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", - "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", - "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", - "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", - "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", - "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", - "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", - "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", - "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", - "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", - "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", - "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", - "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", - "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", - "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", - "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", - "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", - "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", - "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", - "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", - "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", - "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", - "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", - "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", - "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", - "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce" + "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", + "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", + "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", + "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", + "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", + "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", + "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", + "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", + "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", + "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", + "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", + "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", + "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", + "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", + "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", + "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", + "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", + "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", + "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", + "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", + "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", + "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", + "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", + "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", + "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", + "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", + "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", + "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", + "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", + "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", + "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", + "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", + "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", + "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", + "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", + "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", + "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", + "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", + "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", + "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", + "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", + "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", + "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", + "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", + "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", + "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", + "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", + "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", + "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736" ], "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==46.0.7" + "version": "==47.0.0" }, "django": { "hashes": [ @@ -701,11 +718,11 @@ "grpc" ], "hashes": [ - "sha256:9a8113e1a88bdc09a7ff629707f2214d98d61c7f6ceb0ea38c42a095d02dc0f9", - "sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594" + "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", + "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b" ], "markers": "python_version >= '3.9'", - "version": "==2.30.2" + "version": "==2.30.3" }, "google-api-python-client": { "hashes": [ @@ -718,12 +735,12 @@ }, "google-auth": { "hashes": [ - "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", - "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7" + "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", + "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.49.1" + "version": "==2.49.2" }, "google-auth-httplib2": { "hashes": [ @@ -814,6 +831,71 @@ "markers": "python_version >= '3.9'", "version": "==1.74.0" }, + "greenlet": { + "hashes": [ + "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", + "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", + "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", + "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", + "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", + "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", + "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", + "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", + "sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a", + "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", + "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", + "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", + "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", + "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", + "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", + "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", + "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", + "sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb", + "sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd", + "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", + "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", + "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", + "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", + "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", + "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", + "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", + "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", + "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", + "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", + "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", + "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", + "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", + "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", + "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", + "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", + "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", + "sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f", + "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", + "sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb", + "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", + "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", + "sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0", + "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", + "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", + "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", + "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", + "sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858", + "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", + "sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977", + "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", + "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", + "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", + "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", + "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", + "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", + "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", + "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", + "sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243", + "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564" + ], + "markers": "python_version >= '3.10'", + "version": "==3.5.0" + }, "grpcio": { "hashes": [ "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", @@ -908,11 +990,11 @@ }, "idna": { "hashes": [ - "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", - "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" + "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", + "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3" ], "markers": "python_version >= '3.8'", - "version": "==3.11" + "version": "==3.13" }, "importlib-metadata": { "hashes": [ @@ -1413,17 +1495,17 @@ "sha256:fe0b8c83e0f36247fc9431ce5425a5d95f9b3a689133d494831bdbd6f0bceb13", "sha256:ff51f9d657d1afb6f410cb435792ce4e1fe427aab23d2fcd727a2876e21d4cb6" ], - "markers": "platform_python_implementation != 'PyPy'", + "markers": "python_version >= '3.10'", "version": "==3.11.8" }, "packaging": { "hashes": [ - "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", - "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" + "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", + "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==26.0" + "version": "==26.2" }, "pandas": { "hashes": [ @@ -1574,134 +1656,134 @@ }, "psycopg2-binary": { "hashes": [ - "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", - "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", - "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152", - "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", - "sha256:20e7fb94e20b03dcc783f76c0865f9da39559dcc0c28dd1a3fce0d01902a6b9c", - "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", - "sha256:2d11098a83cca92deaeaed3d58cfd150d49b3b06ee0d0852be466bf87596899e", - "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", - "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", - "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", - "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", - "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", - "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e", - "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", - "sha256:41360b01c140c2a03d346cec3280cf8a71aa07d94f3b1509fa0161c366af66b4", - "sha256:44fc5c2b8fa871ce7f0023f619f1349a0aa03a0857f2c96fbc01c657dcbbdb49", - "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", - "sha256:4bdab48575b6f870f465b397c38f1b415520e9879fdf10a53ee4f49dcbdf8a21", - "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b", - "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", - "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b", - "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", - "sha256:691c807d94aecfbc76a14e1408847d59ff5b5906a04a23e12a89007672b9e819", - "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", - "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", - "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14", - "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02", - "sha256:8b81627b691f29c4c30a8f322546ad039c40c328373b11dff7490a3e1b517855", - "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", - "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd", - "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", - "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", - "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", - "sha256:9c55460033867b4622cda1b6872edf445809535144152e5d14941ef591980edf", - "sha256:9d3a9edcfbe77a3ed4bc72836d466dfce4174beb79eda79ea155cc77237ed9e8", - "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", - "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2", - "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", - "sha256:a6c0e4262e089516603a09474ee13eabf09cb65c332277e39af68f6233911087", - "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", - "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", - "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", - "sha256:b637d6d941209e8d96a072d7977238eea128046effbf37d1d8b2c0764750017d", - "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", - "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", - "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", - "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", - "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", - "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", - "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", - "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d", - "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39", - "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", - "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", - "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", - "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2", - "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", - "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", - "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", - "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", - "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", - "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", - "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", - "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", - "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", - "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", - "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747" + "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", + "sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964", + "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", + "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", + "sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115", + "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", + "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", + "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", + "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c", + "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", + "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", + "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", + "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", + "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", + "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", + "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", + "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", + "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", + "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", + "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", + "sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf", + "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", + "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", + "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", + "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4", + "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", + "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", + "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3", + "sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94", + "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", + "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", + "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", + "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", + "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", + "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", + "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433", + "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", + "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463", + "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", + "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", + "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", + "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4", + "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", + "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1", + "sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915", + "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", + "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03", + "sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe", + "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", + "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", + "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", + "sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86", + "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", + "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e", + "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", + "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", + "sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03", + "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56", + "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", + "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256", + "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", + "sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab", + "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", + "sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10", + "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", + "sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2", + "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==2.9.11" + "version": "==2.9.12" }, "pyarrow": { "hashes": [ - "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", - "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", - "sha256:0ae6e17c828455b6265d590100c295193f93cc5675eb0af59e49dbd00d2de350", - "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", - "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", - "sha256:26d50dee49d741ac0e82185033488d28d35be4d763ae6f321f97d1140eb7a0e9", - "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", - "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", - "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", - "sha256:3c30143b17161310f151f4a2bcfe41b5ff744238c1039338779424e38579d701", - "sha256:3f91c038b95f71ddfc865f11d5876c42f343b4495535bd262c7b321b0b94507c", - "sha256:3fab8f82571844eb3c460f90a75583801d14ca0cc32b1acc8c361650e006fd56", - "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", - "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", - "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", - "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", - "sha256:5abde149bb3ce524782d838eb67ac095cd3fd6090eba051130589793f1a7f76d", - "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", - "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", - "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", - "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", - "sha256:6f0147ee9e0386f519c952cc670eb4a8b05caa594eeffe01af0e25f699e4e9bb", - "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", - "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", - "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", - "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", - "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", - "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", - "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", - "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", - "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", - "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", - "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", - "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", - "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", - "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", - "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", - "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", - "sha256:c33b5bf406284fd0bba436ed6f6c3ebe8e311722b441d89397c54f871c6863a2", - "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", - "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", - "sha256:d0744403adabef53c985a7f8a082b502a368510c40d184df349a0a8754533258", - "sha256:db2190fa79c80a23fdd29fef4b8992893f024ae7c17d2f5f4db7171fa30c2c78", - "sha256:ddf743e82f69dcd6dbbcb63628895d7161e04e56794ef80550ac6f3315eeb1d5", - "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", - "sha256:e052a211c5ac9848ae15d5ec875ed0943c0221e2fcfe69eee80b604b4e703222", - "sha256:f00f993a8179e0e1c9713bcc0baf6d6c01326a406a9c23495ec1ba9c9ebf2919", - "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", - "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", - "sha256:fed7020203e9ef273360b9e45be52a2a47d3103caf156a30ace5247ffb51bdbd" + "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba", + "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68", + "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868", + "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495", + "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e", + "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66", + "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275", + "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3", + "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838", + "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491", + "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6", + "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826", + "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a", + "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981", + "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e", + "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c", + "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e", + "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05", + "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b", + "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb", + "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136", + "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810", + "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37", + "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147", + "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0", + "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b", + "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb", + "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f", + "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83", + "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca", + "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931", + "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", + "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2", + "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795", + "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26", + "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74", + "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c", + "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57", + "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1", + "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591", + "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", + "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42", + "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde", + "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699", + "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041", + "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91", + "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76", + "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b", + "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a", + "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==23.0.1" + "version": "==24.0.0" }, "pyasn1": { "hashes": [ @@ -1724,144 +1806,143 @@ "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" ], - "markers": "implementation_name != 'PyPy'", + "markers": "python_version >= '3.10'", "version": "==3.0" }, "pydantic": { "hashes": [ - "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", - "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" + "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", + "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==2.12.5" + "version": "==2.13.3" }, "pydantic-core": { "hashes": [ - "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", - "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", - "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", - "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", - "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", - "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", - "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", - "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", - "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", - "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", - "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", - "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", - "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", - "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", - "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", - "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", - "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", - "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", - "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", - "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", - "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", - "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", - "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", - "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", - "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", - "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", - "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", - "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", - "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", - "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", - "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", - "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", - "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", - "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", - "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", - "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", - "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", - "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", - "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", - "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", - "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", - "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", - "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", - "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", - "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", - "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", - "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", - "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", - "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", - "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", - "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", - "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", - "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", - "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", - "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", - "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", - "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", - "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", - "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", - "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", - "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", - "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", - "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", - "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", - "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", - "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", - "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", - "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", - "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", - "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", - "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", - "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", - "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", - "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", - "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", - "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", - "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", - "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", - "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", - "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", - "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", - "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", - "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", - "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", - "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", - "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", - "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", - "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", - "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", - "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", - "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", - "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", - "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", - "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", - "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", - "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", - "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", - "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", - "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", - "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", - "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", - "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", - "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", - "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", - "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", - "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", - "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", - "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", - "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", - "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", - "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", - "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", - "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", - "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", - "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", - "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", - "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", - "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", - "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", - "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", - "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52" + "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", + "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", + "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", + "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", + "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", + "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", + "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4", + "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", + "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", + "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", + "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1", + "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", + "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", + "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3", + "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", + "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", + "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", + "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", + "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", + "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", + "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6", + "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", + "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", + "sha256:3d08782c4045f90724b44c95d35ebec0d67edb8a957a2ac81d5a8e4b8a200495", + "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", + "sha256:4335e87c7afa436a0dfa899e138d57a72f8aad542e2cf19c36fb428461caabd0", + "sha256:4b068543bdb707f5d935dab765d99227aa2545ef2820935f2e5dd801795c7dbd", + "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", + "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", + "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", + "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda", + "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", + "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", + "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", + "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", + "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", + "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", + "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", + "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", + "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", + "sha256:68ef2f623dda6d5a9067ac014e406c020c780b2a358930a7e5c1b73702900720", + "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", + "sha256:6dff8cc884679df229ebc6d8eb2321ea6f8e091bc7d4886d4dc2e0e71452843c", + "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", + "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", + "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", + "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", + "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807", + "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", + "sha256:831eb19aa789a97356979e94c981e5667759301fb708d1c0d5adf1bc0098b873", + "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57", + "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", + "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", + "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", + "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", + "sha256:91249bcb7c165c2fb2a2f852dbc5c91636e2e218e75d96dfdd517e4078e173dd", + "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", + "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", + "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", + "sha256:99421e7684a60f7f3550a1d159ade5fdff1954baedb6bdd407cba6a307c9f27d", + "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", + "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", + "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", + "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", + "sha256:9f247596366f4221af52beddd65af1218797771d6989bc891a0b86ccaa019168", + "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", + "sha256:a35cc284c8dd7edae8a31533713b4d2467dfe7c4f1b5587dd4031f28f90d1d13", + "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f", + "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a", + "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", + "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", + "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", + "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", + "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943", + "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", + "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", + "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", + "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", + "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", + "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", + "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045", + "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", + "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", + "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", + "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", + "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", + "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", + "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", + "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f", + "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", + "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", + "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", + "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", + "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", + "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", + "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7", + "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", + "sha256:d56bdb4af1767cc15b0386b3c581fdfe659bb9ee4a4f776e92c1cd9d074000d6", + "sha256:dcda6583921c05a40533f982321532f2d8db29326c7b95c4026941fa5074bd79", + "sha256:dd81f6907932ebac3abbe41378dac64b2380db1287e2aa64d8d88f78d170f51a", + "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", + "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a", + "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", + "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", + "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", + "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", + "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", + "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", + "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", + "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", + "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", + "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", + "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", + "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", + "sha256:fa3eb7c2995aa443687a825bc30395c8521b7c6ec201966e55debfd1128bcceb", + "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", + "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", + "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", + "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", + "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56" ], "markers": "python_version >= '3.9'", - "version": "==2.41.5" + "version": "==2.46.3" }, "pyjwt": { "extras": [ @@ -1888,7 +1969,7 @@ "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.9.0.post0" }, "pytz": { @@ -1926,11 +2007,11 @@ }, "s3transfer": { "hashes": [ - "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", - "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920" + "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", + "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524" ], "markers": "python_version >= '3.9'", - "version": "==0.16.0" + "version": "==0.16.1" }, "scipy": { "hashes": [ @@ -2010,19 +2091,19 @@ }, "sentry-sdk": { "hashes": [ - "sha256:4be8d1e71c32fb27f79c577a337ac8912137bba4bcbc64a4ec1da4d6d8dc5199", - "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585" + "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", + "sha256:c1144d947352d54e5b7daa63596d9f848adf684989c06c4f5a659f0c85a18f6f" ], "index": "pypi", "markers": "python_version >= '3.6'", - "version": "==2.57.0" + "version": "==2.58.0" }, "six": { "hashes": [ "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.17.0" }, "soupsieve": { @@ -2182,11 +2263,11 @@ }, "tzdata": { "hashes": [ - "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", - "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98" + "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", + "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7" ], "markers": "python_version >= '2'", - "version": "==2026.1" + "version": "==2026.2" }, "tzlocal": { "hashes": [ @@ -2311,11 +2392,11 @@ }, "zipp": { "hashes": [ - "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", - "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" + "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", + "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110" ], "markers": "python_version >= '3.9'", - "version": "==3.23.0" + "version": "==3.23.1" }, "zstandard": { "hashes": [ @@ -2432,30 +2513,6 @@ "markers": "python_version >= '3.6'", "version": "==5.3.1" }, - "anyio": { - "hashes": [ - "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", - "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc" - ], - "markers": "python_version >= '3.10'", - "version": "==4.13.0" - }, - "anytree": { - "hashes": [ - "sha256:4cbcf10df36b1f1cba131b7e487ff3edafc9d6e932a3c70071b5b768bab901ff", - "sha256:c9d3aa6825fdd06af7ebb05b4ef291d2db63e62bb1f9b7d9b71354be9d362714" - ], - "markers": "python_version < '4.0' and python_full_version >= '3.9.2'", - "version": "==2.13.0" - }, - "app-common-python": { - "hashes": [ - "sha256:74125371e2865bcba7a7c34f0700d4b8b97a0c0a9ff67bbb06fdb20a3da4b75c", - "sha256:861bd93482edabd00a85eb6512e0d7df08597be6f76b864be8e53d6294f81389" - ], - "index": "pypi", - "version": "==0.2.9" - }, "argh": { "hashes": [ "sha256:2edac856ff50126f6e47d884751328c9f466bacbbb6cbfdac322053d94705494", @@ -2499,14 +2556,6 @@ "markers": "python_version >= '3.9'", "version": "==12.28.0" }, - "backoff": { - "hashes": [ - "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", - "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8" - ], - "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==2.2.1" - }, "billiard": { "hashes": [ "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", @@ -2517,37 +2566,29 @@ }, "boto3": { "hashes": [ - "sha256:15cc1404b3ccbcfe17bd5834d467b1b28d53d9aca44e3798dc44876ac57362e6", - "sha256:b5b86a826f8f12c7d38679f35bd0135807a6867a21eb8be6dea7c27aeb14ec14" + "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", + "sha256:966e49f0510af9a64057a902b7df53d4348c447de0d3df4cc855dfd85e058fcd" ], "index": "pypi", "markers": "python_version >= '3.9'", - "version": "==1.42.87" + "version": "==1.42.97" }, "botocore": { "hashes": [ - "sha256:1c6cc9555c1feec50b290a42de70ba6f04826c009562c2c12bb9990d0258482d", - "sha256:32832df27c039cc1a518289afe0f6006296d3df26603b5f66e911457e67f0146" + "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", + "sha256:77d2c8ce1bc592d3fbd7c01c35836f4a5b0cac2ca03ccdf6ffc60faa16b5fadc" ], "markers": "python_version >= '3.9'", - "version": "==1.42.87" - }, - "cached-property": { - "hashes": [ - "sha256:484d617105e3ee0e4f1f58725e72a8ef9e93deee462222dbd51cd91230897641", - "sha256:f617d70ab1100b7bcf6e42228f9ddcb78c676ffa167278d9f730d1c2fba69ccb" - ], - "markers": "python_version >= '3.8'", - "version": "==2.0.1" + "version": "==1.42.97" }, "cachetools": { "hashes": [ - "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", - "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114" + "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b", + "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==7.0.5" + "version": "==7.0.6" }, "celery": { "hashes": [ @@ -2560,11 +2601,11 @@ }, "certifi": { "hashes": [ - "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", - "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" + "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", + "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580" ], "markers": "python_version >= '3.7'", - "version": "==2026.2.25" + "version": "==2026.4.22" }, "cffi": { "hashes": [ @@ -2653,7 +2694,7 @@ "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf" ], - "markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'", + "markers": "python_version >= '3.9'", "version": "==2.0.0" }, "cfgv": { @@ -2666,41 +2707,45 @@ }, "chardet": { "hashes": [ - "sha256:0487a6a6846740f39f9fcd71e3acff2982bae8bca3507ee986d5155cd458e044", - "sha256:04b9be0d786b9a3bbd7860a82d27e843f22211be51b9b84d85fe8d9864e2f535", - "sha256:0848015eb1471e1499963dff2776557af05f99c38ba2a14f34ea078f8668c6a9", - "sha256:0de8d636391f9050e4e048ca8a9f98b25e67eff389705f8c6ff1ab9593f7339b", - "sha256:19bcd1de4a0c1a5802f9d2d370b6696668bddc166a3c89c113cf109313b3d99f", - "sha256:277ce1174ea054415a3c2ad5f51aa089a96dda16999de56e4ac1bc366d0d535e", - "sha256:2da446b920064ca9574504c29a07ef5eae91a1948a302a25043a16fb79ec2397", - "sha256:35ade2a6f93e5d2bdff541b584126cfe066eac5c9457572ff97cabc8068bece1", - "sha256:3886f8f9bb3500bd8c421b2de9b4878a0c183f80bc289338cdda869dfd4397fb", - "sha256:3d66d2949754ad924865a47e81857a0792dc8edc651094285116b6df2e218445", - "sha256:4ececf9631f7932a2cef728746303d71ae8204923190253d5382ee37739dd46e", - "sha256:5d86402a506631af2fb36e3d1c72021477b228fb0dcdb44400b9b681f14b14c0", - "sha256:5e686e5a0d8155cfbf5b1a579f5790bb01bf1a0a52e7f98b38801c09a0c63fcd", - "sha256:62b25b3ea5ef8e1672726e4c1601f6636ce3b76b9de92af669c8000711d7fe13", - "sha256:6b5c03330b9108124f8174a596284b737e95f1cf6a99953c37cea7e2583212e7", - "sha256:7a1c2be068ab91a472fd74617d9371605897c3061ca4f2e5df63d9f3d9c11f8e", - "sha256:80de820fa1df95a2e7c9898867f7d6ff3dc3d52a109938b215b37ab54474d307", - "sha256:8befb12c263cb14e26f065a3f99d76ef2610b0266cb70d827bb61528e9e60f28", - "sha256:9381b3d9075c8a2e622b4d46db5e4229c94aebc71d4c8e620d9cf2cea2930824", - "sha256:a9322fd3ffd359b49b2d608725a15975ebc0d66f2dcedefa7ddb5847e54a6f9c", - "sha256:a98c361b73c6ce4a44beaecf5cfb389ec69b566dd7f3f4ea5d1bde6487e7054d", - "sha256:b726b0b2684d29cd08f602bb4266334386c58741ff34c9e2f6cdf97ad604e235", - "sha256:be39708b300a80a9f78ef8f81018e2e9c6274a71c0823a4d6e493c72f7b3d2a2", - "sha256:bee1d665ca5810d8e3cf11122619e85c23b209075cbddb91f213675248f0e522", - "sha256:c03925738670199d253b8c79828d8a68d404f629a2dbf1b4b5aabd8c8b0249ab", - "sha256:c820c95d8b4de8aea99b54083d38f10f763686646962b5627e8e2b2db113a37b", - "sha256:c98e1044785ab71f0fee70f64b8d56f69df9de1b593793022e001ba2f6b76dd0", - "sha256:caf0715b8a5e20fc3faf21a24abd3ae513f8f58206dd32d1b87eca6351e105ed", - "sha256:cda41132a45dfbf6984dade1f531a4098c813caf266c66cc446d90bb9369cabd", - "sha256:d8aa2bae7d0523963395f802ae2212e8b2248d4503a14a691de86edf716b22d3", - "sha256:e53cc280a1ab616f191ac7ebdd1f38f2aa78b1411dd2677dc556c6f0fa085913", - "sha256:fcaed03cefa53f62346091ef92da7a6f44bae6830a6f4c6b097a70cdc31b1199" + "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", + "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", + "sha256:23163921dccf3103ce59540b0443c106d2c0a0ff2e0503e05196f5e6fdea453f", + "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", + "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", + "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", + "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", + "sha256:3990fffcc6a6045f2234ab72752ad037e3b2d48c72037f244d42738db397eb75", + "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", + "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", + "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", + "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", + "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", + "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", + "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", + "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", + "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", + "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", + "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", + "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", + "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", + "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", + "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", + "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", + "sha256:bba8bea1b28d927b3e99e47deafe53658d34497c0a891d95ff1ba8ff6663f01c", + "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", + "sha256:c0c79b13c9908ac7dfe0a74116ebc9a0f28b2319d23c32f3dfcdfbe1279c7eaf", + "sha256:c7116b0452994734ccff35e154b44240090eb0f4f74b9106292668133557c175", + "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", + "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", + "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", + "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", + "sha256:cfb54563fe5f130da17c44c6a4e2e8052ba628e5ab4eab7ef8190f736f0f8f72", + "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", + "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", + "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131" ], "markers": "python_version >= '3.10'", - "version": "==7.4.1" + "version": "==7.4.3" }, "charset-normalizer": { "hashes": [ @@ -2839,11 +2884,11 @@ }, "click": { "hashes": [ - "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", - "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" + "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", + "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613" ], "markers": "python_version >= '3.10'", - "version": "==8.3.2" + "version": "==8.3.3" }, "click-didyoumean": { "hashes": [ @@ -3069,67 +3114,67 @@ }, "crc-bonfire": { "hashes": [ - "sha256:7fa3b2e739a92c2ffdc3f43ba590201dff64d6993603fc43deb10f3f0c2d903a", - "sha256:d9a32ad91714dd0c00c56a405add63df4896dfeb58994ce5f9688dde7cd4298a" + "sha256:54c60db35846eeffc515013b7966d6313e1b12d4b1df433271fd29cfd581207a", + "sha256:cceb257faf44b7116976d822518d12ab3ec329951078199aa577dff189a61e77" ], "index": "pypi", - "markers": "python_version >= '3.6'", - "version": "==6.9.1" + "markers": "python_version >= '3.10'", + "version": "==6.12.0" }, "cryptography": { "hashes": [ - "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", - "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", - "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", - "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", - "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", - "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", - "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", - "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", - "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", - "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", - "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", - "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", - "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", - "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", - "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", - "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", - "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", - "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", - "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", - "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", - "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", - "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", - "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", - "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", - "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", - "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", - "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", - "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", - "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", - "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", - "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", - "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", - "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", - "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", - "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", - "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", - "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", - "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", - "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", - "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", - "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", - "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", - "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", - "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", - "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", - "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", - "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", - "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", - "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce" + "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", + "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", + "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", + "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", + "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", + "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", + "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", + "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", + "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", + "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", + "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", + "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", + "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", + "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", + "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", + "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", + "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", + "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", + "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", + "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", + "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", + "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", + "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", + "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", + "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", + "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", + "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", + "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", + "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", + "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", + "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", + "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", + "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", + "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", + "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", + "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", + "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", + "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", + "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", + "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", + "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", + "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", + "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", + "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", + "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", + "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", + "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", + "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", + "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736" ], "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==46.0.7" + "version": "==47.0.0" }, "cycler": { "hashes": [ @@ -3208,20 +3253,20 @@ }, "faker": { "hashes": [ - "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", - "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019" + "sha256:20f3a6ec8c266b74d4c554e34118b21c3c2056c0b4a519d15c8decb3a4e6e795", + "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==40.13.0" + "version": "==40.15.0" }, "filelock": { "hashes": [ - "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", - "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70" + "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", + "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258" ], "markers": "python_version >= '3.10'", - "version": "==3.25.2" + "version": "==3.29.0" }, "flake8": { "hashes": [ @@ -3302,20 +3347,20 @@ "grpc" ], "hashes": [ - "sha256:9a8113e1a88bdc09a7ff629707f2214d98d61c7f6ceb0ea38c42a095d02dc0f9", - "sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594" + "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", + "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b" ], "markers": "python_version >= '3.9'", - "version": "==2.30.2" + "version": "==2.30.3" }, "google-auth": { "hashes": [ - "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", - "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7" + "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", + "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==2.49.1" + "version": "==2.49.2" }, "google-cloud-bigquery": { "hashes": [ @@ -3398,25 +3443,6 @@ "markers": "python_version >= '3.9'", "version": "==1.74.0" }, - "gql": { - "extras": [ - "requests" - ], - "hashes": [ - "sha256:f22980844eb6a7c0266ffc70f111b9c7e7c7c13da38c3b439afc7eab3d7c9c8e", - "sha256:f3beed7c531218eb24d97cb7df031b4a84fdb462f4a2beb86e2633d395937479" - ], - "markers": "python_full_version >= '3.8.1'", - "version": "==4.0.0" - }, - "graphql-core": { - "hashes": [ - "sha256:015457da5d996c924ddf57a43f4e959b0b94fb695b85ed4c29446e508ed65cf3", - "sha256:cbee07bee1b3ed5e531723685369039f32ff815ef60166686e0162f540f1520c" - ], - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==3.2.8" - }, "grpcio": { "hashes": [ "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", @@ -3502,19 +3528,19 @@ }, "identify": { "hashes": [ - "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", - "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737" + "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", + "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842" ], "markers": "python_version >= '3.10'", - "version": "==2.6.18" + "version": "==2.6.19" }, "idna": { "hashes": [ - "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", - "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" + "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", + "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3" ], "markers": "python_version >= '3.8'", - "version": "==3.11" + "version": "==3.13" }, "isodate": { "hashes": [ @@ -3540,14 +3566,6 @@ "markers": "python_version >= '3.9'", "version": "==1.1.0" }, - "junitparser": { - "hashes": [ - "sha256:9e279f2214dc74b6a86b22db757abda2e8e66e819fe882dad5b392d57024cd26", - "sha256:f15e292877258d7c5755d672ce86f82c3622c7ea4c2f44f55de44ed7518484d3" - ], - "markers": "python_version >= '3.10'", - "version": "==5.0.0" - }, "kiwisolver": { "hashes": [ "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", @@ -3673,12 +3691,12 @@ }, "koku-nise": { "hashes": [ - "sha256:5ac277879ad686c3f0b9fbd8084e48868a949948f293783d61ad8c219f9a650c", - "sha256:b189f3aad01b698228459a757bfd14af23ec177f582bcf528b897fe02bbae893" + "sha256:2d8c03b9a27f8f9f5caf87ca68b4a6ba23b4d7e6291f69ad0b01655d96e64c51", + "sha256:42b6db673060c9ed23c4bc2badb57fb840a8dccf09d2ec933194aa37e926439d" ], "index": "pypi", "markers": "python_version >= '3.11'", - "version": "==5.4.0" + "version": "==5.4.1" }, "kombu": { "hashes": [ @@ -3794,65 +3812,65 @@ }, "matplotlib": { "hashes": [ - "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", - "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", - "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", - "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", - "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", - "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", - "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", - "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", - "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", - "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", - "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", - "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", - "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", - "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", - "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", - "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", - "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", - "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", - "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", - "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", - "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", - "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", - "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", - "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", - "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", - "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", - "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", - "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", - "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", - "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", - "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", - "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", - "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", - "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", - "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", - "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", - "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", - "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", - "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", - "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", - "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", - "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", - "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", - "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", - "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", - "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", - "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", - "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", - "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", - "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", - "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", - "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", - "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", - "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", - "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7" + "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", + "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", + "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", + "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", + "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", + "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", + "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", + "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", + "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", + "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", + "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", + "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", + "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", + "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", + "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", + "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", + "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", + "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", + "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", + "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", + "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", + "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", + "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", + "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", + "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", + "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", + "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", + "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", + "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", + "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", + "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", + "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", + "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", + "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", + "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", + "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", + "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", + "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", + "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", + "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", + "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", + "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", + "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", + "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", + "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", + "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", + "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", + "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", + "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", + "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", + "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", + "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", + "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", + "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", + "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==3.10.8" + "version": "==3.10.9" }, "mccabe": { "hashes": [ @@ -3871,158 +3889,6 @@ "markers": "python_version >= '3.10'", "version": "==1.23.4" }, - "multidict": { - "hashes": [ - "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0", - "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", - "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", - "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", - "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941", - "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", - "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", - "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", - "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", - "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f", - "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", - "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8", - "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", - "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", - "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", - "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", - "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", - "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", - "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", - "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", - "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5", - "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", - "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", - "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", - "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", - "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", - "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", - "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", - "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", - "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", - "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", - "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", - "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", - "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", - "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", - "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2", - "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", - "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", - "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", - "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", - "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", - "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", - "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", - "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", - "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", - "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", - "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", - "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", - "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", - "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", - "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", - "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", - "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", - "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", - "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", - "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", - "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", - "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", - "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", - "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", - "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f", - "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0", - "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", - "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", - "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", - "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", - "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0", - "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9", - "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", - "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", - "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", - "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", - "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", - "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", - "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", - "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", - "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", - "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", - "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", - "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", - "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", - "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", - "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", - "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", - "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", - "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", - "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", - "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", - "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", - "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8", - "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", - "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", - "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", - "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", - "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", - "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", - "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", - "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", - "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", - "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5", - "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de", - "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", - "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", - "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", - "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", - "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", - "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", - "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f", - "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", - "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", - "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", - "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", - "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", - "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358", - "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", - "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e", - "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", - "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", - "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", - "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", - "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", - "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", - "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", - "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", - "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", - "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", - "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", - "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", - "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", - "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", - "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", - "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", - "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", - "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", - "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", - "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", - "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a", - "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", - "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", - "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", - "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4", - "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", - "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", - "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", - "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", - "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" - ], - "markers": "python_version >= '3.9'", - "version": "==6.7.1" - }, "nodeenv": { "hashes": [ "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", @@ -4117,29 +3983,14 @@ "markers": "python_version >= '3.8'", "version": "==3.3.1" }, - "ocviapy": { - "hashes": [ - "sha256:77602d07d1f124e6f020e08ce21379e8a12cbefa98b168e697fdc202c770f9e2", - "sha256:e5558ec5c46d2793949c0b2fd1c99759d5b5ee564e2ac9ec56fd200c94dcaef4" - ], - "markers": "python_version >= '3.6'", - "version": "==1.6.0" - }, "packaging": { "hashes": [ - "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", - "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" + "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", + "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==26.0" - }, - "parsedatetime": { - "hashes": [ - "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", - "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b" - ], - "version": "==2.6" + "version": "==26.2" }, "pillow": { "hashes": [ @@ -4265,12 +4116,12 @@ }, "pre-commit": { "hashes": [ - "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", - "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61" + "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", + "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==4.5.1" + "version": "==4.6.0" }, "prometheus-client": { "hashes": [ @@ -4289,134 +4140,6 @@ "markers": "python_version >= '3.8'", "version": "==3.0.52" }, - "propcache": { - "hashes": [ - "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", - "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", - "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", - "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", - "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", - "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", - "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", - "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", - "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", - "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", - "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", - "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", - "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", - "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", - "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", - "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", - "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", - "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", - "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", - "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", - "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", - "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", - "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", - "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", - "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", - "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", - "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", - "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", - "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", - "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", - "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", - "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", - "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", - "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", - "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", - "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", - "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", - "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", - "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", - "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", - "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", - "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", - "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", - "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", - "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", - "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", - "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", - "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", - "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", - "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", - "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", - "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", - "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", - "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", - "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", - "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", - "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", - "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", - "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", - "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", - "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", - "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", - "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", - "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", - "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", - "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", - "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", - "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", - "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", - "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", - "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", - "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", - "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", - "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", - "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", - "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", - "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", - "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", - "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", - "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", - "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", - "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", - "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", - "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", - "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", - "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", - "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", - "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", - "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", - "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", - "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", - "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", - "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", - "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", - "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", - "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", - "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", - "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", - "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", - "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", - "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", - "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", - "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", - "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", - "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", - "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", - "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", - "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", - "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", - "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", - "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", - "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", - "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", - "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", - "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", - "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", - "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", - "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", - "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", - "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", - "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", - "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781" - ], - "markers": "python_version >= '3.9'", - "version": "==0.4.1" - }, "proto-plus": { "hashes": [ "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", @@ -4470,7 +4193,7 @@ "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" ], - "markers": "implementation_name != 'PyPy'", + "markers": "python_version >= '3.10'", "version": "==3.0" }, "pydocstyle": { @@ -4512,7 +4235,7 @@ "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.9.0.post0" }, "python-discovery": { @@ -4523,14 +4246,6 @@ "markers": "python_version >= '3.8'", "version": "==1.2.2" }, - "python-dotenv": { - "hashes": [ - "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", - "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" - ], - "markers": "python_version >= '3.10'", - "version": "==1.2.2" - }, "pytz": { "hashes": [ "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", @@ -4644,14 +4359,6 @@ "markers": "python_version >= '3.4'", "version": "==2.0.0" }, - "requests-toolbelt": { - "hashes": [ - "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", - "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.0" - }, "responses": { "hashes": [ "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", @@ -4663,26 +4370,18 @@ }, "s3transfer": { "hashes": [ - "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", - "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920" + "sha256:61bcd00ccb83b21a0fe7e91a553fff9729d46c83b4e0106e7c314a733891f7c2", + "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524" ], "markers": "python_version >= '3.9'", - "version": "==0.16.0" - }, - "sh": { - "hashes": [ - "sha256:653227a7c41a284ec5302173fbc044ee817c7bad5e6e4d8d55741b9aeb9eb65b", - "sha256:e0b15b4ae8ffcd399bc8ffddcbd770a43c7a70a24b16773fbb34c001ad5d52af" - ], - "markers": "python_version < '4.0' and python_full_version >= '3.8.1'", - "version": "==2.2.2" + "version": "==0.16.1" }, "six": { "hashes": [ "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.17.0" }, "snakeviz": { @@ -4699,7 +4398,7 @@ "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895" ], - "markers": "python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version not in '3.0, 3.1, 3.2'", "version": "==3.0.1" }, "sqlparse": { @@ -4711,14 +4410,6 @@ "markers": "python_version >= '3.8'", "version": "==0.5.5" }, - "tabulate": { - "hashes": [ - "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", - "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3" - ], - "markers": "python_version >= '3.10'", - "version": "==0.10.0" - }, "tblib": { "hashes": [ "sha256:26bdccf339bcce6a88b2b5432c988b266ebbe63a4e593f6b578b1d2e723d2b76", @@ -4753,14 +4444,6 @@ "markers": "python_version >= '3.9'", "version": "==4.26.0" }, - "truststore": { - "hashes": [ - "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", - "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981" - ], - "markers": "python_version >= '3.10'", - "version": "==0.10.4" - }, "typing-extensions": { "hashes": [ "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", @@ -4771,11 +4454,11 @@ }, "tzdata": { "hashes": [ - "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", - "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98" + "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", + "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7" ], "markers": "python_version >= '2'", - "version": "==2026.1" + "version": "==2026.2" }, "tzlocal": { "hashes": [ @@ -4803,19 +4486,11 @@ }, "virtualenv": { "hashes": [ - "sha256:b66ffe81301766c0d5e2208fc3576652c59d44e7b731fc5f5ed701c9b537fa78", - "sha256:bd16b49c53562b28cf1a3ad2f36edb805ad71301dee70ddc449e5c88a9f919a2" + "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", + "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e" ], "markers": "python_version >= '3.8'", - "version": "==21.2.1" - }, - "wait-for": { - "hashes": [ - "sha256:1129f3350e29b0600889e24328d685a6bff048c8f4cabce28ef7632ed40c5d91", - "sha256:5642975f1fc5850acb55684b2d7842bd820fb068e725cd4ffff4bf3eba8e2788" - ], - "markers": "python_version >= '3.6'", - "version": "==1.2.0" + "version": "==21.3.0" }, "watchdog": { "hashes": [ @@ -4869,140 +4544,6 @@ ], "markers": "python_version >= '3.9'", "version": "==1.9.0" - }, - "yarl": { - "hashes": [ - "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", - "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", - "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", - "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", - "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", - "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", - "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", - "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", - "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", - "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", - "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", - "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", - "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", - "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", - "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", - "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", - "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", - "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", - "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", - "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", - "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", - "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", - "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", - "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", - "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", - "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", - "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", - "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", - "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", - "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", - "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", - "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", - "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", - "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", - "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", - "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", - "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", - "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", - "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", - "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", - "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", - "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", - "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", - "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", - "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", - "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", - "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", - "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", - "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", - "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", - "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", - "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", - "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", - "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", - "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", - "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", - "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", - "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", - "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", - "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", - "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", - "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", - "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", - "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", - "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", - "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", - "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", - "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", - "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", - "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", - "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", - "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", - "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", - "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", - "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", - "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", - "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", - "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", - "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", - "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", - "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", - "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", - "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", - "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", - "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", - "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", - "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", - "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", - "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", - "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", - "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", - "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", - "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", - "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", - "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", - "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", - "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", - "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", - "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", - "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", - "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", - "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", - "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", - "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", - "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", - "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", - "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", - "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", - "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", - "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", - "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", - "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", - "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", - "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", - "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", - "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", - "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", - "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", - "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", - "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", - "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", - "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", - "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", - "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", - "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", - "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", - "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", - "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d" - ], - "markers": "python_version >= '3.10'", - "version": "==1.23.0" } } } diff --git a/docs/architecture/api-settings-endpoints.md b/docs/architecture/api-settings-endpoints.md index a08ac23034..9c9d0b7ac7 100644 --- a/docs/architecture/api-settings-endpoints.md +++ b/docs/architecture/api-settings-endpoints.md @@ -516,6 +516,80 @@ PUT /settings/aws_category_keys/disable/ --- +## Currency Enablement + +### **Purpose** +Control which ISO 4217 currencies are available for selection across the tenant. Only enabled currencies can be used in account settings, cost models, and report filters. + +### **Endpoints** + +#### List Enabled Currencies +``` +GET /currency/ +``` + +Returns only the currencies that an administrator has enabled via the `EnabledCurrency` table. Metadata (name, symbol, description) is computed at response time via babel. + +**Query Parameters:** +- `limit` (integer) - Results per page +- `offset` (integer) - Pagination offset + +**Response:** +```json +{ + "meta": { + "count": 2 + }, + "data": [ + { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "description": "EUR (€) - Euro" + }, + { + "code": "USD", + "name": "US Dollar", + "symbol": "$", + "description": "USD ($) - US Dollar" + } + ] +} +``` + +#### Enable a Currency +``` +POST /settings/currency/exchange_rate//enable/ +``` + +#### Disable a Currency +``` +DELETE /settings/currency/exchange_rate//enable/ +``` + +Enable or disable a single currency by its ISO 4217 code in the URL path. + +**Path Parameters:** +- `code` (string) - ISO 4217 currency code (case-insensitive, normalized to uppercase) + +**Response:** `204 No Content` + +**Error Responses:** + +**Invalid Currency Code (400 Bad Request):** +```json +{ + "code": ["Invalid ISO 4217 currency code: INVALID"] +} +``` + +**Behavior:** +- `POST` — idempotently creates an `EnabledCurrency` row for the code +- `DELETE` — idempotently deletes the `EnabledCurrency` row if it exists +- Currency code in the URL is case-insensitive (`/settings/currency/exchange_rate/usd/enable/` enables `USD`) + +--- + ## Cost Groups (OpenShift) ### **Purpose** diff --git a/docs/architecture/constant-currency/README.md b/docs/architecture/constant-currency/README.md index 0ea28d5dd6..3d8824e15d 100644 --- a/docs/architecture/constant-currency/README.md +++ b/docs/architecture/constant-currency/README.md @@ -126,25 +126,22 @@ again. **Problem**: Should all currencies returned by the exchange rate API be immediately available for use, or should an administrator explicitly enable them? -**Resolution**: Explicit enablement. Currencies fetched from the dynamic exchange -rate API arrive in Cost Management as **disabled** by default (stored in the -`EnabledCurrency` table with `enabled=False`). An administrator must explicitly -enable currencies through the Settings UI before they appear in the target -currency dropdown. +**Resolution**: Explicit enablement. The full list of known currencies comes from +Babel's ISO 4217 registry. Only currencies that an administrator has explicitly +enabled are stored in the `EnabledCurrency` table. An administrator must enable +currencies through the Settings API (`POST settings/currency/enabled-currencies/{code}/`) before +they appear in the target currency dropdown. All currencies are always stored in `MonthlyExchangeRate` regardless of their -enabled status — the `enabled` flag only controls dropdown visibility, not -data storage. This ensures the underlying data is complete and +enabled status — the `EnabledCurrency` table only controls dropdown visibility, +not data storage. This ensures the underlying data is complete and ready when an administrator enables a currency. **Rationale**: Explicit enablement gives administrators control over which currencies appear in their UI. In on-premise environments, customers may only -need a small subset of the ~170 currencies available from the API. Showing all -currencies by default would clutter the dropdown. +need a small subset of the ~300 ISO 4217 currencies. Showing all currencies by +default would clutter the dropdown. -**Exception**: Static exchange rate pairs always make their currencies available -in the dropdown, regardless of `EnabledCurrency` status. If an administrator -defines a `USD→EUR` static rate, both `USD` and `EUR` are immediately available. ### IQ-6: Rate resolution without `CURRENCY_URL` — RESOLVED @@ -153,15 +150,17 @@ configured (e.g., airgapped or disconnected deployments)? **Resolution**: The system does not require `CURRENCY_URL` to function. Rate resolution follows a simple priority: **static rates first, dynamic rates as -fallback, error if neither exists** for a given currency pair. When -`CURRENCY_URL` is empty or unset: +fallback**. When `CURRENCY_URL` is empty or unset: - The daily Celery task skips the API fetch step (no dynamic rates are fetched) - Static exchange rates defined via the CRUD API work normally - If dynamic rates were previously fetched (before the URL was removed), they remain available as fallback -- If no rate exists for a given pair (static or dynamic), the API returns an - actionable error +- If `MonthlyExchangeRate` is completely empty (no rates configured at all), + the feature is inactive — no currencies are enabled, so the user cannot + select a target currency and costs are returned as-is in their original + bill currency +- If rates exist but not for a given pair, the API returns an actionable error The `CURRENCY_URL` setting is documented with the production API URL (`open.er-api.com`) as a reference example. Only the free tier of the Open @@ -170,7 +169,9 @@ Exchange Rates API is supported in this design. **Rationale**: The system should work with whatever data is available rather than treating the absence of `CURRENCY_URL` as a special mode. Customers can define their own exchange rates via the CRUD API regardless of whether dynamic -rates are being fetched. +rates are being fetched. Deployments that never configure exchange rates +continue to work exactly as before — costs are returned in their original +currency with no conversion. ### IQ-7: No-rate corner case — RESOLVED @@ -254,28 +255,27 @@ graph LR API["open.er-api.com
(or custom URL)"] -->|"daily fetch
(skipped if no URL)"| CT["Celery Task:
get_daily_currency_rates"] CT -->|upsert| ER["ExchangeRates
(public schema)"] CT -->|rebuild| ERD["ExchangeRateDictionary
(public schema)"] - CT -->|"discover currencies
create as disabled"| EC["EnabledCurrency
(tenant schema)
enabled/disabled per currency"] + CT -->|"no currency discovery"| EC["EnabledCurrency
(tenant schema)
admin-managed"] CT -->|"Writer 1: per-tenant
skip static pairs
all currencies"| MER["MonthlyExchangeRate
(tenant schema)
single source of truth"] MER -->|"all months:
per-month rates"| QH["QueryHandler
Subquery annotation"] QH -->|"per-month rates +
rate metadata"| REPORT["Report Response
+ exchange_rates_applied"] - QH -->|"no rate? →
actionable error"| ERR["Error: no exchange rate
available"] + QH -->|"no rate +
feature active? →
actionable error"| ERR["Error: no exchange rate
available"] ADMIN["CM Admin"] -->|"enable/disable
currencies"| EC USER["Price List Admin"] -->|CRUD| SER["Serializer"] SER -->|"write canonical
rate record"| STATIC["StaticExchangeRate
(tenant schema)"] SER -->|"Writer 2: upsert
rate_type=static"| MER - EC -->|"dropdown filter:
enabled dynamic ∪
static rate currencies"| DD["Target Currency
Dropdown"] - STATIC -->|"static currencies
always available"| DD + EC -->|"dropdown filter:
enabled currencies only"| DD["Target Currency
Dropdown"] ``` **Key changes**: 1. **Single source of truth**: `MonthlyExchangeRate` stores rates for all months (current and past); query handlers read from this one table 2. **Two writers**: Celery task writes dynamic rates daily for the current month; CRUD serializer writes static rates for affected months -3. **Rate resolution**: All months read from `MonthlyExchangeRate`; M2 migration seeds current-month data at deployment; pre-deployment months fall back to earliest available rate; error if no rate exists at all for a currency pair +3. **Rate resolution**: All months read from `MonthlyExchangeRate`; M2 migration seeds current-month data at deployment; pre-deployment months fall back to earliest available rate. When `MonthlyExchangeRate` is empty (feature not configured), no currencies are enabled and costs are returned as-is; when rows exist but not for the target currency, an actionable error is returned 4. Report responses include rate provenance metadata 5. **Currency enablement**: Dynamic currencies arrive as disabled; administrator enables them via Settings to make them visible in the dropdown (all currencies are always stored) -6. **Dropdown visibility**: Target currency dropdown shows only the union of enabled dynamic currencies and static rate currencies (disabled currencies are stored but hidden from the dropdown) -7. **No-rate error**: If user selects a currency with no conversion path from the bill currency, an actionable error is returned +6. **Dropdown visibility**: Target currency dropdown shows only currencies that an administrator has explicitly enabled (static rate currencies still require enablement) +7. **No-rate handling**: If `MonthlyExchangeRate` is empty, the feature is inactive — no currencies are enabled, so costs are returned as-is. If rows exist but not for the selected currency, an actionable error is returned --- @@ -289,14 +289,13 @@ graph LR | 4 | **No multi-hop conversion** | No chain conversion (e.g., USD→EUR→CNY) to avoid prioritization complexity | | 5 | **Bidirectional implicit inverse** | USD→EUR at 0.87 implies EUR→USD = 1/0.87 unless explicitly defined | | 6 | **Natural month boundaries** | Start/end dates must align to first/last day of month; no mid-month validity periods | -| 7 | **Simple integer versioning** | Auto-increment on `StaticExchangeRate.version`; Phase 2 adds full audit history | -| 8 | **Automatic finalized month locking** | Dynamic rows overwritten daily during current month; untouched after month ends | -| 9 | **Forward-only with current-month seed** | M2 migration seeds current-month data from `ExchangeRateDictionary`; pre-deployment months fall back to earliest available rate | -| 10 | **Per-pair rows, not JSON blob** | Enables `unique_together` constraint, simpler queries, cleaner ORM integration | -| 11 | **Explicit currency enablement** | Dynamic currencies arrive disabled; administrator enables them in Settings to control which currencies appear in the dropdown. All currencies are always stored regardless of enabled status. | -| 12 | **Configurable exchange rate URL** | `CURRENCY_URL` is a variable; empty value skips dynamic rate fetching. System works with whatever rates are available (static first, dynamic fallback, error if neither). Documentation references `open.er-api.com` (free tier) as the production example | -| 13 | **Show-then-error for no-rate currencies** | Available currencies appear in dropdown even without a conversion path from the bill currency; actionable error returned on selection | -| 14 | **Static rates bypass enablement** | Currencies in static exchange rate pairs are always available in dropdowns regardless of `EnabledCurrency` status | +| 7 | **Automatic finalized month locking** | Dynamic rows overwritten daily during current month; untouched after month ends | +| 8 | **Forward-only with current-month seed** | M2 migration seeds current-month data from `ExchangeRateDictionary`; pre-deployment months fall back to earliest available rate | +| 9 | **Per-pair rows, not JSON blob** | Enables `unique_together` constraint, simpler queries, cleaner ORM integration | +| 10 | **Explicit currency enablement** | Dynamic currencies arrive disabled; administrator enables them in Settings to control which currencies appear in the dropdown. All currencies are always stored regardless of enabled status. | +| 11 | **Configurable exchange rate URL** | `CURRENCY_URL` is a variable; empty value skips dynamic rate fetching. System works with whatever rates are available (static first, dynamic fallback). Documentation references `open.er-api.com` (free tier) as the production example | +| 12 | **Show-then-error for no-rate currencies** | Available currencies appear in dropdown even without a conversion path from the bill currency; actionable error returned on selection. When `MonthlyExchangeRate` is empty (no rates configured at all), the feature is inactive — no currencies are enabled and costs are returned as-is | +| 13 | **Enablement is always required for reports** | Static exchange rate currencies must still be explicitly enabled to appear in the report dropdown. The settings admin page shows them regardless for management purposes. | --- @@ -313,3 +312,7 @@ graph LR | v1.6 | 2026-03-30 | Removed `ExchangeRateDictionary` fallback from query handler. M2 seeds current-month data. Decision #9 updated. | | v1.7 | 2026-04-12 | Updated data flow diagram: query handler uses `Subquery` annotation instead of `Case`/`When`. | | v1.8 | 2026-04-13 | Synced pre-deployment month references: fall back to earliest available rate (aligns with pipeline-changes.md v2.1). | +| v1.9 | 2026-04-28 | Updated currency enablement URL reference to `settings/currency/enabled-currencies/{code}/`. | +| v2.0 | 2026-04-28 | Removed static-rate enablement bypass (decision #13). Report dropdown governed solely by `EnabledCurrency`; settings admin page shows static rates regardless. | +| v2.1 | 2026-04-28 | Added "costs as-is" behavior: when `MonthlyExchangeRate` is empty, feature is inactive, validation skipped, costs returned in original currency. Updated IQ-6, decision #12, data flow key changes. | +| v2.2 | 2026-04-30 | Fixed currency enablement URL to `settings/currency/enabled-currencies/{code}/`. Clarified "costs as-is": no currencies enabled means serializer blocks currency selection; costs returned as-is. | diff --git a/docs/architecture/constant-currency/api-and-frontend.md b/docs/architecture/constant-currency/api-and-frontend.md index 7b067f41e2..531e782fc7 100644 --- a/docs/architecture/constant-currency/api-and-frontend.md +++ b/docs/architecture/constant-currency/api-and-frontend.md @@ -7,25 +7,22 @@ OpenAPI updates. --- -## New CRUD Endpoint: Exchange Rate Pairs +## Exchange Rate Endpoint ### URL ``` -GET/POST /api/cost-management/v1/exchange-rate-pairs/ -GET/PUT/DELETE /api/cost-management/v1/exchange-rate-pairs/{uuid}/ +GET/POST /api/cost-management/v1/settings/currency/exchange_rate/ +PUT/DELETE /api/cost-management/v1/settings/currency/exchange_rate/{uuid}/ +POST/DELETE /api/cost-management/v1/settings/currency/enabled-currencies/{code}/ ``` ### Registration -**File**: `koku/cost_models/urls.py` +**File**: `koku/api/urls.py` -Register `StaticExchangeRateViewSet` on the existing `DefaultRouter` as -`"exchange-rate-pairs"`. - -```python -router.register(r"exchange-rate-pairs", StaticExchangeRateViewSet, basename="exchange-rate-pairs") -``` +Registered as explicit `path()` entries mapping to `StaticExchangeRateViewSet` +and `EnabledCurrencyView`. ### Query Parameters @@ -38,22 +35,24 @@ router.register(r"exchange-rate-pairs", StaticExchangeRateViewSet, basename="exc ### View -**File**: New `koku/cost_models/static_exchange_rate_view.py` - -```python -class StaticExchangeRateViewSet(viewsets.ModelViewSet): - queryset = StaticExchangeRate.objects.all() - serializer_class = StaticExchangeRateSerializer - lookup_field = "uuid" - permission_classes = (CostModelsAccessPermission,) -``` +**File**: `koku/cost_models/static_exchange_rate_view.py` -Follows the pattern from `CostModelViewSet` in `koku/cost_models/view.py`. -All operations run under tenant context (handled by `django-tenants` middleware). +The `StaticExchangeRateViewSet` handles CRUD for exchange rates. The `list` +action returns exchange rates grouped by base currency with enabled status +(via `CurrencyExchangeRateSerializer`). All other actions use the flat +`StaticExchangeRateSerializer`. **Permission**: `CostModelsAccessPermission` — requires the **Price List Administrator** role. Same permission used for cost model CRUD. +**File**: `koku/api/settings/currency_views.py` + +The `EnabledCurrencyView` handles currency enablement via POST (enable) and +DELETE (disable). No request body required. + +**Permission**: `SettingsAccessPermission` — requires the **Cost Management +Administrator** role. + ### Serializer **File**: New `koku/cost_models/static_exchange_rate_serializer.py` @@ -66,7 +65,6 @@ Administrator** role. Same permission used for cost model CRUD. | Different currencies | `base_currency != target_currency` | | Month boundaries | `start_date` must be 1st of month; `end_date` must be last day of a month | | No overlap | No overlapping validity periods for same directional `(base, target)` pair | -| Version | Auto-increment `version` on update | | Name | Read-only computed field: `"{base_currency}-{target_currency}"` | **Side effects** (see [pipeline-changes.md § Writer 2](./pipeline-changes.md#static-rate--monthlyexchangerate-upsert--writer-2)): @@ -84,35 +82,45 @@ together with the `StaticExchangeRate` write. If any side effect fails, the proactively populates `rate_type=RateType.DYNAMIC` rows from the current `ExchangeRateDictionary` to avoid a data gap until the next daily Celery run -### Example: List Response +### Example: GET List Response + +The list endpoint returns exchange rates grouped by base currency. Each +currency entry includes its enabled status and a nested list of exchange rates. +Only currencies with at least one `StaticExchangeRate` record appear. ```json { - "meta": { "count": 2 }, + "meta": { "count": 1 }, "data": [ { - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "name": "USD-EUR", - "base_currency": "USD", - "target_currency": "EUR", - "exchange_rate": "0.870000000000000", - "start_date": "2026-01-01", - "end_date": "2026-03-31", - "version": 1, - "created_timestamp": "2026-01-15T10:30:00Z", - "updated_timestamp": "2026-01-15T10:30:00Z" - }, - { - "uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "name": "USD-GBP", - "base_currency": "USD", - "target_currency": "GBP", - "exchange_rate": "0.740000000000000", - "start_date": "2026-01-01", - "end_date": "2026-06-30", - "version": 2, - "created_timestamp": "2026-01-15T10:30:00Z", - "updated_timestamp": "2026-02-01T14:00:00Z" + "code": "USD", + "name": "US Dollar", + "symbol": "$", + "enabled": true, + "exchange_rates": [ + { + "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "USD-EUR", + "base_currency": "USD", + "target_currency": "EUR", + "exchange_rate": "0.870000000000000", + "start_date": "2026-01-01", + "end_date": "2026-03-31", + "created_timestamp": "2026-01-15T10:30:00Z", + "updated_timestamp": "2026-01-15T10:30:00Z" + }, + { + "uuid": "b2c3d4e5-f6a7-8901-bcde-f12345678901", + "name": "USD-GBP", + "base_currency": "USD", + "target_currency": "GBP", + "exchange_rate": "0.740000000000000", + "start_date": "2026-01-01", + "end_date": "2026-06-30", + "created_timestamp": "2026-01-15T10:30:00Z", + "updated_timestamp": "2026-02-01T14:00:00Z" + } + ] } ] } @@ -136,125 +144,76 @@ This endpoint is always available. No Unleash feature flag gating. --- -## Currency Enablement Settings API +## Currency Enablement + +Currency enablement is managed via a dedicated endpoint under +`settings/currency/enabled-currencies/`. The enabled status for each currency +is also visible in the `GET settings/currency/exchange_rate/` list response and +can be toggled individually. ### URL ``` -GET/PUT /api/cost-management/v1/settings/currency/enabled-currencies/ +POST /api/cost-management/v1/settings/currency/enabled-currencies/{code}/ +DELETE /api/cost-management/v1/settings/currency/enabled-currencies/{code}/ ``` -This endpoint lists all known currencies and their enabled/disabled status, and -allows an administrator to enable or disable currencies. - -### View - -**File**: Extend existing settings views in `koku/api/settings/` or add a new -`EnabledCurrencyViewSet`. - -**Permission**: Cost Management Administrator role (same permission level as -other Settings operations). - -### Example: GET Response +- **POST**: Enables the currency (creates an `EnabledCurrency` row). No request body. +- **DELETE**: Disables the currency (removes the `EnabledCurrency` row). No request body. -```json -{ - "meta": { "count": 5 }, - "data": [ - { "currency_code": "USD", "enabled": true }, - { "currency_code": "EUR", "enabled": true }, - { "currency_code": "GBP", "enabled": false }, - { "currency_code": "CNY", "enabled": false }, - { "currency_code": "JPY", "enabled": false } - ] -} -``` +Both return `204 No Content`. -Currencies with `enabled: false` were discovered by the daily exchange rate API -fetch but have not been enabled by an administrator. They will not appear in the -target currency dropdown until enabled. All currencies are always stored -regardless of their enabled status. +### View -### Example: PUT Request (Enable/Disable) +**File**: `koku/api/settings/currency_views.py` — `EnabledCurrencyView` -```json -{ - "currencies": [ - { "currency_code": "GBP", "enabled": true }, - { "currency_code": "CNY", "enabled": true } - ] -} -``` +**Permission**: `SettingsAccessPermission` — requires the **Cost Management +Administrator** role. **Side effects**: Enabling or disabling a currency only affects its visibility in the target currency dropdown. It does not affect the `MonthlyExchangeRate`, -`ExchangeRateDictionary`, `ExchangeRates`, or `StaticExchangeRate` -tables — all currencies are always stored regardless of their enabled status. +`ExchangeRateDictionary`, `ExchangeRates`, or `StaticExchangeRate` tables. ### No `CURRENCY_URL` Configured -When no `CURRENCY_URL` is configured, no dynamic currencies are discovered by the -Celery task, so the `EnabledCurrency` table will have no dynamically-discovered -rows. The GET response will return either an empty list or only currencies that -were manually added. Previously fetched dynamic currencies (if the URL was -removed later) remain in the table. +When no `CURRENCY_URL` is configured, no dynamic exchange rates are fetched by +the Celery task. The `EnabledCurrency` table only contains currencies that an +administrator has explicitly enabled. The full list of ISO 4217 currencies is +always available from Babel. --- ## Available Currencies for Dropdown -The target currency dropdown in the UI must compute its list of available -currencies from two sources: +The target currency dropdown in the UI shows only currencies that an +administrator has explicitly enabled. -### Availability Rules +### Availability Rule | Source | Rule | Example | |--------|------|---------| -| **Dynamic** | Currency has `enabled=True` in `EnabledCurrency` | USD, EUR enabled → appear in dropdown | -| **Static** | Currency appears in any `StaticExchangeRate` pair (as base or target) | Static rate EUR→CHF defined → both EUR and CHF appear in dropdown regardless of `EnabledCurrency` status | - -### Dropdown Endpoint - -**File**: New endpoint or extend existing currency-related views. - -``` -GET /api/cost-management/v1/settings/currency/available-currencies/ -``` +| **EnabledCurrency** | Currency exists in `EnabledCurrency` table | USD, EUR enabled → appear in dropdown | -Returns the currencies visible to the user — the union of enabled dynamic -currencies and static rate currencies: +Defining a static exchange rate does **not** automatically make its currencies +available in the report dropdown. The administrator must explicitly enable them. -```json -{ - "data": [ - { "currency_code": "USD", "source": "dynamic" }, - { "currency_code": "EUR", "source": "both" }, - { "currency_code": "CHF", "source": "static" }, - { "currency_code": "GBP", "source": "dynamic" } - ] -} -``` - -The `source` field indicates whether the currency is available via dynamic rates, -static rates, or both. This is informational for the frontend. +The settings admin page (`GET settings/currency/exchange_rate/`) shows all +currencies with static rates regardless of enabled status, so the administrator +can see and manage exchange rates without needing to enable currencies first. ### No Currencies Available -When **no currencies are available at all** — meaning: - -- All dynamic currencies are disabled in `EnabledCurrency` (or none exist), **and** -- No `StaticExchangeRate` rows exist (no static rates) - -Then the currency dropdown should either be **hidden** or show a message: +When no currencies exist in `EnabledCurrency` (none enabled), the currency +dropdown should either be **hidden** or show a message: *"No exchange rates available."* Whichever approach is simpler to implement. --- ## Corner Case: No Exchange Rate -A currency may appear in the dropdown (because it has static or enabled dynamic -rates) but have **no exchange rate path** from the bill's source currency to -the selected target currency. +A currency may appear in the dropdown (because it is enabled) but have **no +exchange rate path** from the bill's source currency to the selected target +currency. **Example**: - Cloud bill arrives in `USD` @@ -262,17 +221,27 @@ the selected target currency. - User wants to see costs in `EUR` - There is no `USD→EUR` rate (static or dynamic) -### Behavior (Preferred Approach) +### Behavior -**Make all available currencies visible** in the dropdown (`EUR`, `CHF`, `CNY`, -`SAR`), but when the user selects a target currency for which no conversion rate -exists from the bill currency, the API returns an error: +There are two distinct cases: + +**1. Feature not configured** (`MonthlyExchangeRate` is empty): When no exchange +rates have been configured at all (no `CURRENCY_URL`, no static rates, no Celery +task run), the constant currency feature is inactive. No currencies are enabled, +so the serializer rejects any explicit `currency` parameter — the user cannot +select a target currency. Without a `currency` parameter, costs are returned +as-is in their original bill currency. The `Coalesce("exchange_rate", Value(1))` +fallback in provider maps ensures NULL annotations resolve to `1` (no +conversion). This is the default state for fresh deployments. + +**2. Feature active but target currency has no rates** (`MonthlyExchangeRate` +has rows but none for the target): The API returns an error: ```json { "errors": [ { - "detail": "No exchange rate available between USD and EUR. Ask your administrator to configure static exchange rates or enable dynamic exchange rates.", + "detail": "No exchange rate available for EUR. Ask your administrator to configure static exchange rates or enable dynamic exchange rates.", "status": 400, "source": "currency" } @@ -284,6 +253,9 @@ The frontend should display this error message to the user. The report data is **not** returned with unconverted amounts — the request fails with a clear, actionable error. +**Make all available currencies visible** in the dropdown (`EUR`, `CHF`, `CNY`, +`SAR`). + **Rationale**: This approach was preferred over filtering the dropdown to only show currencies with available conversion paths because: @@ -317,6 +289,22 @@ transparency on which rates (static vs dynamic) were used and for which periods. "rate": "0.870000000000000", "type": "static", "start_date": "2026-01-01", + "end_date": "2026-01-31" + }, + { + "base_currency": "USD", + "target_currency": "EUR", + "rate": "0.870000000000000", + "type": "static", + "start_date": "2026-02-01", + "end_date": "2026-02-28" + }, + { + "base_currency": "USD", + "target_currency": "EUR", + "rate": "0.870000000000000", + "type": "static", + "start_date": "2026-03-01", "end_date": "2026-03-31" }, { @@ -342,10 +330,15 @@ transparency on which rates (static vs dynamic) were used and for which periods. **Implementation**: The response formatter queries `MonthlyExchangeRate` for the report's date range and target currency -(see [pipeline-changes.md § Rate Resolution](./pipeline-changes.md#rate-resolution-strategy)), -then groups consecutive months with the same rate and type into a single entry -with `start_date` / `end_date` boundaries (first-of-month and last-day-of-month -respectively). +(see [pipeline-changes.md § Rate Resolution](./pipeline-changes.md#rate-resolution-strategy)). +Each `MonthlyExchangeRate` row produces one entry in the array with `start_date` +set to the first of the month and `end_date` to the last day of that month. +Consecutive months with the same rate and type are **not** grouped — each month +is returned as a separate entry. + +Only base currencies that actually appear in the report's cost data are included. +For OCP reports, this means currencies from both `raw_currency` (infrastructure +costs) and cost model currencies are considered. --- @@ -355,14 +348,12 @@ respectively). Add endpoint definitions for: -- `GET /api/cost-management/v1/exchange-rate-pairs/` — list with filters -- `POST /api/cost-management/v1/exchange-rate-pairs/` — create -- `GET /api/cost-management/v1/exchange-rate-pairs/{uuid}/` — retrieve -- `PUT /api/cost-management/v1/exchange-rate-pairs/{uuid}/` — update -- `DELETE /api/cost-management/v1/exchange-rate-pairs/{uuid}/` — delete -- `GET /api/cost-management/v1/settings/currency/enabled-currencies/` — list enabled/disabled currencies -- `PUT /api/cost-management/v1/settings/currency/enabled-currencies/` — enable/disable currencies -- `GET /api/cost-management/v1/settings/currency/available-currencies/` — list available target currencies +- `GET /api/cost-management/v1/settings/currency/exchange_rate/` — list exchange rates grouped by currency (with enabled status) +- `POST /api/cost-management/v1/settings/currency/exchange_rate/` — create exchange rate +- `PUT /api/cost-management/v1/settings/currency/exchange_rate/{uuid}/` — update exchange rate +- `DELETE /api/cost-management/v1/settings/currency/exchange_rate/{uuid}/` — delete exchange rate +- `POST /api/cost-management/v1/settings/currency/enabled-currencies/{code}/` — enable currency +- `DELETE /api/cost-management/v1/settings/currency/enabled-currencies/{code}/` — disable currency Add `exchange_rates_applied` to report response schemas. @@ -378,15 +369,15 @@ and will consume the APIs defined above. The frontend will: -- Add a currency exchange rate table in the Settings "Currency" tab +- Add a currency exchange rate table in the Settings "Currency" tab, using + `GET settings/currency/exchange_rate/` (grouped response with enabled status) - Allow Price List Administrators to add, edit, and remove rate pairs - Display validity periods (start/end month) - Show a note explaining dynamic rates are used when no static rate is defined -- **Add a currency enablement section** in Settings for enabling/disabling - currencies discovered from the exchange rate API -- **Populate the target currency dropdown** from the available-currencies - endpoint (union of enabled dynamic currencies + static rate currencies). - Disabled currencies are stored but hidden from this dropdown. +- **Add a currency enablement toggle** using + `POST/DELETE settings/currency/enabled-currencies/{code}/` +- **Populate the target currency dropdown** from enabled currencies only. + Disabled currencies are stored but hidden from the report dropdown. - **Handle the no-rate error**: When the user selects a target currency that has no conversion path from the bill currency, display the error message returned by the API @@ -418,3 +409,7 @@ The frontend will: | v1.5 | 2026-04-09 | Replaced stale `MonthlyExchangeRateSnapshot` → `MonthlyExchangeRate`, removed `StaticExchangeRateDictionary` references (removed in pipeline-changes v1.6). | | v1.6 | 2026-04-12 | Updated `exchange_rates_applied` implementation to reflect `Subquery`-based rate resolution (removed `effective_exchange_rates` reference). | | v1.7 | 2026-04-13 | Removed stale "snapshotted" terminology (remnant from `MonthlyExchangeRateSnapshot` rename). | +| v1.8 | 2026-04-28 | Consolidated endpoints under `settings/currency/exchange_rate/`. List returns grouped response with enabled status. Currency enablement via POST/DELETE at `settings/currency/enabled-currencies/{code}/` (no body). Removed separate `AllCurrencyView` and `available-currencies` endpoints. | +| v1.9 | 2026-04-28 | Removed static-rate enablement bypass. Report dropdown governed solely by `EnabledCurrency`. Settings admin page shows static rates regardless for management. | +| v2.0 | 2026-04-28 | Added "costs as-is" behavior to Corner Case section: when `MonthlyExchangeRate` is empty, feature is inactive, costs returned in original currency. | +| v2.1 | 2026-04-30 | Fixed currency enablement URLs to `settings/currency/enabled-currencies/{code}/`. Clarified "costs as-is" Corner Case: serializer enforces enabled currencies before query handler validation. | diff --git a/docs/architecture/constant-currency/data-model.md b/docs/architecture/constant-currency/data-model.md index ad91758b11..98cceb0559 100644 --- a/docs/architecture/constant-currency/data-model.md +++ b/docs/architecture/constant-currency/data-model.md @@ -69,7 +69,6 @@ class StaticExchangeRate(models.Model): exchange_rate = models.DecimalField(max_digits=33, decimal_places=15) start_date = models.DateField() # first day of a natural month end_date = models.DateField() # last day of a natural month (or later) - version = models.IntegerField(default=1) created_timestamp = models.DateTimeField(auto_now_add=True) updated_timestamp = models.DateTimeField(auto_now=True) @@ -80,15 +79,15 @@ class StaticExchangeRate(models.Model): **Example `static_exchange_rate` rows**: -| uuid | base_currency | target_currency | exchange_rate | start_date | end_date | version | created_timestamp | updated_timestamp | -|------|---------------|-----------------|---------------|------------|----------|---------|-------------------|-------------------| -| `a1b2c3d4-...` | `USD` | `EUR` | `0.920000000000000` | `2026-01-01` | `2026-03-31` | 1 | `2026-01-15 10:30:00+00` | `2026-01-15 10:30:00+00` | -| `e5f6a7b8-...` | `USD` | `GBP` | `0.780000000000000` | `2026-01-01` | `2026-01-31` | 2 | `2026-01-10 08:00:00+00` | `2026-01-20 14:22:00+00` | -| `c9d0e1f2-...` | `EUR` | `GBP` | `0.848000000000000` | `2026-02-01` | `2026-06-30` | 1 | `2026-02-01 09:00:00+00` | `2026-02-01 09:00:00+00` | +| uuid | base_currency | target_currency | exchange_rate | start_date | end_date | created_timestamp | updated_timestamp | +|------|---------------|-----------------|---------------|------------|----------|-------------------|-------------------| +| `a1b2c3d4-...` | `USD` | `EUR` | `0.920000000000000` | `2026-01-01` | `2026-03-31` | `2026-01-15 10:30:00+00` | `2026-01-15 10:30:00+00` | +| `e5f6a7b8-...` | `USD` | `GBP` | `0.780000000000000` | `2026-01-01` | `2026-01-31` | `2026-01-10 08:00:00+00` | `2026-01-20 14:22:00+00` | +| `c9d0e1f2-...` | `EUR` | `GBP` | `0.848000000000000` | `2026-02-01` | `2026-06-30` | `2026-02-01 09:00:00+00` | `2026-02-01 09:00:00+00` | In this example: - The `USD→EUR` rate of `0.92` applies for Jan–Mar 2026 (overrides dynamic rates for those months) -- The `USD→GBP` rate was updated once (`version=2`) and only covers January +- The `USD→GBP` rate only covers January - The `EUR→GBP` rate covers Feb–Jun 2026 **Constraints** (enforced in serializer validation): @@ -99,7 +98,6 @@ In this example: that same month or a later month - No overlapping validity periods for the same `(base_currency, target_currency)` directional pair -- `version` auto-increments on update (managed by serializer, not DB trigger) **Computed properties**: @@ -115,17 +113,15 @@ defined, each uses its own rate. ### `EnabledCurrency` -Tracks which currencies are visible in the target currency dropdown. Currencies -must be explicitly enabled by an administrator before they appear in the -dropdown. All currencies are always stored in `MonthlyExchangeRate` regardless -of their enabled status — the `enabled` flag only controls dropdown visibility. +Tracks which currencies are enabled for the target currency dropdown. Only +enabled currencies are stored — presence in this table means the currency is +enabled. The full list of known currencies comes from Babel's ISO 4217 registry +at runtime. ```python class EnabledCurrency(models.Model): currency_code = models.CharField(max_length=5, unique=True) - enabled = models.BooleanField(default=False) created_timestamp = models.DateTimeField(auto_now_add=True) - updated_timestamp = models.DateTimeField(auto_now=True) class Meta: db_table = "enabled_currency" @@ -134,50 +130,54 @@ class EnabledCurrency(models.Model): **Example `enabled_currency` rows**: -| id | currency_code | enabled | created_timestamp | updated_timestamp | -|----|---------------|---------|-------------------|-------------------| -| 1 | `USD` | `true` | `2026-01-01 06:00:00+00` | `2026-01-01 06:00:00+00` | -| 2 | `EUR` | `true` | `2026-01-01 06:00:00+00` | `2026-01-15 10:30:00+00` | -| 3 | `GBP` | `false` | `2026-01-01 06:00:00+00` | `2026-01-01 06:00:00+00` | -| 4 | `CNY` | `false` | `2026-01-01 06:00:00+00` | `2026-01-01 06:00:00+00` | -| 5 | `JPY` | `false` | `2026-01-01 06:00:00+00` | `2026-01-01 06:00:00+00` | +| id | currency_code | created_timestamp | +|----|---------------|-------------------| +| 1 | `USD` | `2026-01-01 06:00:00+00` | +| 2 | `EUR` | `2026-01-15 10:30:00+00` | -In this example, `USD` and `EUR` are enabled and will appear in the target -currency dropdown. `GBP`, `CNY`, and `JPY` were discovered by the daily Celery -task (fetched from the exchange rate API) but have not yet been enabled by an -administrator — they are stored in `MonthlyExchangeRate` but hidden from the -dropdown. +In this example, only `USD` and `EUR` are enabled and will appear in the target +currency dropdown. All other ISO 4217 currencies are known via Babel but not +enabled. **Lifecycle**: | Event | Action | |-------|--------| -| Daily Celery task fetches from exchange rate API | Creates `EnabledCurrency` rows with `enabled=False` for any newly discovered currencies not already in the table | -| Administrator enables a currency in Settings | Sets `enabled=True` | -| Administrator disables a currency in Settings | Sets `enabled=False` | +| Administrator enables a currency via `POST settings/currency/enabled-currencies/{code}/` | Creates an `EnabledCurrency` row for that currency | +| Administrator disables a currency via `DELETE settings/currency/enabled-currencies/{code}/` | Removes the `EnabledCurrency` row for that currency | +| `GET settings/currency/exchange_rate/` | Returns currencies that have exchange rates, grouped by base currency, with `enabled` flag based on `EnabledCurrency` table membership | -**How currencies become "available" in dropdowns**: +**How currencies become "available" in the report dropdown**: -A currency is visible in the target currency dropdown if **any** of the -following are true: +A currency is visible in the target currency dropdown only if it exists in the +`EnabledCurrency` table. Defining a static exchange rate does **not** automatically +make its currencies available — the administrator must explicitly enable them. -1. It has `enabled=True` in `EnabledCurrency` -2. It appears in any `StaticExchangeRate` pair (static rates make their currencies - visible regardless of the `EnabledCurrency` status) +The settings admin page (`GET settings/currency/exchange_rate/`) shows all +currencies with static rates regardless of enabled status, so the administrator +can see and manage them. -**Corner case — no usable rate**: A currency may be available in the dropdown but -have no exchange rate path from the bill's source currency. In this case, the API -returns an error: *"No exchange rate available. Ask your administrator to configure -static exchange rates or enable dynamic exchange rates."* See +**Corner case — no usable rate**: A currency may be enabled but have no exchange +rate path from the bill's source currency. If `MonthlyExchangeRate` has rows +(the feature is active) but none for the requested target currency, the API +returns an error: *"No exchange rate available. Ask your administrator to +configure static exchange rates or enable dynamic exchange rates."* See [api-and-frontend.md § Corner Case: No Exchange Rate](./api-and-frontend.md#corner-case-no-exchange-rate). +**No rates configured at all**: When `MonthlyExchangeRate` is completely empty +(no `CURRENCY_URL` configured, no static rates defined, no Celery task run), +the constant currency feature is inactive. No currencies are enabled, so the +serializer rejects any explicit `currency` parameter. Without a `currency` +parameter, costs are returned as-is in their original bill currency. The +`Coalesce(..., Value(1))` fallback in provider maps ensures exchange rate +annotations resolve to `1` (no conversion). This is the default state for +fresh deployments. + **No `CURRENCY_URL` configured**: When the URL is not set, no dynamic currencies are discovered by the Celery task, so no rows are created automatically. The table may still contain previously fetched currencies or manually-created rows. -The system does not treat this as a special mode — it uses whatever rates are -available (static first, dynamic fallback, error if neither exists). If no -currencies are visible (all disabled and no static rates), the currency dropdown -is hidden or shows *"No exchange rates available."* +If no currencies are visible (all disabled and no static rates), the currency +dropdown is hidden or shows *"No exchange rates available."* **Registration points**: None. Accessed via the Settings API (see [api-and-frontend.md § Currency Enablement](./api-and-frontend.md#currency-enablement-settings-api)). @@ -376,7 +376,7 @@ changes required. |---------|------|---------| | v1.0 | 2026-03-19 | Initial data model design | | v1.1 | 2026-03-24 | Added `EnabledCurrency` model, M4 migration | -| v1.2 | 2026-03-24 | Simplified enablement: `enabled` flag only controls dropdown visibility, not snapshotting | +| v1.2 | 2026-03-24 | Simplified enablement: `EnabledCurrency` table controls dropdown visibility, not snapshotting | | v1.3 | 2026-03-24 | Removed airgapped mode concept. Rate resolution: static first, dynamic fallback, error if neither. | | v1.4 | 2026-03-26 | Clarified `StaticExchangeRateDictionary` as source of truth for static rates; `MonthlyExchangeRateSnapshot` as historical rate storage for reports. | | v1.5 | 2026-03-29 | Replaced `year_month` CharField with `effective_date` DateField on `MonthlyExchangeRateSnapshot` for consistency with existing date field patterns (`usage_start`, `billing_period_start`). | @@ -384,3 +384,7 @@ changes required. | v1.7 | 2026-03-30 | M2 now seeds current-month data from `ExchangeRateDictionary` during migration. Eliminates `ExchangeRateDictionary` fallback in query handler. | | v1.8 | 2026-04-12 | Updated reader description to reflect `Subquery`-based rate resolution (replaces `Case`/`When`). | | v1.9 | 2026-04-13 | Fixed `ExchangeRates` model description: actual fields are `currency_type` (CharField) and `exchange_rate` (FloatField), not `base_currency`/`exchange_rates` JSONField. Removed non-existent `updated_timestamp` column from `ExchangeRateDictionary` example. | +| v2.0 | 2026-04-28 | Updated `EnabledCurrency` lifecycle to reflect POST/DELETE per-currency enablement at `settings/currency/enabled-currencies/{code}/`. | +| v2.1 | 2026-04-28 | Removed static-rate enablement bypass. Report dropdown governed solely by `EnabledCurrency`. | +| v2.2 | 2026-04-28 | Added "costs as-is" behavior: when `MonthlyExchangeRate` is empty, feature is inactive, costs returned in original currency. | +| v2.3 | 2026-04-30 | Fixed `EnabledCurrency` lifecycle URLs to `settings/currency/enabled-currencies/{code}/`. Clarified "costs as-is" behavior: serializer blocks non-enabled currencies. | diff --git a/docs/architecture/constant-currency/phased-delivery.md b/docs/architecture/constant-currency/phased-delivery.md index b12ce835d2..3f598d0ed2 100644 --- a/docs/architecture/constant-currency/phased-delivery.md +++ b/docs/architecture/constant-currency/phased-delivery.md @@ -35,26 +35,24 @@ pairs. Show rate provenance in report responses. |----------|------|-------------| | `StaticExchangeRate` model | `koku/cost_models/models.py` | User-defined rate pairs with validity periods | | `MonthlyExchangeRate` model | `koku/cost_models/models.py` | Single source of truth: per-month, per-pair rate storage for all months | -| `EnabledCurrency` model | `koku/cost_models/models.py` | Tracks enabled/disabled currencies per tenant | +| `EnabledCurrency` model | `koku/cost_models/models.py` | Tracks enabled currencies per tenant (presence = enabled) | | Migration M1 | `koku/cost_models/migrations/XXXX_*.py` | Create `static_exchange_rate` table | | Migration M2 | `koku/cost_models/migrations/XXXX_*.py` | Create `monthly_exchange_rate` table + seed current-month data from `ExchangeRateDictionary` | | Migration M3 | `koku/cost_models/migrations/XXXX_*.py` | Create `enabled_currency` table | | Serializer | `koku/cost_models/static_exchange_rate_serializer.py` | Validation + `MonthlyExchangeRate` upsert side-effects | | ViewSet | `koku/cost_models/static_exchange_rate_view.py` | CRUD API for static rates | -| Currency enablement view | `koku/api/settings/` or new file | Settings API for enable/disable currencies | -| Available currencies view | `koku/api/settings/` or new file | Returns available target currencies for dropdown | -| URL registration | `koku/cost_models/urls.py` | Router entry for `exchange-rate-pairs` | -| Settings URL registration | `koku/api/urls.py` or `koku/api/settings/urls.py` | Routes for currency enablement and available currencies endpoints | +| Currency enablement view | `koku/api/settings/currency_views.py` | POST/DELETE enablement for individual currencies | +| URL registration | `koku/api/urls.py` | Routes for `settings/currency/exchange_rate/` (list/create, detail) and `settings/currency/enabled-currencies/` (enable/disable) | | Celery task update | `koku/masu/celery/tasks.py` | Currency discovery, `MonthlyExchangeRate` upsert for all currencies per tenant (skips fetch if no `CURRENCY_URL`) | | Query handler update | `koku/api/query_handler.py` | Read from `MonthlyExchangeRate` for all months (no fallback; M2 seeds current month) | | OCP handler update | `koku/api/report/ocp/query_handler.py` | OCP-specific rate resolution from `MonthlyExchangeRate` | | Forecast handler update | `koku/forecast/forecast.py` | Rate resolution from `MonthlyExchangeRate` | | Report meta update | `koku/api/report/queries.py` | `exchange_rates_applied` metadata, no-rate error handling | -| OpenAPI update | `koku/docs/specs/openapi.json` | New endpoint definitions (exchange-rate-pairs, enabled-currencies, available-currencies) | +| OpenAPI update | `koku/docs/specs/openapi.json` | New endpoint definitions (exchange_rate list/create/update/delete, enable/disable) | | Serializer tests | `koku/cost_models/test/test_static_exchange_rate_serializer.py` | Validation tests | | View tests | `koku/cost_models/test/test_static_exchange_rate_view.py` | CRUD tests | | MonthlyExchangeRate tests | `koku/cost_models/test/test_monthly_exchange_rate.py` | Rate creation, query, locking tests | -| Currency enablement tests | `koku/cost_models/test/test_enabled_currency.py` or `koku/api/settings/test/` | Enable/disable, discovery, available-currencies tests | +| Currency enablement tests | `koku/cost_models/test/test_enabled_currency.py` or `koku/api/settings/test/` | Enable/disable, discovery, currency-config tests | | No-rate error tests | `koku/api/report/test/` | Corner case: error when no conversion path exists | ### Validation @@ -62,8 +60,7 @@ pairs. Show rate provenance in report responses. - [ ] Static rate CRUD: create, read, update, delete via API - [ ] Overlapping validity period rejection returns 400 - [ ] Natural month boundary enforcement (mid-month dates rejected) -- [ ] Auto-increment version on update -- [ ] Bidirectional inverse rate resolution (1/rate when reverse undefined) +- [x] Bidirectional inverse rate resolution (1/rate when reverse undefined) - [ ] Dynamic rate daily `MonthlyExchangeRate` upsert per tenant - [ ] Static rate precedence: task skips pairs with existing static rates - [ ] Finalized month immutability: past month rows never overwritten @@ -75,16 +72,15 @@ pairs. Show rate provenance in report responses. - [ ] Consecutive months with same rate/type collapsed into one period string - [ ] Unit tests pass for serializer, view, MonthlyExchangeRate logic, query handler - [ ] On-prem mode: full functionality without Trino -- [ ] **Currency enablement**: Dynamic currencies arrive as disabled in `EnabledCurrency` -- [ ] **Currency enablement**: Administrator can enable/disable currencies via Settings API +- [ ] **Currency enablement**: Administrator can enable currencies via `POST settings/currency/enabled-currencies/{code}/` - [ ] **Currency enablement**: All currencies are stored in `MonthlyExchangeRate` regardless of enabled status; `enabled` flag only controls dropdown visibility -- [ ] **Rate resolution**: Static rates take precedence over dynamic rates; error returned if neither exists for a given pair +- [ ] **Rate resolution**: Static rates take precedence over dynamic rates; when `MonthlyExchangeRate` is empty (feature not configured), no currencies are enabled and costs returned as-is; when rows exist but not for target, error returned - [ ] **No `CURRENCY_URL`**: Celery task skips API fetch; system works with whatever rates are available -- [ ] **Available currencies**: Dropdown shows only enabled dynamic currencies and static rate currencies (disabled currencies are stored but hidden) -- [ ] **Available currencies**: Static rate currencies appear regardless of `EnabledCurrency` status -- [ ] **No-rate corner case**: Selecting a target currency with no conversion path returns HTTP 400 with actionable error +- [ ] **Available currencies**: Report dropdown shows only enabled currencies (static rates do not bypass enablement) +- [ ] **Costs as-is**: When no exchange rates are configured at all (`MonthlyExchangeRate` empty), no currencies are enabled — serializer rejects any `currency` parameter and costs returned in original bill currency +- [ ] **No-rate corner case**: Selecting a target currency with no conversion path (when rates exist for other currencies) returns HTTP 400 with actionable error - [ ] **No currencies available**: Dropdown hidden or shows "No exchange rates available" when no currencies are available -- [ ] **Currency discovery**: New currencies from API are created as disabled `EnabledCurrency` rows +- [ ] **Currency list**: `GET settings/currency/exchange_rate/` returns currencies grouped by base currency with enabled flag and nested exchange rates ### Rollback @@ -95,9 +91,7 @@ pairs. Show rate provenance in report responses. `MonthlyExchangeRate` upsert, currency discovery) 4. Revert report meta changes in `koku/api/report/queries.py` (remove `exchange_rates_applied` metadata and no-rate error handling) -5. Revert URL registration in `koku/cost_models/urls.py` -6. Revert Settings URL registration (remove currency enablement and - available-currencies endpoints) +5. Revert URL registration in `koku/api/urls.py` (remove `settings/currency/exchange_rate/` and `settings/currency/enabled-currencies/` routes) 7. Drop tables via reverse migration (`migrate_schemas` runs `DeleteModel` for all three new tables: `static_exchange_rate`, `monthly_exchange_rate`, `enabled_currency`) @@ -173,7 +167,7 @@ design would be needed to handle path prioritization. |---------|------|---------| | v1.0 | 2026-03-19 | Initial phased delivery plan | | v1.1 | 2026-03-24 | Added EnabledCurrency artifacts (M4, views, tests), currency enablement and airgapped validation items, R7/R8 risks, updated rollback steps | -| v1.2 | 2026-03-24 | Simplified enablement: `enabled` flag only controls dropdown visibility. All currencies always stored and snapshotted. | +| v1.2 | 2026-03-24 | Simplified enablement: `EnabledCurrency` table controls dropdown visibility. All currencies always stored and snapshotted. | | v1.3 | 2026-03-24 | Removed airgapped mode concept. Rate resolution: static first, dynamic fallback, error if neither. | | v1.4 | 2026-03-26 | Updated artifacts and validation to reflect two-tier rate resolution (dictionaries + snapshots). | | v1.5 | 2026-03-29 | Updated future scalability section: `year_month` CharField replaced by `effective_date` DateField. | @@ -182,3 +176,7 @@ design would be needed to handle path prioritization. | v1.8 | 2026-04-12 | Fixed R6 status from "Low" to "Mitigated" to match risk-register.md. | | v1.9 | 2026-04-12 | R5 mitigated (Subquery replaces Case/When). Updated validation to reflect Subquery approach. | | v2.0 | 2026-04-13 | Updated pre-deployment month validation item: fall back to earliest available rate (aligns with pipeline-changes.md v2.1). | +| v2.1 | 2026-04-28 | Updated URL references to `settings/currency/exchange_rate/`. Consolidated URL registration to `koku/api/urls.py`. Removed separate available-currencies endpoint. | +| v2.2 | 2026-04-28 | Removed static-rate enablement bypass from validation checklist. Report dropdown governed solely by `EnabledCurrency`. | +| v2.3 | 2026-04-28 | Added "costs as-is" validation item: when `MonthlyExchangeRate` is empty, feature inactive, costs returned as-is. Updated rate resolution and no-rate validation items. | +| v2.4 | 2026-04-30 | Fixed currency enablement URL to `settings/currency/enabled-currencies/{code}/`. Clarified "costs as-is": serializer blocks non-enabled currencies before query handler. | diff --git a/docs/architecture/constant-currency/pipeline-changes.md b/docs/architecture/constant-currency/pipeline-changes.md index cea100b5c1..12277e180e 100644 --- a/docs/architecture/constant-currency/pipeline-changes.md +++ b/docs/architecture/constant-currency/pipeline-changes.md @@ -83,9 +83,7 @@ configuring their own on-premise deployment. 3. Fetches rates from `CURRENCY_URL` *(unchanged when URL is set)* 4. Upserts `ExchangeRates` rows *(unchanged)* 5. Rebuilds `ExchangeRateDictionary` *(unchanged)* -6. **NEW**: Per-tenant currency discovery — creates `EnabledCurrency` rows - with `enabled=False` for newly discovered currencies -7. **NEW**: Per-tenant upsert into `MonthlyExchangeRate` for all currencies +6. **NEW**: Per-tenant upsert into `MonthlyExchangeRate` for all currencies returned by the API At query time: @@ -95,9 +93,8 @@ At query time: 9. **CHANGED**: Per-month rate resolution via `Subquery` annotation (replaces single-rate `Case`/`When`) 10. **NEW**: Report response includes `exchange_rates_applied` metadata -11. **NEW**: Available currencies for dropdown computed from enabled dynamic - currencies + static rate currencies (`enabled` flag controls dropdown - visibility only, not storage) +11. **NEW**: Available currencies for dropdown computed from `EnabledCurrency` + table only (static rates do not bypass enablement) ### Single Source of Truth: `MonthlyExchangeRate` @@ -152,26 +149,17 @@ if not settings.CURRENCY_URL: Fetch rates from `CURRENCY_URL`, upsert `ExchangeRates`, rebuild `ExchangeRateDictionary`. This logic is unchanged from today. -### Step 3: Currency discovery + `MonthlyExchangeRate` upsert (new) +### Step 3: `MonthlyExchangeRate` upsert (new) -After rebuilding `ExchangeRateDictionary`, add per-tenant currency discovery -and `MonthlyExchangeRate` upsert: +After rebuilding `ExchangeRateDictionary`, upsert per-tenant +`MonthlyExchangeRate` rows: ```python current_month = dh.this_month_start # date(2026, 3, 1) exchange_dict = ExchangeRateDictionary.objects.first().currency_exchange_dictionary -all_api_currencies = set(exchange_dict.keys()) for tenant in Tenant.objects.exclude(schema_name="public"): with schema_context(tenant.schema_name): - # Currency discovery: create EnabledCurrency rows for newly seen currencies - existing_codes = set(EnabledCurrency.objects.values_list("currency_code", flat=True)) - new_currencies = all_api_currencies - existing_codes - EnabledCurrency.objects.bulk_create( - [EnabledCurrency(currency_code=code, enabled=False) for code in new_currencies], - ignore_conflicts=True, - ) - # Pre-fetch all static pairs for this month in a single query static_pairs = set( MonthlyExchangeRate.objects.filter( @@ -180,7 +168,7 @@ for tenant in Tenant.objects.exclude(schema_name="public"): ).values_list("base_currency", "target_currency") ) - # Upsert ALL currencies — enabled flag only controls dropdown visibility + # Upsert all currencies — EnabledCurrency controls dropdown visibility for base_cur, targets in exchange_dict.items(): for target_cur, rate in targets.items(): if base_cur == target_cur: @@ -198,13 +186,13 @@ for tenant in Tenant.objects.exclude(schema_name="public"): - **URL check**: If `CURRENCY_URL` is not configured, the task skips the API fetch. Dynamic rates are simply not fetched; the system uses whatever rates - are available (static first, dynamic fallback, error if neither exists). -- **Currency discovery**: Creates `EnabledCurrency` rows with `enabled=False` - for any new currencies returned by the API. These appear in Settings as - disabled currencies that the administrator can enable. + are available (static first, dynamic fallback). If `MonthlyExchangeRate` is + completely empty (no rates configured), the feature is inactive — no + currencies are enabled and costs are returned as-is in their original bill + currency. - **All currencies stored**: Upserts dynamic rates for all currency pairs - returned by the API. The `enabled` flag on `EnabledCurrency` only controls - dropdown visibility, not rate storage. + returned by the API. The `EnabledCurrency` table controls dropdown visibility, + not rate storage. Administrators enable currencies via the Settings API. - Runs daily; overwrites current month's dynamic rows with latest rate - Skips pairs with `rate_type=RateType.STATIC` (static takes precedence) - Past months' rows are never updated (automatic finalization) @@ -455,100 +443,72 @@ an exception. **Risk linkage**: See [risk-register.md § R5](./risk-register.md#r5--query-handler-performance) -### Post-Query Exchange Rate Validation +### Pre-Query Exchange Rate Validation + +Validation happens at two levels before the report query executes: -After the queryset is evaluated but before the response is serialized, the query -handler checks for `NULL` exchange rate annotations. A `NULL` value means both -the exact-month and earliest-available subqueries returned no result — indicating -that `MonthlyExchangeRate` has no data at all for that currency pair. This is a -system configuration error, not normal operation. +**Level 1 — Serializer** (`CurrencyField(enabled_only=True)`): The `currency` +query parameter is validated against the `EnabledCurrency` table. Only +currencies that an administrator has explicitly enabled are accepted. When no +currencies are enabled (feature not configured), any `currency` parameter is +rejected — the user cannot select a target currency and costs are returned +as-is in their default currency. + +**Level 2 — Query handler** (`_validate_exchange_rates`): If the serializer +passes, the query handler checks that `MonthlyExchangeRate` has rows for the +target currency: ```python -def _validate_exchange_rates(self, queryset): - """Raise if any rows have NULL exchange rates (no rate data for currency pair).""" - if not self.currency: - return - - null_rate_filter = Q(exchange_rate__isnull=True) - # OCP has a second annotation to check - if hasattr(self, "_mapper") and hasattr(self._mapper, "cost_units_key"): - null_rate_filter |= Q(infra_exchange_rate__isnull=True) - - if queryset.filter(null_rate_filter).exists(): - missing_currencies = ( - queryset.filter(null_rate_filter) - .values_list(self._mapper.cost_units_key, flat=True) - .distinct() - ) - raise ExchangeRateNotFound( - f"No exchange rates found for base currencies " - f"{list(missing_currencies)} → {self.currency}. " - f"MonthlyExchangeRate table may not be seeded." - ) +def _validate_exchange_rates(self, target_currency): + """Raise ExchangeRateNotFound if no MonthlyExchangeRate rows exist for the target currency. + + Skips validation when MonthlyExchangeRate is completely empty (feature not configured). + The Coalesce(..., Value(1)) fallback in provider maps ensures costs are returned as-is. + """ + with tenant_context(self.tenant): + if not MonthlyExchangeRate.objects.exists(): + return # feature not configured, costs returned as-is + if not MonthlyExchangeRate.objects.filter(target_currency=target_currency).exists(): + raise ExchangeRateNotFound(target_currency) ``` **Key design choices**: -- **Post-query, not pre-query**: The validation runs after the query because - the base currency varies per row (it comes from the data, not the request). - A pre-query check could only verify that *some* rates exist for the target - currency, but would miss cases where rates exist for one base currency but - not another. -- **Performance**: The `.filter(...).exists()` check is a lightweight query - that only adds overhead when the annotation produced `NULL` values. The - `.values_list().distinct()` for the error message only runs on the error - path. +- **Feature activation**: When `MonthlyExchangeRate` is completely empty (no + rates configured at all), the feature is inactive. No currencies are enabled, + so the serializer rejects any `currency` parameter before the query handler + runs. Without a `currency` parameter, the `Coalesce("exchange_rate", + Value(1))` fallback in provider maps ensures NULL annotations resolve to `1` + (no conversion). +- **Pre-query check**: The query handler validation runs before the query + executes. It verifies that *any* `MonthlyExchangeRate` row exists for the + target currency. Per-row mismatches (e.g., a specific base currency with no + rate) are handled gracefully by the `Coalesce` fallback to `1`. - **Exception type**: `ExchangeRateNotFound` is a custom exception caught by - the view layer and returned as an appropriate HTTP error response. This is a - system configuration issue (missing rate data), not a user input error. -- **OCP dual annotations**: The OCP query handler must validate both - `exchange_rate` and `infra_exchange_rate` since they resolve against - different base currencies (cost model currency vs cloud bill currency). + the view layer and returned as HTTP 400 with an actionable error message. + This only fires when rates are configured but not for the requested currency + — indicating an administrator configuration gap, not a system error. ### New: Available Currency Resolution -The query handler (or a shared utility) computes the list of currencies -visible in the target currency dropdown. All currencies are stored in -`MonthlyExchangeRate` regardless of their enabled status — the `enabled` flag only -controls dropdown visibility. A currency is **visible in the dropdown** if any -of the following are true: - -1. It has `enabled=True` in `EnabledCurrency` -2. It appears as either `base_currency` or `target_currency` in any - `StaticExchangeRate` row (static rates make their currencies visible - regardless of `EnabledCurrency` status) +The report dropdown shows only currencies that an administrator has explicitly +enabled via the `EnabledCurrency` table. Defining a static exchange rate does +**not** automatically make its currencies available in the report dropdown — the +administrator must still enable them. -```python -@cached_property -def available_currencies(self): - """Currencies visible in the target currency dropdown.""" - # Dynamic: enabled currencies (all are stored; enabled controls visibility only) - enabled_codes = set( - EnabledCurrency.objects.filter(enabled=True).values_list("currency_code", flat=True) - ) - - # Static: all currencies appearing in any static exchange rate pair - static_currencies = set( - StaticExchangeRate.objects.values_list("base_currency", flat=True) - ) | set( - StaticExchangeRate.objects.values_list("target_currency", flat=True) - ) - - return enabled_codes | static_currencies -``` +The settings admin page (`GET settings/currency/exchange_rate/`) shows all +currencies with static rates regardless of enabled status, so the administrator +can manage them without needing to enable them first. -When the user selects a target currency that is "available" but has **no -exchange rate path** from the bill's source currency (e.g., bill is in USD, user -selects EUR, but no USD→EUR rate exists — only EUR↔CHF and CNY↔SAR are defined), -the API returns an error rather than silently showing zero or unconverted costs: +When the user selects a currency that is enabled but has **no exchange rate +path** from the bill's source currency, the API returns an error rather than +silently showing zero or unconverted costs: > *"No exchange rate available. Ask your administrator to configure static > exchange rates or enable dynamic exchange rates."* -When **no currencies are visible** (no dynamic currencies enabled and no -static rates defined), the frontend either hides the currency dropdown -entirely or shows *"No exchange rates available."* — whichever is simpler to -implement. +When **no currencies are enabled**, the frontend either hides the currency +dropdown entirely or shows *"No exchange rates available."* See [api-and-frontend.md § Corner Case: No Exchange Rate](./api-and-frontend.md#corner-case-no-exchange-rate) for the full UX specification. @@ -685,3 +645,6 @@ per-source-type would miss cross-provider reports (e.g., OCP-on-AWS). | v2.0 | 2026-04-12 | Adopted `Subquery` approach for rate resolution (replaces `Case`/`When`). Removed `effective_exchange_rates` property. OCP uses nested `Subquery` for `source_uuid` → cost model currency resolution. R5 mitigated. | | v2.1 | 2026-04-12 | Pre-deployment months now fall back to earliest available rate instead of defaulting to 1. Added post-query validation that raises `ExchangeRateNotFound` when no rate exists for a currency pair. Removed `Value(Decimal("1"))` from `Coalesce` in both base and OCP annotations. | | v2.2 | 2026-04-13 | Fixed current pipeline description: `ExchangeRates` upserts per target currency (not base). Fixed "stored and stored" typo in available currency resolution. | +| v2.3 | 2026-04-28 | Removed static-rate enablement bypass from available currency resolution. Report dropdown governed solely by `EnabledCurrency`. | +| v2.4 | 2026-04-28 | Replaced post-query validation pseudocode with actual pre-query implementation. Added "costs as-is" behavior: when `MonthlyExchangeRate` is empty, feature inactive, validation skipped. | +| v2.5 | 2026-04-30 | Documented two-level validation: serializer (`CurrencyField(enabled_only=True)`) blocks non-enabled currencies before query handler checks `MonthlyExchangeRate`. | diff --git a/docs/architecture/constant-currency/risk-register.md b/docs/architecture/constant-currency/risk-register.md index 4f445e2515..4274d3a4b3 100644 --- a/docs/architecture/constant-currency/risk-register.md +++ b/docs/architecture/constant-currency/risk-register.md @@ -16,7 +16,7 @@ Single source of truth for risks related to the Constant Currency feature | **R5** | Query handler subquery performance with many months/currencies | Mitigated | 1 | `Subquery` approach uses indexed lookups instead of growing `CASE` expressions | | **R6** | Static rate deletion leaves gap before dynamic rate fills in | Mitigated | 1 | Serializer proactively populates dynamic rows on static rate deletion; no gap | | **R7** | User selects a target currency with no conversion path from bill currency | Mitigated | 1 | Show actionable error; currencies remain visible in dropdown | -| **R8** | No rates configured (static or dynamic) for a currency pair | Accepted | 1 | Static first, dynamic fallback, error if neither; hide dropdown when no currencies visible | +| **R8** | No rates configured (static or dynamic) for a currency pair | Accepted | 1 | If `MonthlyExchangeRate` is empty: feature inactive, costs as-is. If rows exist but not for target: error. | --- @@ -180,23 +180,31 @@ with unconverted or zero amounts. - No dynamic exchange rate exists for the pair (either `CURRENCY_URL` was never configured, the API never returned that pair, or no rates have been fetched yet) -The system does not treat this as a special mode — it simply has no data for -that conversion. - -**Behavior**: Rate resolution follows a simple priority: - -1. **Static rates** — used if defined for the pair -2. **Dynamic rates** — used as fallback if no static rate exists -3. **Error** — if neither exists, the API returns an actionable error: - *"No exchange rate available. Ask your administrator to configure static - exchange rates or enable dynamic exchange rates."* +**Behavior**: Two distinct cases: + +1. **`MonthlyExchangeRate` is completely empty** (feature not configured): The + constant currency feature is inactive. No currencies are enabled, so the + serializer rejects any explicit `currency` parameter. Without a `currency` + parameter, the `Coalesce("exchange_rate", Value(1))` fallback in provider + maps ensures all exchange rate annotations resolve to `1`, so costs are + returned as-is in their original bill currency. This is the default state + for fresh deployments without `CURRENCY_URL` or static rates. + +2. **`MonthlyExchangeRate` has rows but none for the target currency**: The + feature is active but the specific currency pair is missing. Rate resolution + follows the priority: + 1. **Static rates** — used if defined for the pair + 2. **Dynamic rates** — used as fallback if no static rate exists + 3. **Error** — if neither exists, the API returns an actionable error: + *"No exchange rate available. Ask your administrator to configure static + exchange rates or enable dynamic exchange rates."* If no currencies are visible at all (all disabled and no static rates), the currency dropdown is either hidden or shows *"No exchange rates available"*. **Recovery path**: The administrator can: -1. Define static exchange rates via the CRUD API (`/exchange-rate-pairs/`) +1. Define static exchange rates via the CRUD API (`/settings/currency/exchange_rate/`) 2. Configure `CURRENCY_URL` to fetch dynamic rates from an exchange rate API 3. Enable discovered currencies in Settings to make them visible in the dropdown @@ -233,3 +241,6 @@ R8 ✓ | v1.5 | 2026-03-30 | R4 resolved: M2 seeds current-month data, eliminating need for `ExchangeRateDictionary` fallback. | | v1.6 | 2026-04-12 | R5 mitigated: `Subquery` approach replaces `Case`/`When`, eliminating O(months × currencies) scaling concern. | | v1.7 | 2026-04-13 | R4: updated to reflect earliest-available-rate fallback for pre-deployment months (aligns with pipeline-changes.md v2.1). | +| v1.8 | 2026-04-28 | R8: updated CRUD API URL to `settings/currency/exchange_rate/`. | +| v1.9 | 2026-04-28 | R8: added "costs as-is" behavior — when `MonthlyExchangeRate` is empty, feature is inactive, validation skipped, costs returned in original currency. | +| v2.0 | 2026-04-30 | R8: clarified "costs as-is" — serializer blocks non-enabled currencies; query handler skip is secondary defense. | diff --git a/koku/api/currency/currencies.py b/koku/api/currency/currencies.py index 7db20ff793..4700edc4d4 100644 --- a/koku/api/currency/currencies.py +++ b/koku/api/currency/currencies.py @@ -2,143 +2,80 @@ # Copyright 2021 Red Hat Inc. # SPDX-License-Identifier: Apache-2.0 # -"""List of currencies.""" -# turn off black formatting -# fmt: off -CURRENCIES = [ - { - "code": "AED", - "name": "United Arab Emirates Dirham", - "symbol": "AED", - "description": "AED (AED) - United Arab Emirates Dirham", - }, - { - "code": "AUD", - "name": "Australian Dollar", - "symbol": "A$", - "description": "AUD (A$) - Australian Dollar", - }, - { - "code": "BRL", - "name": "Brazilian Real", - "symbol": "R$", - "description": "BRL (R$) - Brazilian Real", - }, - { - "code": "CAD", - "name": "Canadian Dollar", - "symbol": "CA$", - "description": "CAD (CA$) - Canadian Dollar", - }, - { - "code": "CHF", - "name": "Swiss Franc", - "symbol": "CHF", - "description": "CHF (CHF) - Swiss Franc", - }, - { - "code": "CNY", - "name": "Chinese Yuan", - "symbol": "CN\u00a5", - "description": "CNY (CN\u00a5) - Chinese Yuan", - }, - { - "code": "CZK", - "name": "Czech Koruna", - "symbol": "CZK", - "description": "CZK (CZK) - Czech Koruna", - }, - { - "code": "DKK", - "name": "Danish Krone", - "symbol": "DKK", - "description": "DKK (DKK) - Danish Krone", - }, - { - "code": "EUR", - "name": "Euro", - "symbol": "\u20ac", - "description": "EUR (\u20ac) - Euro", - }, - { - "code": "GBP", - "name": "British Pound", - "symbol": "\u00a3", - "description": "GBP (\u00a3) - British Pound", - }, - { - "code": "HKD", - "name": "Hong Kong Dollar", - "symbol": "HK$", - "description": "HKD (HK$) - Hong Kong Dollar", - }, - { - "code": "INR", - "name": "Indian Rupee", - "symbol": "\u20b9", - "description": "INR (\u20b9) - Indian Rupee", - }, - { - "code": "JPY", - "name": "Japanese Yen", - "symbol": "\u00a5", - "description": "JPY (\u00a5) - Japanese Yen", - }, - { - "code": "NGN", - "name": "Nigerian Naira", - "symbol": "\u20a6", - "description": "NGN (\u20a6) - Nigerian Naira", - }, - { - "code": "NOK", - "name": "Norwegian Krone", - "symbol": "NOK", - "description": "NOK (NOK) - Norwegian Krone", - }, - { - "code": "NZD", - "name": "New Zealand Dollar", - "symbol": "NZ$", - "description": "NZD (NZ$) - New Zealand Dollar", - }, - { - "code": "SAR", - "name": "Saudi Riyal", - "symbol": "SAR", - "description": "SAR (SAR) - Saudi Riyal", - }, - { - "code": "SEK", - "name": "Swedish Krona", - "symbol": "SEK", - "description": "SEK (SEK) - Swedish Krona", - }, - { - "code": "SGD", - "name": "Singapore Dollar", - "symbol": "S$", - "description": "SGD (S$) - Singapore Dollar", - }, - { - "code": "TWD", - "name": "New Taiwan Dollar", - "symbol": "NT$", - "description": "TWD (NT$) - New Taiwan Dollar", - }, - { - "code": "USD", - "name": "United States Dollar", - "symbol": "$", - "description": "USD ($) - United States Dollar", - }, - { - "code": "ZAR", - "name": "South African Rand", - "symbol": "R", - "description": "ZAR (R) - South African Rand", - }, -] -# fmt: on -VALID_CURRENCIES = [currency["code"] for currency in CURRENCIES] -CURRENCY_CHOICES = tuple((currency, currency) for currency in VALID_CURRENCIES) +"""Currency helpers backed by the EnabledCurrency table. + +All known currencies come from babel's ISO 4217 registry. Only the +currencies that an administrator has explicitly enabled are stored in +the ``EnabledCurrency`` table (tenant schema). + +Name, symbol, and description are computed at response time via babel. +""" +from babel.core import get_global +from babel.numbers import get_currency_name +from babel.numbers import get_currency_symbol +from babel.numbers import UnknownCurrencyError +from rest_framework import serializers + +from api.currency.models import ExchangeRates +from cost_models.models import EnabledCurrency + +_ISO_4217_CURRENCIES = get_global("all_currencies") + + +def get_enabled_currency_codes(): + """Return the set of currency codes that are currently enabled. + + Requires tenant schema context (set by django-tenants middleware for + requests or by ``schema_context()`` in tasks). + """ + return set(EnabledCurrency.objects.values_list("currency_code", flat=True)) + + +class CurrencyField(serializers.CharField): + """CharField that normalizes to uppercase and validates against enabled currencies.""" + + def __init__(self, *, enabled_only, **kwargs): + kwargs.setdefault("max_length", 5) + self.enabled_only = enabled_only + super().__init__(**kwargs) + + def to_internal_value(self, data): + value = super().to_internal_value(data).upper() + if self.enabled_only and value not in get_enabled_currency_codes(): + raise serializers.ValidationError(f'"{value}" is not an enabled currency.') + return value + + +def is_valid_iso_currency(code): + """Check whether *code* is a valid ISO 4217 currency using babel's registry.""" + return code.upper() in _ISO_4217_CURRENCIES + + +def get_dynamic_rate_currencies(): + """Return the set of currency codes that have a dynamic exchange rate available.""" + return set(ExchangeRates.objects.values_list("currency_type", flat=True).distinct()) + + +def get_currency_info(code, dynamic_rate_codes): + """Return a dict with code, name, symbol, description, and dynamic rate availability. + + All metadata is resolved via babel at call time. Falls back to the + code itself for currencies babel does not recognise. + """ + code = code.upper() + try: + name = get_currency_name(code, locale="en_US") + symbol = get_currency_symbol(code, locale="en_US") + except UnknownCurrencyError: + name = code + symbol = code + + has_dynamic_rate = code.lower() in dynamic_rate_codes + + return { + "code": code, + "name": name, + "symbol": symbol, + "description": f"{code} ({symbol}) - {name}", + "has_dynamic_rate": has_dynamic_rate, + } diff --git a/koku/api/currency/models.py b/koku/api/currency/models.py index 8d9281d37c..096379ec5d 100644 --- a/koku/api/currency/models.py +++ b/koku/api/currency/models.py @@ -6,15 +6,12 @@ # from env import currency endpoimy from django.db import models -from api.currency.currencies import CURRENCIES from koku.type_json_transcode import TypedJSONDecoder from koku.type_json_transcode import TypedJSONEncoder class ExchangeRates(models.Model): - SUPPORTED_CURRENCIES = tuple((curr.get("code", "").lower(), curr.get("code")) for curr in CURRENCIES) - - currency_type = models.CharField(max_length=5, choices=SUPPORTED_CURRENCIES, unique=False, blank=True) + currency_type = models.CharField(max_length=5, unique=False, blank=True) exchange_rate = models.FloatField(default=0) diff --git a/koku/api/currency/test/test_views.py b/koku/api/currency/test/test_views.py index a842015058..eed5cc14de 100644 --- a/koku/api/currency/test/test_views.py +++ b/koku/api/currency/test/test_views.py @@ -2,19 +2,48 @@ # Copyright 2021 Red Hat Inc. # SPDX-License-Identifier: Apache-2.0 # +from unittest.mock import patch + from django.urls import reverse +from django_tenants.utils import schema_context from rest_framework import status from rest_framework.test import APIClient -from api.currency.currencies import CURRENCIES from api.iam.test.iam_test_case import IamTestCase +from cost_models.models import EnabledCurrency class CurrencyViewTest(IamTestCase): """Tests for the currency view.""" - def test_supported_currencies(self): - """Test that a list GET call returns the supported currencies.""" + def setUp(self): + super().setUp() + with schema_context(self.schema_name): + EnabledCurrency.objects.all().delete() + EnabledCurrency.objects.create(currency_code="USD") + EnabledCurrency.objects.create(currency_code="EUR") + + @patch( + "api.currency.view.get_currency_info", + side_effect=lambda c, dynamic_rate_codes: { + "USD": { + "code": "USD", + "name": "US Dollar", + "symbol": "$", + "description": "USD ($) - US Dollar", + "has_dynamic_rate": True, + }, + "EUR": { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "description": "EUR (€) - Euro", + "has_dynamic_rate": False, + }, + }[c], + ) + def test_supported_currencies(self, _mock_display): + """Test that GET returns only enabled currencies with name, symbol, description.""" qs = "?limit=25" url = reverse("currency") + qs client = APIClient() @@ -23,4 +52,31 @@ def test_supported_currencies(self): self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.data - self.assertEqual(data.get("data"), CURRENCIES) + expected = [ + { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "description": "EUR (€) - Euro", + "has_dynamic_rate": False, + }, + { + "code": "USD", + "name": "US Dollar", + "symbol": "$", + "description": "USD ($) - US Dollar", + "has_dynamic_rate": True, + }, + ] + self.assertEqual(data.get("data"), expected) + + def test_non_enabled_currencies_excluded(self): + """Test that currencies not in the EnabledCurrency table do not appear in the response.""" + url = reverse("currency") + "?limit=25" + client = APIClient() + + response = client.get(url, **self.headers) + codes = [c["code"] for c in response.data["data"]] + self.assertIn("USD", codes) + self.assertIn("EUR", codes) + self.assertNotIn("GBP", codes) diff --git a/koku/api/currency/utils.py b/koku/api/currency/utils.py index 9ddee9fbee..c99aa9cf21 100644 --- a/koku/api/currency/utils.py +++ b/koku/api/currency/utils.py @@ -4,7 +4,10 @@ # from decimal import Decimal +from django.conf import settings + from api.currency.models import ExchangeRateDictionary +from cost_models.models import MonthlyExchangeRate def build_exchange_dictionary(rates): @@ -16,6 +19,22 @@ def build_exchange_dictionary(rates): return exchanged_rates +def get_missing_rate_warning(code): + """Return a warning string if the currency has no exchange rates configured.""" + has_rate = MonthlyExchangeRate.objects.filter(target_currency=code).exists() + if has_rate: + return None + if settings.CURRENCY_URL: + return ( + f"No exchange rate available for {code}. " + f"You may need to configure a static rate before users can use it." + ) + return ( + f"No exchange rate available for {code}. " + f"Configure a static rate or enable dynamic exchange rates before users can use it." + ) + + def exchange_dictionary(rates): """Posts exchange rates dictionary to DB""" exchange_data = build_exchange_dictionary(rates) diff --git a/koku/api/currency/view.py b/koku/api/currency/view.py index 45e780416e..2f4c0d9656 100644 --- a/koku/api/currency/view.py +++ b/koku/api/currency/view.py @@ -12,26 +12,26 @@ from rest_framework.settings import api_settings from api.common.pagination import ListPaginator -from api.currency.currencies import CURRENCIES +from api.currency.currencies import get_currency_info +from api.currency.currencies import get_dynamic_rate_currencies from api.currency.models import ExchangeRateDictionary +from cost_models.models import EnabledCurrency @api_view(("GET",)) -@permission_classes((permissions.AllowAny,)) +@permission_classes((permissions.IsAuthenticated,)) @renderer_classes([JSONRenderer] + api_settings.DEFAULT_RENDERER_CLASSES) def get_currency(request): - """Get Currency Data. - - This method is responsible for passing request data to the reporting APIs. - - Args: - request (Request): The HTTP request object - - Returns: - (Response): The report in a Response object + """Get available currencies. + Returns currencies that have been enabled by an administrator via + the EnabledCurrency table. Name, symbol, and description are + computed at response time via babel. """ - return ListPaginator(CURRENCIES, request).paginated_response + enabled_codes = EnabledCurrency.objects.values_list("currency_code", flat=True) + dynamic_codes = get_dynamic_rate_currencies() + available = [get_currency_info(code, dynamic_rate_codes=dynamic_codes) for code in sorted(enabled_codes)] + return ListPaginator(available, request).paginated_response @api_view(("GET",)) diff --git a/koku/api/forecast/serializers.py b/koku/api/forecast/serializers.py index 082df33f76..ab9bd0f5d1 100644 --- a/koku/api/forecast/serializers.py +++ b/koku/api/forecast/serializers.py @@ -5,7 +5,7 @@ """Forecast Serializers.""" from rest_framework import serializers -from api.currency.currencies import CURRENCY_CHOICES +from api.currency.currencies import CurrencyField from api.report.constants import AWS_COST_TYPE_CHOICES from api.report.serializers import handle_invalid_fields from api.utils import get_cost_type @@ -17,7 +17,7 @@ class ForecastParamSerializer(serializers.Serializer): limit = serializers.IntegerField(required=False, min_value=1) offset = serializers.IntegerField(required=False, min_value=0) - currency = serializers.ChoiceField(choices=CURRENCY_CHOICES, required=False) + currency = CurrencyField(required=False, enabled_only=True) def __init__(self, *args, **kwargs): """Initialize the BaseSerializer.""" diff --git a/koku/api/forecast/views.py b/koku/api/forecast/views.py index f593753b27..9a3fd5c921 100644 --- a/koku/api/forecast/views.py +++ b/koku/api/forecast/views.py @@ -28,6 +28,7 @@ from api.forecast.serializers import OCPGCPCostForecastParamSerializer from api.provider.models import Provider from api.query_params import QueryParameters +from cost_models.exchange_rate_annotations import ExchangeRateNotFound from forecast import AWSForecast from forecast import AzureForecast from forecast import GCPForecast @@ -56,7 +57,10 @@ def get(self, request, **kwargs): return Response(data=exc.detail, status=status.HTTP_400_BAD_REQUEST) handler = self.query_handler(params) - output = handler.predict() + try: + output = handler.predict() + except ExchangeRateNotFound as exc: + return Response(data={"currency": [str(exc)]}, status=status.HTTP_400_BAD_REQUEST) LOG.debug(f"DATA: {output}") cost_type = params.parameters.get("cost_type") paginator = ForecastListPaginator(output, request, cost_type) diff --git a/koku/api/iam/models.py b/koku/api/iam/models.py index 6d8fd48d46..42f35a6ed3 100644 --- a/koku/api/iam/models.py +++ b/koku/api/iam/models.py @@ -71,6 +71,11 @@ def __init__(self, *args, **kwargs): self.identity_header = None self.beta = False + @property + def is_authenticated(self): + """Always True — User instances only exist after middleware authentication.""" + return True + class Meta: ordering = ["username"] diff --git a/koku/api/migrations/0072_alter_exchangerates_currency_type.py b/koku/api/migrations/0072_alter_exchangerates_currency_type.py new file mode 100644 index 0000000000..c6415cc93f --- /dev/null +++ b/koku/api/migrations/0072_alter_exchangerates_currency_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-04-28 14:34 +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0071_sources_updated_timestamp"), + ] + + operations = [ + migrations.AlterField( + model_name="exchangerates", + name="currency_type", + field=models.CharField(blank=True, max_length=5), + ), + ] diff --git a/koku/api/query_handler.py b/koku/api/query_handler.py index 82b88e84e0..776c7ba6e0 100644 --- a/koku/api/query_handler.py +++ b/koku/api/query_handler.py @@ -11,20 +11,16 @@ from dateutil import relativedelta from django.conf import settings from django.core.exceptions import FieldDoesNotExist -from django.db.models import Case -from django.db.models import DecimalField -from django.db.models import Value -from django.db.models import When from django.db.models.functions import TruncDay from django.db.models.functions import TruncMonth -from api.currency.models import ExchangeRateDictionary from api.query_filter import QueryFilter from api.query_filter import QueryFilterCollection from api.report.constants import RESOLUTION_DAILY from api.report.constants import TIME_SCOPE_UNITS_DAILY from api.report.constants import TIME_SCOPE_VALUES_DAILY from api.utils import DateHelper +from cost_models.exchange_rate_annotations import build_exchange_rate_annotation_dict LOG = logging.getLogger(__name__) WILDCARD = "*" @@ -120,22 +116,10 @@ def has_wildcard(in_list): return False return any(WILDCARD == item for item in in_list) - @cached_property - def exchange_rates(self): - try: - return ExchangeRateDictionary.objects.first().currency_exchange_dictionary - except AttributeError as err: - LOG.warning(f"Exchange rates dictionary is not populated resulting in {err}.") - return {} - @cached_property def exchange_rate_annotation_dict(self): - """Get the exchange rate annotation based on the exchange_rates property.""" - whens = [ - When(**{self._mapper.cost_units_key: k, "then": Value(v.get(self.currency))}) - for k, v in self.exchange_rates.items() - ] - return {"exchange_rate": Case(*whens, default=1, output_field=DecimalField())} + """Get per-month exchange rate annotation from MonthlyExchangeRate via Subquery.""" + return build_exchange_rate_annotation_dict(self._mapper.cost_units_key, self.currency) @property def order(self): diff --git a/koku/api/report/ocp/query_handler.py b/koku/api/report/ocp/query_handler.py index a3d878400a..0d0136a733 100644 --- a/koku/api/report/ocp/query_handler.py +++ b/koku/api/report/ocp/query_handler.py @@ -11,12 +11,9 @@ from decimal import InvalidOperation from functools import cached_property -from django.db.models import Case from django.db.models import CharField -from django.db.models import DecimalField from django.db.models import F from django.db.models import Value -from django.db.models import When from django.db.models.fields.json import KT from django.db.models.functions import Coalesce from django_tenants.utils import tenant_context @@ -29,8 +26,8 @@ from api.report.queries import is_grouped_by_node from api.report.queries import is_grouped_by_project from api.report.queries import ReportQueryHandler +from cost_models.exchange_rate_annotations import build_ocp_exchange_rate_annotation_dict from cost_models.models import CostModel -from cost_models.models import CostModelMap LOG = logging.getLogger(__name__) @@ -161,36 +158,34 @@ def annotations(self): return annotations @cached_property - def source_to_currency_map(self): - """ - OCP sources do not have costs associated, so we need to - grab the base currency from the cost model, and create - a mapping of source_uuid to currency. - returns: - dict: {source_uuid: currency} + def exchange_rate_annotation_dict(self): + """Get per-month exchange rate annotations from MonthlyExchangeRate via Subquery. + + OCP needs two annotations: + - exchange_rate: cost model currency (resolved via source_uuid -> CostModel.currency) + - infra_exchange_rate: cloud bill currency (raw_currency column) """ - source_map = defaultdict(lambda: self._mapper.cost_units_fallback) - cost_models = CostModel.objects.all().values("uuid", "currency").distinct() - cm_to_currency = {row["uuid"]: row["currency"] for row in cost_models} - mapping = CostModelMap.objects.all().values("provider_uuid", "cost_model_id") - source_map |= {row["provider_uuid"]: cm_to_currency[row["cost_model_id"]] for row in mapping} - return source_map + return build_ocp_exchange_rate_annotation_dict(self._mapper.cost_units_key, self.currency) - @cached_property - def exchange_rate_annotation_dict(self): - """Get the exchange rate annotation based on the exchange_rates property.""" - exchange_rate_whens = [ - When(**{"source_uuid": uuid, "then": Value(self.exchange_rates.get(cur, {}).get(self.currency, 1))}) - for uuid, cur in self.source_to_currency_map.items() - ] - infra_exchange_rate_whens = [ - When(**{self._mapper.cost_units_key: k, "then": Value(v.get(self.currency))}) - for k, v in self.exchange_rates.items() - ] - return { - "exchange_rate": Case(*exchange_rate_whens, default=1, output_field=DecimalField()), - "infra_exchange_rate": Case(*infra_exchange_rate_whens, default=1, output_field=DecimalField()), - } + def _get_base_currencies_in_data(self): + """Return base currencies from both raw_currency (infra) and cost model currencies.""" + base_currencies = super()._get_base_currencies_in_data() + base_table = self._mapper.query_table + with tenant_context(self.tenant): + source_uuids = ( + base_table.objects.filter( + usage_start__gte=self.start_datetime.date(), + usage_start__lte=self.end_datetime.date(), + ) + .values_list("source_uuid", flat=True) + .distinct() + ) + cm_currencies = set( + CostModel.objects.filter( + costmodelmap__provider_uuid__in=source_uuids, + ).values_list("currency", flat=True) + ) + return base_currencies | cm_currencies def format_tags(self, tags_iterable): """ diff --git a/koku/api/report/queries.py b/koku/api/report/queries.py index bc8842a4a6..314c7e3f93 100644 --- a/koku/api/report/queries.py +++ b/koku/api/report/queries.py @@ -24,7 +24,6 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import Case from django.db.models import CharField -from django.db.models import DecimalField from django.db.models import F from django.db.models import Q from django.db.models import Value @@ -35,9 +34,9 @@ from django.db.models.functions import Coalesce from django.db.models.functions import Concat from django.db.models.functions import RowNumber +from django_tenants.utils import tenant_context from pandas.api.types import CategoricalDtype -from api.currency.models import ExchangeRateDictionary from api.models import Provider from api.query_filter import QueryFilter from api.query_filter import QueryFilterCollection @@ -45,6 +44,8 @@ from api.report.constants import AWS_CATEGORY_PREFIX from api.report.constants import TAG_PREFIX from api.report.constants import URL_ENCODED_SAFE +from cost_models.exchange_rate_annotations import ExchangeRateNotFound +from cost_models.models import MonthlyExchangeRate LOG = logging.getLogger(__name__) @@ -880,23 +881,6 @@ def _aws_category_group_by(self) -> list[tuple[str, int, str]]: group_by.append((db_name, group_pos, original_aws_category)) return group_by - @cached_property - def exchange_rates(self): - try: - return ExchangeRateDictionary.objects.first().currency_exchange_dictionary - except AttributeError as err: - LOG.warning(f"Exchange rates dictionary is not populated resulting in {err}.") - return {} - - @cached_property - def exchange_rate_annotation_dict(self): - """Get the exchange rate annotation based on the exchange_rates property.""" - whens = [ - When(**{self._mapper.cost_units_key: k, "then": Value(v.get(self.currency))}) - for k, v in self.exchange_rates.items() - ] - return {"exchange_rate": Case(*whens, default=1, output_field=DecimalField())} - def _project_classification_annotation(self, query_data): """Get the correct annotation for a project or category""" whens = [ @@ -1064,14 +1048,96 @@ def _apply_group_by(self, query_data, group_by=None): bucket_by_date[date] = grouped return bucket_by_date + def _validate_exchange_rates(self, target_currency): + """Raise ExchangeRateNotFound if no MonthlyExchangeRate rows exist for the target currency. + + Skips validation when MonthlyExchangeRate is completely empty (feature not configured). + The Coalesce(..., Value(1)) fallback in provider maps ensures costs are returned as-is. + """ + with tenant_context(self.tenant): + if not MonthlyExchangeRate.objects.exists(): + return + if not MonthlyExchangeRate.objects.filter(target_currency=target_currency).exists(): + raise ExchangeRateNotFound(target_currency) + def _initialize_response_output(self, parameters): """Initialize output response object.""" output = copy.deepcopy(parameters.parameters) # remove access from the output output.pop("access") + if self.currency: + self._validate_exchange_rates(self.currency) + output["currency"] = self.currency + start = self.start_datetime + end = self.end_datetime + output["exchange_rates_applied"] = self._get_exchange_rates_applied( + start.date(), + end.date(), + self.currency, + ) + return output + def _get_base_currencies_in_data(self): + """Return the set of base currencies present in the report data for the query range. + + Uses the mapper's base query table (e.g. OCPUsageLineItemDailySummary) + rather than the resolved view/summary table, because the base table is + always populated and is the source of truth for which currencies exist. + + Subclasses (e.g. OCP) can override to include additional currency + sources such as cost model currencies. + """ + cost_units_key = self._mapper.cost_units_key + base_table = self._mapper.query_table + with tenant_context(self.tenant): + return set( + base_table.objects.filter( + usage_start__gte=self.start_datetime.date(), + usage_start__lte=self.end_datetime.date(), + ) + .values_list(cost_units_key, flat=True) + .distinct() + ) + + def _get_exchange_rates_applied(self, start_date, end_date, target_currency): + """Build exchange_rates_applied metadata from MonthlyExchangeRate for the query range. + + Only includes rates whose base_currency actually appears in the + report data, so the response stays small and relevant. + """ + base_currencies = self._get_base_currencies_in_data() + if not base_currencies: + return [] + + start_month = start_date.replace(day=1) + end_month = end_date.replace(day=1) + + with tenant_context(self.tenant): + rates = list( + MonthlyExchangeRate.objects.filter( + effective_date__gte=start_month, + effective_date__lte=end_month, + target_currency=target_currency, + base_currency__in=base_currencies, + ) + .order_by("base_currency", "effective_date") + .values("base_currency", "target_currency", "exchange_rate", "rate_type", "effective_date") + ) + + return [ + { + "base_currency": rate["base_currency"], + "target_currency": rate["target_currency"], + "rate": str(rate["exchange_rate"]), + "type": rate["rate_type"], + "start_date": str(rate["effective_date"]), + "end_date": str(self.dh.month_end(rate["effective_date"])), + } + for rate in rates + ] + def _pack_data_object(self, data, **kwargs): # noqa: C901 """Pack data into object format.""" if not isinstance(data, dict): diff --git a/koku/api/report/serializers.py b/koku/api/report/serializers.py index c6586a0510..5304def228 100644 --- a/koku/api/report/serializers.py +++ b/koku/api/report/serializers.py @@ -10,7 +10,7 @@ from rest_framework import serializers from rest_framework.fields import DateField -from api.currency.currencies import CURRENCY_CHOICES +from api.currency.currencies import CurrencyField from api.report.constants import AWS_CATEGORY_PREFIX from api.report.constants import TAG_PREFIX from api.report.queries import ReportQueryHandler @@ -347,16 +347,13 @@ def _schema_name(self): return request.user.customer.schema_name return None - # Adding pagination fields to the serializer because we validate - # before running reports and paginating limit = serializers.IntegerField(required=False) offset = serializers.IntegerField(required=False) - # DateField defaults: format='iso-8601', input_formats=['iso-8601'] start_date = serializers.DateField(required=False) end_date = serializers.DateField(required=False) - currency = serializers.ChoiceField(choices=CURRENCY_CHOICES, required=False) + currency = CurrencyField(required=False, enabled_only=True) category = StringOrListField(child=serializers.CharField(), required=False) order_by_allowlist = ( diff --git a/koku/api/report/view.py b/koku/api/report/view.py index 77ad0a51d6..03f18ea64d 100644 --- a/koku/api/report/view.py +++ b/koku/api/report/view.py @@ -18,6 +18,7 @@ from api.common.pagination import ReportPagination from api.common.pagination import ReportRankedPagination from api.query_params import QueryParameters +from cost_models.exchange_rate_annotations import ExchangeRateNotFound LOG = logging.getLogger(__name__) @@ -71,7 +72,10 @@ def get(self, request, **kwargs): return Response(data=exc.detail, status=status.HTTP_400_BAD_REQUEST) handler = self.query_handler(params) - output = handler.execute_query() + try: + output = handler.execute_query() + except ExchangeRateNotFound as exc: + return Response(data={"currency": [str(exc)]}, status=status.HTTP_400_BAD_REQUEST) # reset the meta when order_by[date] is used if output.get("cost_explorer_order_by"): diff --git a/koku/api/settings/currency_views.py b/koku/api/settings/currency_views.py new file mode 100644 index 0000000000..0c11762404 --- /dev/null +++ b/koku/api/settings/currency_views.py @@ -0,0 +1,80 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""Views for currency enablement.""" +import logging + +from django.db.models import Q +from django.utils.decorators import method_decorator +from django.views.decorators.cache import never_cache +from rest_framework import status +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.common import log_json +from api.common.pagination import ListPaginator +from api.common.permissions.settings_access import SettingsAccessPermission +from api.currency.currencies import get_currency_info +from api.currency.currencies import get_dynamic_rate_currencies +from api.currency.currencies import is_valid_iso_currency +from api.currency.utils import get_missing_rate_warning +from api.utils import DateHelper +from cost_models.models import EnabledCurrency +from cost_models.models import MonthlyExchangeRate +from cost_models.models import RateType + +LOG = logging.getLogger(__name__) + + +class EnabledCurrencyView(APIView): + """Enable or disable a single currency for a tenant. + + POST enables the currency; DELETE disables it. No request body required. + """ + + permission_classes = [SettingsAccessPermission] + + def _validate_code(self, code): + code = code.upper() + if not is_valid_iso_currency(code): + raise ValidationError({"code": f"Invalid ISO 4217 currency code: {code}"}) + return code + + @method_decorator(never_cache) + def get(self, request, *args, **kwargs): + if "code" in kwargs: + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + enabled_codes = EnabledCurrency.objects.values_list("currency_code", flat=True) + dynamic_codes = get_dynamic_rate_currencies() + available = [get_currency_info(code, dynamic_rate_codes=dynamic_codes) for code in sorted(enabled_codes)] + return ListPaginator(available, request).paginated_response + + @method_decorator(never_cache) + def post(self, request, *args, **kwargs): + code = self._validate_code(kwargs["code"]) + EnabledCurrency.objects.get_or_create(currency_code=code) + LOG.info(log_json(msg="Currency enabled", currency=code)) + + warning = get_missing_rate_warning(code) + if warning: + LOG.warning(log_json(msg="Currency enabled with warning", currency=code, warning=warning)) + return Response({"warning": warning}, status=status.HTTP_200_OK) + + @method_decorator(never_cache) + def delete(self, request, *args, **kwargs): + code = self._validate_code(kwargs["code"]) + EnabledCurrency.objects.filter(currency_code=code).delete() + current_month = DateHelper().this_month_start.date() + deleted_count, _ = MonthlyExchangeRate.objects.filter( + Q(base_currency=code) | Q(target_currency=code), + rate_type=RateType.DYNAMIC, + effective_date=current_month, + ).delete() + if deleted_count: + LOG.info( + log_json(msg="Cleaned up dynamic rates for disabled currency", currency=code, deleted=deleted_count) + ) + LOG.info(log_json(msg="Currency disabled", currency=code)) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/koku/api/settings/serializers.py b/koku/api/settings/serializers.py index d319135696..46fe46944e 100644 --- a/koku/api/settings/serializers.py +++ b/koku/api/settings/serializers.py @@ -5,7 +5,7 @@ """Serializers for Masu API `manifest`.""" from rest_framework import serializers -from api.currency.currencies import CURRENCY_CHOICES +from api.currency.currencies import CurrencyField from api.settings.settings import COST_TYPE_CHOICES from reporting.tenant_settings.models import TenantSettings from reporting.user_settings.models import UserSettings @@ -27,9 +27,9 @@ class UserSettingUpdateCostTypeSerializer(serializers.Serializer): class UserSettingUpdateCurrencySerializer(serializers.Serializer): - """Serializer for setting cost type.""" + """Serializer for setting currency.""" - currency = serializers.ChoiceField(choices=CURRENCY_CHOICES) + currency = CurrencyField(enabled_only=True) class TenantSettingsSerializer(serializers.Serializer): diff --git a/koku/api/settings/utils.py b/koku/api/settings/utils.py index 38dc7afcb8..6848f29d15 100644 --- a/koku/api/settings/utils.py +++ b/koku/api/settings/utils.py @@ -23,7 +23,7 @@ from querystring_parser import parser from rest_framework.exceptions import ValidationError -from api.currency.currencies import VALID_CURRENCIES +from api.currency.currencies import get_enabled_currency_codes from api.report.constants import URL_ENCODED_SAFE from api.settings.settings import COST_TYPES from api.settings.settings import DEFAULT_USER_SETTINGS @@ -251,7 +251,7 @@ def set_currency(schema, currency_code=KOKU_DEFAULT_CURRENCY): with schema_context(schema): account_currency_setting = UserSettings.objects.all().first() - if currency_code not in VALID_CURRENCIES: + if currency_code not in get_enabled_currency_codes(): raise ValueError(f"{currency_code} is not a supported currency") if not account_currency_setting: diff --git a/koku/api/urls.py b/koku/api/urls.py index 5ad07894e2..ec874ac443 100644 --- a/koku/api/urls.py +++ b/koku/api/urls.py @@ -9,6 +9,7 @@ from rest_framework.routers import DefaultRouter from api.common.deprecate_view import SunsetView +from api.settings.currency_views import EnabledCurrencyView from api.views import AccountSettings from api.views import AWSAccountRegionView from api.views import AWSAccountView @@ -105,6 +106,7 @@ from api.views import StatusView from api.views import UserAccessView from api.views import UserCostTypeSettings +from cost_models.static_exchange_rate_view import StaticExchangeRateViewSet from koku.cache import AWS_CACHE_PREFIX from koku.cache import AZURE_CACHE_PREFIX from koku.cache import CacheEnum @@ -423,6 +425,26 @@ SettingsDisableAWSCategoryKeyView.as_view(), name="settings-aws-category-keys-disable", ), + path( + "settings/currency/exchange_rate/", + StaticExchangeRateViewSet.as_view({"get": "list", "post": "create"}), + name="exchange-rate-list", + ), + path( + "settings/currency/exchange_rate//", + StaticExchangeRateViewSet.as_view({"put": "update", "delete": "destroy"}), + name="exchange-rate-detail", + ), + path( + "settings/currency/enabled-currencies/", + EnabledCurrencyView.as_view(), + name="enabled-currencies-list", + ), + path( + "settings/currency/enabled-currencies//", + EnabledCurrencyView.as_view(), + name="enabled-currencies-detail", + ), path("settings/tags/", SettingsTagView.as_view(), name="settings-tags"), path("settings/tags/enable/", SettingsEnableTagView.as_view(), name="tags-enable"), path("settings/tags/disable/", SettingsDisableTagView.as_view(), name="tags-disable"), diff --git a/koku/cost_models/exchange_rate_annotations.py b/koku/cost_models/exchange_rate_annotations.py new file mode 100644 index 0000000000..c3966462fc --- /dev/null +++ b/koku/cost_models/exchange_rate_annotations.py @@ -0,0 +1,101 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""Shared exchange rate annotation builders for query handlers and forecasts.""" +from django.conf import settings +from django.db.models import DecimalField +from django.db.models import OuterRef +from django.db.models import Subquery +from django.db.models.functions import Coalesce +from django.db.models.functions import ExtractMonth +from django.db.models.functions import ExtractYear + +from cost_models.models import CostModel +from cost_models.models import MonthlyExchangeRate + + +class ExchangeRateNotFound(Exception): + """Raised when no exchange rate exists for a required currency pair.""" + + def __init__(self, target_currency): + if settings.CURRENCY_URL: + msg = ( + f"No exchange rate available for {target_currency}. " + "Ask your administrator to configure static exchange rates." + ) + else: + msg = ( + f"No exchange rate available for {target_currency}. " + "Ask your administrator to configure static exchange rates " + "or enable dynamic exchange rates." + ) + super().__init__(msg) + + +def _build_monthly_rate_annotation(base_currency, target_currency): + """Build a Coalesce annotation that resolves exchange rates per month. + + Tries the rate matching the row's usage_start month first, then falls back + to the earliest available rate for the currency pair. + + Uses ExtractYear/ExtractMonth instead of TruncMonth on OuterRef because + Django's ResolvedOuterRef lacks the output_field attribute that TruncMonth + requires for date truncation. + + Args: + base_currency: A Django expression resolving to the base currency code. + target_currency: The target currency code string. + + Returns: + A Coalesce expression suitable for use in an .annotate() call. + """ + rate_subquery = MonthlyExchangeRate.objects.filter( + effective_date__year=ExtractYear(OuterRef("usage_start")), + effective_date__month=ExtractMonth(OuterRef("usage_start")), + base_currency=base_currency, + target_currency=target_currency, + ).values("exchange_rate")[:1] + + earliest_rate_subquery = ( + MonthlyExchangeRate.objects.filter( + base_currency=base_currency, + target_currency=target_currency, + ) + .order_by("effective_date") + .values("exchange_rate")[:1] + ) + + return Coalesce( + Subquery(rate_subquery), + Subquery(earliest_rate_subquery), + output_field=DecimalField(), + ) + + +def build_exchange_rate_annotation_dict(cost_units_key, target_currency): + """Build annotation dict with a single 'exchange_rate' key. + + Used by non-OCP query handlers and forecasts where there is only one + currency dimension (the bill/report currency). + """ + return {"exchange_rate": _build_monthly_rate_annotation(OuterRef(cost_units_key), target_currency)} + + +def build_ocp_exchange_rate_annotation_dict(cost_units_key, target_currency): + """Build annotation dict with dual exchange rates for OCP. + + OCP needs two annotations: + - exchange_rate: cost model currency (resolved via source_uuid -> CostModel.currency) + - infra_exchange_rate: cloud bill currency (raw_currency column) + """ + cost_model_currency = Subquery( + CostModel.objects.filter(costmodelmap__provider_uuid=OuterRef(OuterRef("source_uuid")),).values( + "currency" + )[:1], + ) + + return { + "exchange_rate": _build_monthly_rate_annotation(cost_model_currency, target_currency), + "infra_exchange_rate": _build_monthly_rate_annotation(OuterRef(cost_units_key), target_currency), + } diff --git a/koku/cost_models/migrations/0014_constant_currency.py b/koku/cost_models/migrations/0014_constant_currency.py new file mode 100644 index 0000000000..1954079acc --- /dev/null +++ b/koku/cost_models/migrations/0014_constant_currency.py @@ -0,0 +1,141 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""Add StaticExchangeRate, EnabledCurrency, and MonthlyExchangeRate models for constant currency.""" +import uuid +from datetime import date + +from django.db import migrations +from django.db import models + + +DEFAULT_ENABLED_CURRENCIES = ( + "AED", + "AUD", + "BRL", + "CAD", + "CHF", + "CNY", + "CZK", + "DKK", + "EUR", + "GBP", + "HKD", + "INR", + "JPY", + "NGN", + "NOK", + "NZD", + "SAR", + "SEK", + "SGD", + "TWD", + "USD", + "ZAR", +) + + +def seed_enabled_currencies(apps, schema_editor): + """Seed EnabledCurrency with the default set that was previously hardcoded.""" + EnabledCurrency = apps.get_model("cost_models", "EnabledCurrency") + EnabledCurrency.objects.bulk_create( + [EnabledCurrency(currency_code=code) for code in DEFAULT_ENABLED_CURRENCIES], + ignore_conflicts=True, + ) + + +def seed_current_month(apps, schema_editor): + """Seed MonthlyExchangeRate with current-month dynamic rates from ExchangeRateDictionary.""" + ExchangeRateDictionary = apps.get_model("api", "ExchangeRateDictionary") + MonthlyExchangeRate = apps.get_model("cost_models", "MonthlyExchangeRate") + + erd = ExchangeRateDictionary.objects.first() + if not erd or not erd.currency_exchange_dictionary: + return + + current_month = date.today().replace(day=1) + + rows = [] + for base_cur, targets in erd.currency_exchange_dictionary.items(): + for target_cur, rate in targets.items(): + if base_cur == target_cur: + continue + rows.append( + MonthlyExchangeRate( + effective_date=current_month, + base_currency=base_cur, + target_currency=target_cur, + exchange_rate=rate, + rate_type="dynamic", + ) + ) + MonthlyExchangeRate.objects.bulk_create(rows, ignore_conflicts=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("cost_models", "0013_normalize_rates_to_rate_table"), + ] + + operations = [ + migrations.CreateModel( + name="StaticExchangeRate", + fields=[ + ( + "uuid", + models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False), + ), + ("base_currency", models.CharField(max_length=5)), + ("target_currency", models.CharField(max_length=5)), + ("exchange_rate", models.DecimalField(decimal_places=15, max_digits=33)), + ("start_date", models.DateField()), + ("end_date", models.DateField()), + ("created_timestamp", models.DateTimeField(auto_now_add=True)), + ("updated_timestamp", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "static_exchange_rate", + "ordering": ["-updated_timestamp"], + "unique_together": {("base_currency", "target_currency", "start_date", "end_date")}, + }, + ), + migrations.CreateModel( + name="EnabledCurrency", + fields=[ + ( + "id", + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"), + ), + ("currency_code", models.CharField(max_length=5, unique=True)), + ("created_timestamp", models.DateTimeField(auto_now_add=True)), + ], + options={ + "db_table": "enabled_currency", + "ordering": ["currency_code"], + }, + ), + migrations.CreateModel( + name="MonthlyExchangeRate", + fields=[ + ( + "id", + models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID"), + ), + ("effective_date", models.DateField()), + ("base_currency", models.CharField(max_length=5)), + ("target_currency", models.CharField(max_length=5)), + ("exchange_rate", models.DecimalField(decimal_places=15, max_digits=33)), + ("rate_type", models.CharField(choices=[("static", "Static"), ("dynamic", "Dynamic")], max_length=10)), + ("created_timestamp", models.DateTimeField(auto_now_add=True)), + ("updated_timestamp", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "monthly_exchange_rate", + "unique_together": {("effective_date", "base_currency", "target_currency")}, + }, + ), + migrations.RunPython(code=seed_enabled_currencies, reverse_code=migrations.RunPython.noop), + migrations.RunPython(code=seed_current_month, reverse_code=migrations.RunPython.noop), + ] diff --git a/koku/cost_models/models.py b/koku/cost_models/models.py index 239c30e1f0..fd2e55d576 100644 --- a/koku/cost_models/models.py +++ b/koku/cost_models/models.py @@ -197,3 +197,61 @@ class Meta: cost_model = models.ForeignKey("CostModel", on_delete=models.CASCADE, related_name="price_list_maps") priority = models.PositiveIntegerField() + + +class RateType(models.TextChoices): + STATIC = "static", "Static" + DYNAMIC = "dynamic", "Dynamic" + + +class StaticExchangeRate(models.Model): + """User-defined exchange rates with validity periods.""" + + class Meta: + db_table = "static_exchange_rate" + ordering = ["-updated_timestamp"] + unique_together = [("base_currency", "target_currency", "start_date", "end_date")] + + uuid = models.UUIDField(primary_key=True, default=uuid4) + base_currency = models.CharField(max_length=5) + target_currency = models.CharField(max_length=5) + exchange_rate = models.DecimalField(max_digits=33, decimal_places=15) + start_date = models.DateField() + end_date = models.DateField() + created_timestamp = models.DateTimeField(auto_now_add=True) + updated_timestamp = models.DateTimeField(auto_now=True) + + @property + def name(self): + return f"{self.base_currency}-{self.target_currency}" + + +class EnabledCurrency(models.Model): + """Per-tenant enabled currencies: presence in this table means the currency is enabled.""" + + class Meta: + db_table = "enabled_currency" + ordering = ["currency_code"] + + currency_code = models.CharField(max_length=5, unique=True) + created_timestamp = models.DateTimeField(auto_now_add=True) + + +class MonthlyExchangeRate(models.Model): + """Single source of truth for exchange rates used in reports. + + Stores both static and dynamic rates as per-pair rows, one row per month. + The query handler reads from this table for all months. + """ + + class Meta: + db_table = "monthly_exchange_rate" + unique_together = ("effective_date", "base_currency", "target_currency") + + effective_date = models.DateField() + base_currency = models.CharField(max_length=5) + target_currency = models.CharField(max_length=5) + exchange_rate = models.DecimalField(max_digits=33, decimal_places=15) + rate_type = models.CharField(max_length=10, choices=RateType.choices) + created_timestamp = models.DateTimeField(auto_now_add=True) + updated_timestamp = models.DateTimeField(auto_now=True) diff --git a/koku/cost_models/serializers.py b/koku/cost_models/serializers.py index 270f54093d..5d7a2adf9f 100644 --- a/koku/cost_models/serializers.py +++ b/koku/cost_models/serializers.py @@ -13,7 +13,7 @@ from api.common import error_obj from api.common import log_json -from api.currency.currencies import CURRENCY_CHOICES +from api.currency.currencies import CurrencyField from api.metrics import constants as metric_constants from api.metrics.constants import SOURCE_TYPE_MAP from api.metrics.views import CostModelMetricMapJSONException @@ -95,7 +95,7 @@ class TieredRateSerializer(serializers.Serializer): value = serializers.DecimalField(required=False, max_digits=19, decimal_places=10) usage = serializers.DictField(required=False) - unit = serializers.ChoiceField(choices=CURRENCY_CHOICES) + unit = CurrencyField(enabled_only=True) def validate_value(self, value): """Check that value is a positive value.""" @@ -133,7 +133,7 @@ class TagRateValueSerializer(serializers.Serializer): DECIMALS = ("value", "usage_start", "usage_end") tag_value = serializers.CharField(max_length=100) - unit = serializers.ChoiceField(choices=CURRENCY_CHOICES) + unit = CurrencyField(enabled_only=True) usage = serializers.DictField(required=False) value = serializers.DecimalField(required=False, max_digits=19, decimal_places=10) description = serializers.CharField(allow_blank=True, max_length=500) @@ -459,7 +459,7 @@ class Meta: distribution_info = DistributionSerializer(required=False) - currency = serializers.ChoiceField(choices=CURRENCY_CHOICES, required=False) + currency = CurrencyField(required=False, enabled_only=True) price_list_uuids = serializers.ListField(child=serializers.UUIDField(), required=False) diff --git a/koku/cost_models/static_exchange_rate_serializer.py b/koku/cost_models/static_exchange_rate_serializer.py new file mode 100644 index 0000000000..f87fa27d04 --- /dev/null +++ b/koku/cost_models/static_exchange_rate_serializer.py @@ -0,0 +1,176 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""Serializer for StaticExchangeRate with MonthlyExchangeRate side effects.""" +import calendar +import logging + +from django.db import transaction +from rest_framework import serializers + +from api.common import log_json +from api.currency.currencies import get_currency_info +from api.currency.currencies import get_dynamic_rate_currencies +from api.currency.currencies import is_valid_iso_currency +from cost_models.models import EnabledCurrency +from cost_models.models import StaticExchangeRate +from cost_models.static_exchange_rate_utils import remove_static_and_backfill_dynamic +from cost_models.static_exchange_rate_utils import upsert_static_monthly_rates +from koku.cache import invalidate_view_cache_for_tenant_and_all_source_types + +LOG = logging.getLogger(__name__) + + +class StaticExchangeRateSerializer(serializers.ModelSerializer): + name = serializers.SerializerMethodField() + + class Meta: + model = StaticExchangeRate + fields = [ + "uuid", + "name", + "base_currency", + "target_currency", + "exchange_rate", + "start_date", + "end_date", + "created_timestamp", + "updated_timestamp", + ] + read_only_fields = ["uuid", "name", "created_timestamp", "updated_timestamp"] + + def get_name(self, obj): + return f"{obj.base_currency}-{obj.target_currency}" + + def validate_base_currency(self, value): + if not is_valid_iso_currency(value): + raise serializers.ValidationError(f"Invalid currency code: {value}") + return value.upper() + + def validate_target_currency(self, value): + if not is_valid_iso_currency(value): + raise serializers.ValidationError(f"Invalid currency code: {value}") + return value.upper() + + def validate_exchange_rate(self, value): + if value <= 0: + raise serializers.ValidationError("exchange_rate must be greater than zero.") + return value + + def validate_start_date(self, value): + if value.day != 1: + raise serializers.ValidationError("start_date must be the first day of a month.") + return value + + def validate_end_date(self, value): + last_day = calendar.monthrange(value.year, value.month)[1] + if value.day != last_day: + raise serializers.ValidationError("end_date must be the last day of a month.") + return value + + def validate(self, data): + base = data.get("base_currency") or (self.instance.base_currency if self.instance else None) + target = data.get("target_currency") or (self.instance.target_currency if self.instance else None) + start = data.get("start_date") or (self.instance.start_date if self.instance else None) + end = data.get("end_date") or (self.instance.end_date if self.instance else None) + + if base and target and base == target: + raise serializers.ValidationError("base_currency and target_currency must be different.") + + if start and end and start > end: + raise serializers.ValidationError("start_date must be on or before end_date.") + + if base and target and start and end: + overlap_qs = StaticExchangeRate.objects.filter( + base_currency=base, + target_currency=target, + start_date__lte=end, + end_date__gte=start, + ) + if self.instance: + overlap_qs = overlap_qs.exclude(uuid=self.instance.uuid) + if overlap_qs.exists(): + raise serializers.ValidationError("Overlapping validity period exists for this currency pair.") + + return data + + @transaction.atomic + def create(self, validated_data): + instance = StaticExchangeRate.objects.create(**validated_data) + upsert_static_monthly_rates(instance) + schema_name = self.context["request"].user.customer.schema_name + invalidate_view_cache_for_tenant_and_all_source_types(schema_name) + LOG.info( + log_json( + msg="Static exchange rate created", + pair=instance.name, + start=str(instance.start_date), + end=str(instance.end_date), + ) + ) + return instance + + @transaction.atomic + def update(self, instance, validated_data): + old_start = instance.start_date + old_end = instance.end_date + old_base = instance.base_currency + old_target = instance.target_currency + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + if ( + old_base != instance.base_currency + or old_target != instance.target_currency + or old_start != instance.start_date + or old_end != instance.end_date + ): + remove_static_and_backfill_dynamic(old_base, old_target, old_start, old_end) + + upsert_static_monthly_rates(instance) + + schema_name = self.context["request"].user.customer.schema_name + invalidate_view_cache_for_tenant_and_all_source_types(schema_name) + LOG.info( + log_json( + msg="Static exchange rate updated", + pair=instance.name, + ) + ) + return instance + + +class CurrencyExchangeRateSerializer(serializers.Serializer): + """Read-only serializer for a currency grouped with its static exchange rates.""" + + code = serializers.CharField() + name = serializers.CharField() + symbol = serializers.CharField() + enabled = serializers.BooleanField() + exchange_rates = StaticExchangeRateSerializer(many=True) + + @classmethod + def build_grouped_response(cls, queryset): + """Group exchange rates by base_currency and attach currency metadata + enabled flag.""" + enabled_codes = set(EnabledCurrency.objects.values_list("currency_code", flat=True)) + dynamic_codes = get_dynamic_rate_currencies() + + grouped = {} + for rate in queryset: + code = rate.base_currency + if code not in grouped: + info = get_currency_info(code, dynamic_codes) + info["enabled"] = code in enabled_codes + info["exchange_rates"] = [] + grouped[code] = info + grouped[code]["exchange_rates"].append(rate) + + result = [] + for code in sorted(grouped): + entry = grouped[code] + entry["exchange_rates"] = StaticExchangeRateSerializer(entry["exchange_rates"], many=True).data + result.append(entry) + return result diff --git a/koku/cost_models/static_exchange_rate_utils.py b/koku/cost_models/static_exchange_rate_utils.py new file mode 100644 index 0000000000..d190e45eb7 --- /dev/null +++ b/koku/cost_models/static_exchange_rate_utils.py @@ -0,0 +1,122 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""Utilities for managing MonthlyExchangeRate side effects of StaticExchangeRate operations.""" +from decimal import Decimal + +from dateutil.relativedelta import relativedelta + +from api.currency.models import ExchangeRateDictionary +from cost_models.models import MonthlyExchangeRate +from cost_models.models import RateType +from cost_models.models import StaticExchangeRate + + +def _iter_months(start_date, end_date): + """Yield the first day of each month between start_date and end_date inclusive.""" + current = start_date.replace(day=1) + end = end_date.replace(day=1) + while current <= end: + yield current + current += relativedelta(months=1) + + +def _explicit_static_rate_exists(base_currency, target_currency, month_start): + """Check if a StaticExchangeRate explicitly defines this direction covering the given month.""" + return StaticExchangeRate.objects.filter( + base_currency=base_currency, + target_currency=target_currency, + start_date__lte=month_start, + end_date__gte=month_start, + ).exists() + + +def upsert_static_monthly_rates(static_rate): + """Upsert MonthlyExchangeRate rows for each month, including the inverse direction. + + The forward direction is always written unconditionally. The inverse (1/rate) + is written only when no explicit StaticExchangeRate defines the reverse pair + for that month, ensuring explicit user-defined rates always take precedence. + """ + if static_rate.exchange_rate <= 0: + raise ValueError(f"exchange_rate must be positive, got {static_rate.exchange_rate}") + inverse_rate = Decimal(1) / static_rate.exchange_rate + + for month_start in _iter_months(static_rate.start_date, static_rate.end_date): + MonthlyExchangeRate.objects.update_or_create( + effective_date=month_start, + base_currency=static_rate.base_currency, + target_currency=static_rate.target_currency, + defaults={ + "exchange_rate": static_rate.exchange_rate, + "rate_type": RateType.STATIC, + }, + ) + + if not _explicit_static_rate_exists(static_rate.target_currency, static_rate.base_currency, month_start): + MonthlyExchangeRate.objects.update_or_create( + effective_date=month_start, + base_currency=static_rate.target_currency, + target_currency=static_rate.base_currency, + defaults={ + "exchange_rate": inverse_rate, + "rate_type": RateType.STATIC, + }, + ) + + +def remove_static_and_backfill_dynamic(base_currency, target_currency, start_date, end_date): + """Remove static rows for affected months and backfill with dynamic rates from ExchangeRateDictionary. + + Also removes auto-generated inverse rows unless an explicit StaticExchangeRate + defines the reverse direction for that month. + """ + MonthlyExchangeRate.objects.filter( + effective_date__gte=start_date.replace(day=1), + effective_date__lte=end_date.replace(day=1), + base_currency=base_currency, + target_currency=target_currency, + rate_type=RateType.STATIC, + ).delete() + + for month_start in _iter_months(start_date, end_date): + if not _explicit_static_rate_exists(target_currency, base_currency, month_start): + MonthlyExchangeRate.objects.filter( + effective_date=month_start, + base_currency=target_currency, + target_currency=base_currency, + rate_type=RateType.STATIC, + ).delete() + + erd = ExchangeRateDictionary.objects.first() + if not erd or not erd.currency_exchange_dictionary: + return + + exchange_dict = erd.currency_exchange_dictionary + rate = exchange_dict.get(base_currency, {}).get(target_currency) + if rate is not None: + for month_start in _iter_months(start_date, end_date): + MonthlyExchangeRate.objects.update_or_create( + effective_date=month_start, + base_currency=base_currency, + target_currency=target_currency, + defaults={ + "exchange_rate": rate, + "rate_type": RateType.DYNAMIC, + }, + ) + + inverse_rate = exchange_dict.get(target_currency, {}).get(base_currency) + if inverse_rate is not None: + for month_start in _iter_months(start_date, end_date): + if not _explicit_static_rate_exists(target_currency, base_currency, month_start): + MonthlyExchangeRate.objects.update_or_create( + effective_date=month_start, + base_currency=target_currency, + target_currency=base_currency, + defaults={ + "exchange_rate": inverse_rate, + "rate_type": RateType.DYNAMIC, + }, + ) diff --git a/koku/cost_models/static_exchange_rate_view.py b/koku/cost_models/static_exchange_rate_view.py new file mode 100644 index 0000000000..e56317df48 --- /dev/null +++ b/koku/cost_models/static_exchange_rate_view.py @@ -0,0 +1,69 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""View for StaticExchangeRate CRUD operations.""" +import logging + +from django.db import transaction +from django_filters import CharFilter +from django_filters import DateFilter +from django_filters import FilterSet +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import viewsets + +from api.common import log_json +from api.common.pagination import ListPaginator +from api.common.permissions.cost_models_access import CostModelsAccessPermission +from cost_models.models import StaticExchangeRate +from cost_models.static_exchange_rate_serializer import CurrencyExchangeRateSerializer +from cost_models.static_exchange_rate_serializer import StaticExchangeRateSerializer +from cost_models.static_exchange_rate_utils import remove_static_and_backfill_dynamic +from koku.cache import invalidate_view_cache_for_tenant_and_all_source_types + +LOG = logging.getLogger(__name__) + + +class StaticExchangeRateFilter(FilterSet): + """Filters for static exchange rate lookups.""" + + base_currency = CharFilter(field_name="base_currency", lookup_expr="iexact") + target_currency = CharFilter(field_name="target_currency", lookup_expr="iexact") + start_date = DateFilter(field_name="end_date", lookup_expr="gte") + end_date = DateFilter(field_name="start_date", lookup_expr="lte") + + class Meta: + model = StaticExchangeRate + fields = ["base_currency", "target_currency", "start_date", "end_date"] + + +class StaticExchangeRateViewSet(viewsets.ModelViewSet): + """CRUD for static exchange rate pairs.""" + + queryset = StaticExchangeRate.objects.all() + serializer_class = StaticExchangeRateSerializer + lookup_field = "uuid" + permission_classes = (CostModelsAccessPermission,) + http_method_names = ["get", "post", "put", "delete", "head"] + filter_backends = (DjangoFilterBackend,) + filterset_class = StaticExchangeRateFilter + + def list(self, request, *args, **kwargs): + """Return exchange rates grouped by base currency with enabled status.""" + queryset = self.filter_queryset(self.get_queryset()) + result = CurrencyExchangeRateSerializer.build_grouped_response(queryset) + paginator = ListPaginator(result, request) + return paginator.get_paginated_response(result) + + @transaction.atomic + def perform_destroy(self, instance): + remove_static_and_backfill_dynamic( + instance.base_currency, + instance.target_currency, + instance.start_date, + instance.end_date, + ) + pair_name = instance.name + instance.delete() + invalidate_view_cache_for_tenant_and_all_source_types(self.request.user.customer.schema_name) + LOG.info(log_json(msg="Static exchange rate deleted", pair=pair_name)) diff --git a/koku/cost_models/test/test_enabled_currency.py b/koku/cost_models/test/test_enabled_currency.py new file mode 100644 index 0000000000..497b3c9936 --- /dev/null +++ b/koku/cost_models/test/test_enabled_currency.py @@ -0,0 +1,247 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""Tests for EnabledCurrency views.""" +from datetime import date +from decimal import Decimal +from unittest.mock import patch + +from django.urls import reverse +from django_tenants.utils import tenant_context +from rest_framework import status +from rest_framework.test import APIClient + +from api.currency.models import ExchangeRates +from api.iam.test.iam_test_case import IamTestCase +from api.utils import DateHelper +from cost_models.models import EnabledCurrency +from cost_models.models import MonthlyExchangeRate +from cost_models.models import RateType + + +class EnabledCurrencyDetailViewTest(IamTestCase): + """Tests for POST/DELETE on settings/currency/enabled-currencies//.""" + + def setUp(self): + super().setUp() + self.client = APIClient() + + def _url(self, code): + return reverse("enabled-currencies-detail", kwargs={"code": code}) + + def _create_rate(self, target_currency): + """Helper to create a MonthlyExchangeRate for the given target currency.""" + MonthlyExchangeRate.objects.create( + effective_date=date(2026, 1, 1), + base_currency="USD", + target_currency=target_currency, + exchange_rate=Decimal("1.000000000000000"), + rate_type=RateType.STATIC, + ) + + def test_post_enables_currency(self): + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + response = self.client.post(self._url("USD"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(EnabledCurrency.objects.filter(currency_code="USD").exists()) + + def test_delete_disables_currency(self): + current_month = DateHelper().this_month_start.date() + last_month = DateHelper().last_month_start.date() + + with tenant_context(self.tenant): + MonthlyExchangeRate.objects.all().delete() + EnabledCurrency.objects.all().delete() + EnabledCurrency.objects.create(currency_code="USD") + EnabledCurrency.objects.create(currency_code="EUR") + EnabledCurrency.objects.create(currency_code="GBP") + + MonthlyExchangeRate.objects.create( + effective_date=current_month, + base_currency="GBP", + target_currency="USD", + exchange_rate=Decimal("1.27"), + rate_type=RateType.DYNAMIC, + ) + MonthlyExchangeRate.objects.create( + effective_date=current_month, + base_currency="USD", + target_currency="GBP", + exchange_rate=Decimal("0.79"), + rate_type=RateType.DYNAMIC, + ) + MonthlyExchangeRate.objects.create( + effective_date=current_month, + base_currency="USD", + target_currency="EUR", + exchange_rate=Decimal("0.87"), + rate_type=RateType.DYNAMIC, + ) + MonthlyExchangeRate.objects.create( + effective_date=last_month, + base_currency="GBP", + target_currency="USD", + exchange_rate=Decimal("1.25"), + rate_type=RateType.DYNAMIC, + ) + MonthlyExchangeRate.objects.create( + effective_date=last_month, + base_currency="USD", + target_currency="GBP", + exchange_rate=Decimal("0.79"), + rate_type=RateType.STATIC, + ) + + response = self.client.delete(self._url("GBP"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(EnabledCurrency.objects.filter(currency_code="GBP").exists()) + + self.assertFalse( + MonthlyExchangeRate.objects.filter( + base_currency="GBP", effective_date=current_month, rate_type=RateType.DYNAMIC + ).exists(), + ) + self.assertFalse( + MonthlyExchangeRate.objects.filter( + target_currency="GBP", effective_date=current_month, rate_type=RateType.DYNAMIC + ).exists(), + ) + self.assertTrue( + MonthlyExchangeRate.objects.filter( + base_currency="GBP", effective_date=last_month, rate_type=RateType.DYNAMIC + ).exists(), + ) + self.assertTrue( + MonthlyExchangeRate.objects.filter( + base_currency="USD", target_currency="GBP", rate_type=RateType.STATIC + ).exists(), + ) + self.assertTrue(MonthlyExchangeRate.objects.filter(base_currency="USD", target_currency="EUR").exists()) + + def test_post_enable_is_idempotent(self): + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + EnabledCurrency.objects.create(currency_code="USD") + response = self.client.post(self._url("USD"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(EnabledCurrency.objects.filter(currency_code="USD").count(), 1) + + def test_delete_disable_is_idempotent(self): + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + response = self.client.delete(self._url("USD"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(EnabledCurrency.objects.filter(currency_code="USD").exists()) + + def test_post_does_not_affect_other_currencies(self): + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + EnabledCurrency.objects.create(currency_code="EUR") + EnabledCurrency.objects.create(currency_code="GBP") + response = self.client.post(self._url("USD"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(EnabledCurrency.objects.filter(currency_code="EUR").exists()) + self.assertTrue(EnabledCurrency.objects.filter(currency_code="GBP").exists()) + self.assertTrue(EnabledCurrency.objects.filter(currency_code="USD").exists()) + + def test_post_invalid_currency_code(self): + response = self.client.post(self._url("INVALID"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_post_normalizes_to_uppercase(self): + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + response = self.client.post(self._url("usd"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(EnabledCurrency.objects.filter(currency_code="USD").exists()) + + def test_get_on_detail_returns_405(self): + """GET on the detail route (with a code) is not allowed.""" + response = self.client.get(self._url("USD"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_post_warns_no_rate(self): + """When no exchange rate exists for the currency, return a warning.""" + with tenant_context(self.tenant): + MonthlyExchangeRate.objects.all().delete() + EnabledCurrency.objects.all().delete() + response = self.client.post(self._url("JPY"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNotNone(response.data["warning"]) + self.assertTrue(EnabledCurrency.objects.filter(currency_code="JPY").exists()) + + def test_post_no_warning_when_rate_exists(self): + """No warning when MonthlyExchangeRate has a row for the target currency.""" + with tenant_context(self.tenant): + MonthlyExchangeRate.objects.all().delete() + self._create_rate("EUR") + EnabledCurrency.objects.all().delete() + response = self.client.post(self._url("EUR"), **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.data["warning"]) + + +class EnabledCurrencyListViewTest(IamTestCase): + """Tests for GET on settings/currency/enabled-currencies/.""" + + def setUp(self): + super().setUp() + self.client = APIClient() + self.url = reverse("enabled-currencies-list") + + @patch( + "api.settings.currency_views.get_currency_info", + side_effect=lambda c, dynamic_rate_codes: { + "USD": { + "code": "USD", + "name": "US Dollar", + "symbol": "$", + "description": "USD ($) - US Dollar", + "has_dynamic_rate": True, + }, + "EUR": { + "code": "EUR", + "name": "Euro", + "symbol": "\u20ac", + "description": "EUR (\u20ac) - Euro", + "has_dynamic_rate": False, + }, + }[c], + ) + def test_get_returns_enabled_currencies(self, _mock): + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + EnabledCurrency.objects.create(currency_code="USD") + EnabledCurrency.objects.create(currency_code="EUR") + response = self.client.get(self.url, **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data["data"] + codes = [c["code"] for c in data] + self.assertEqual(codes, ["EUR", "USD"]) + self.assertEqual(data[0]["name"], "Euro") + self.assertEqual(data[1]["symbol"], "$") + + def test_get_has_dynamic_rate_flag(self): + """Test that has_dynamic_rate reflects ExchangeRates table.""" + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + EnabledCurrency.objects.create(currency_code="USD") + EnabledCurrency.objects.create(currency_code="EUR") + ExchangeRates.objects.all().delete() + ExchangeRates.objects.create(currency_type="usd", exchange_rate=1.0) + response = self.client.get(self.url, **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.data["data"] + eur_entry = next(c for c in data if c["code"] == "EUR") + usd_entry = next(c for c in data if c["code"] == "USD") + self.assertTrue(usd_entry["has_dynamic_rate"]) + self.assertFalse(eur_entry["has_dynamic_rate"]) + + def test_get_returns_empty_list_when_none_enabled(self): + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + response = self.client.get(self.url, **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["data"], []) diff --git a/koku/cost_models/test/test_monthly_exchange_rate.py b/koku/cost_models/test/test_monthly_exchange_rate.py new file mode 100644 index 0000000000..b004d153e5 --- /dev/null +++ b/koku/cost_models/test/test_monthly_exchange_rate.py @@ -0,0 +1,110 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""Tests for the MonthlyExchangeRate and EnabledCurrency models.""" +from datetime import date +from decimal import Decimal + +from django.db import IntegrityError +from django_tenants.utils import tenant_context + +from cost_models.models import EnabledCurrency +from cost_models.models import MonthlyExchangeRate +from cost_models.models import RateType +from masu.test import MasuTestCase + + +class MonthlyExchangeRateTest(MasuTestCase): + """Tests for MonthlyExchangeRate model.""" + + def test_create_dynamic_rate(self): + """Test creating a dynamic exchange rate.""" + with tenant_context(self.tenant): + rate = MonthlyExchangeRate.objects.create( + effective_date=date(2026, 1, 1), + base_currency="USD", + target_currency="EUR", + exchange_rate=Decimal("0.870000000000000"), + rate_type=RateType.DYNAMIC, + ) + self.assertEqual(rate.base_currency, "USD") + self.assertEqual(rate.target_currency, "EUR") + self.assertEqual(rate.rate_type, RateType.DYNAMIC) + + def test_create_static_rate(self): + """Test creating a static exchange rate.""" + with tenant_context(self.tenant): + rate = MonthlyExchangeRate.objects.create( + effective_date=date(2026, 1, 1), + base_currency="USD", + target_currency="GBP", + exchange_rate=Decimal("0.780000000000000"), + rate_type=RateType.STATIC, + ) + self.assertEqual(rate.rate_type, RateType.STATIC) + + def test_unique_together_constraint(self): + """Test that duplicate (effective_date, base, target) raises IntegrityError.""" + with tenant_context(self.tenant): + MonthlyExchangeRate.objects.create( + effective_date=date(2026, 2, 1), + base_currency="USD", + target_currency="EUR", + exchange_rate=Decimal("0.870000000000000"), + rate_type=RateType.DYNAMIC, + ) + with self.assertRaises(IntegrityError): + MonthlyExchangeRate.objects.create( + effective_date=date(2026, 2, 1), + base_currency="USD", + target_currency="EUR", + exchange_rate=Decimal("0.890000000000000"), + rate_type=RateType.STATIC, + ) + + def test_update_or_create_overwrites_dynamic_with_static(self): + """Test that static rate overwrites dynamic for the same triple.""" + with tenant_context(self.tenant): + MonthlyExchangeRate.objects.create( + effective_date=date(2026, 3, 1), + base_currency="USD", + target_currency="EUR", + exchange_rate=Decimal("0.870000000000000"), + rate_type=RateType.DYNAMIC, + ) + MonthlyExchangeRate.objects.update_or_create( + effective_date=date(2026, 3, 1), + base_currency="USD", + target_currency="EUR", + defaults={ + "exchange_rate": Decimal("0.920000000000000"), + "rate_type": RateType.STATIC, + }, + ) + rate = MonthlyExchangeRate.objects.get( + effective_date=date(2026, 3, 1), + base_currency="USD", + target_currency="EUR", + ) + self.assertEqual(rate.rate_type, RateType.STATIC) + self.assertEqual(rate.exchange_rate, Decimal("0.920000000000000")) + + +class EnabledCurrencyTest(MasuTestCase): + """Tests for EnabledCurrency model.""" + + def test_create_enabled_currency(self): + """Test creating an enabled currency entry.""" + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + ec = EnabledCurrency.objects.create(currency_code="JPY") + self.assertEqual(ec.currency_code, "JPY") + + def test_unique_currency_code(self): + """Test that duplicate currency codes raise IntegrityError.""" + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + EnabledCurrency.objects.create(currency_code="CNY") + with self.assertRaises(IntegrityError): + EnabledCurrency.objects.create(currency_code="CNY") diff --git a/koku/cost_models/test/test_rate_serializer.py b/koku/cost_models/test/test_rate_serializer.py index 861300b0c7..25d11edbb4 100644 --- a/koku/cost_models/test/test_rate_serializer.py +++ b/koku/cost_models/test/test_rate_serializer.py @@ -4,6 +4,7 @@ # """Tests for RateSerializer rate_id and custom_name output.""" from unittest import TestCase +from unittest.mock import patch from uuid import uuid4 from cost_models.serializers import RateSerializer @@ -151,7 +152,8 @@ def test_is_valid_rejects_malformed_rate_id(self): self.assertFalse(serializer.is_valid()) self.assertIn("rate_id", serializer.errors) - def test_is_valid_accepts_valid_rate_id(self): + @patch("api.currency.currencies.get_enabled_currency_codes", return_value={"USD"}) + def test_is_valid_accepts_valid_rate_id(self, _mock): """Test that a valid UUID string passes field validation.""" data = { "metric": {"name": "cpu_core_usage_per_hour"}, diff --git a/koku/cost_models/test/test_serializers.py b/koku/cost_models/test/test_serializers.py index e73c65320d..768e80fb43 100644 --- a/koku/cost_models/test/test_serializers.py +++ b/koku/cost_models/test/test_serializers.py @@ -807,9 +807,10 @@ def test_cost_model_currency(self): def test_invalid_currency(self): """Test failure while handling invalid cost_type.""" self.ocp_data["currency"] = "invalid" - serializer = CostModelSerializer(data=self.ocp_data, context=self.request_context) - with self.assertRaises(serializers.ValidationError): - serializer.is_valid(raise_exception=True) + with tenant_context(self.tenant): + serializer = CostModelSerializer(data=self.ocp_data, context=self.request_context) + with self.assertRaises(serializers.ValidationError): + serializer.is_valid(raise_exception=True) def test_tiered_not_matching_currency(self): """Test if tiered rates do not match currency raises a validation error.""" diff --git a/koku/cost_models/test/test_static_exchange_rate_serializer.py b/koku/cost_models/test/test_static_exchange_rate_serializer.py new file mode 100644 index 0000000000..91ceb4b224 --- /dev/null +++ b/koku/cost_models/test/test_static_exchange_rate_serializer.py @@ -0,0 +1,309 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""Tests for the StaticExchangeRate serializer.""" +from decimal import Decimal +from unittest.mock import MagicMock +from unittest.mock import patch + +from django_tenants.utils import tenant_context + +from cost_models.models import MonthlyExchangeRate +from cost_models.models import RateType +from cost_models.models import StaticExchangeRate +from cost_models.static_exchange_rate_serializer import StaticExchangeRateSerializer +from cost_models.static_exchange_rate_utils import remove_static_and_backfill_dynamic +from masu.test import MasuTestCase + + +class StaticExchangeRateSerializerTest(MasuTestCase): + """Tests for StaticExchangeRateSerializer.""" + + def setUp(self): + super().setUp() + self.valid_data = { + "base_currency": "USD", + "target_currency": "EUR", + "exchange_rate": "0.870000000000000", + "start_date": "2026-01-01", + "end_date": "2026-03-31", + } + + def _make_request_context(self): + request = MagicMock() + request.user.customer.schema_name = self.schema_name + return {"request": request} + + def test_validate_base_currency_uppercase(self): + """Test that base_currency is uppercased.""" + with tenant_context(self.tenant): + data = self.valid_data.copy() + data["base_currency"] = "usd" + serializer = StaticExchangeRateSerializer(data=data, context=self._make_request_context()) + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data["base_currency"], "USD") + + def test_validate_invalid_currency(self): + """Test that invalid currency codes are rejected.""" + with tenant_context(self.tenant): + data = self.valid_data.copy() + data["base_currency"] = "FAKE" + serializer = StaticExchangeRateSerializer(data=data, context=self._make_request_context()) + self.assertFalse(serializer.is_valid()) + + def test_validate_same_currencies(self): + """Test that base_currency == target_currency is rejected.""" + with tenant_context(self.tenant): + data = self.valid_data.copy() + data["target_currency"] = "USD" + serializer = StaticExchangeRateSerializer(data=data, context=self._make_request_context()) + self.assertFalse(serializer.is_valid()) + + def test_validate_start_date_not_first_of_month(self): + """Test that start_date must be the 1st of a month.""" + with tenant_context(self.tenant): + data = self.valid_data.copy() + data["start_date"] = "2026-01-15" + serializer = StaticExchangeRateSerializer(data=data, context=self._make_request_context()) + self.assertFalse(serializer.is_valid()) + + def test_validate_end_date_not_last_of_month(self): + """Test that end_date must be the last day of a month.""" + with tenant_context(self.tenant): + data = self.valid_data.copy() + data["end_date"] = "2026-03-15" + serializer = StaticExchangeRateSerializer(data=data, context=self._make_request_context()) + self.assertFalse(serializer.is_valid()) + + def test_validate_start_after_end(self): + """Test that start_date > end_date is rejected.""" + with tenant_context(self.tenant): + data = self.valid_data.copy() + data["start_date"] = "2026-04-01" + data["end_date"] = "2026-03-31" + serializer = StaticExchangeRateSerializer(data=data, context=self._make_request_context()) + self.assertFalse(serializer.is_valid()) + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_create_upserts_monthly_exchange_rate(self, mock_invalidate): + """Test that creating a static rate upserts MonthlyExchangeRate rows.""" + with tenant_context(self.tenant): + serializer = StaticExchangeRateSerializer(data=self.valid_data, context=self._make_request_context()) + self.assertTrue(serializer.is_valid(), serializer.errors) + instance = serializer.save() + + self.assertIsNotNone(instance.uuid) + self.assertEqual(instance.name, "USD-EUR") + + monthly_rates = MonthlyExchangeRate.objects.filter( + base_currency="USD", + target_currency="EUR", + rate_type=RateType.STATIC, + ) + self.assertEqual(monthly_rates.count(), 3) + + for rate in monthly_rates: + self.assertEqual(rate.exchange_rate, Decimal("0.870000000000000")) + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_overlap_detection(self, mock_invalidate): + """Test that overlapping validity periods are rejected.""" + with tenant_context(self.tenant): + serializer = StaticExchangeRateSerializer(data=self.valid_data, context=self._make_request_context()) + serializer.is_valid(raise_exception=True) + serializer.save() + + overlap_data = { + "base_currency": "USD", + "target_currency": "EUR", + "exchange_rate": "0.900000000000000", + "start_date": "2026-02-01", + "end_date": "2026-04-30", + } + serializer2 = StaticExchangeRateSerializer(data=overlap_data, context=self._make_request_context()) + self.assertFalse(serializer2.is_valid()) + + def test_delete_removes_static_monthly_rates(self): + """Test that deleting a static rate removes static MonthlyExchangeRate rows.""" + with tenant_context(self.tenant): + serializer = StaticExchangeRateSerializer(data=self.valid_data, context=self._make_request_context()) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + + self.assertTrue( + MonthlyExchangeRate.objects.filter( + base_currency="USD", target_currency="EUR", rate_type=RateType.STATIC + ).exists() + ) + + remove_static_and_backfill_dynamic( + instance.base_currency, + instance.target_currency, + instance.start_date, + instance.end_date, + ) + instance.delete() + + self.assertFalse( + MonthlyExchangeRate.objects.filter( + base_currency="USD", target_currency="EUR", rate_type=RateType.STATIC + ).exists() + ) + self.assertFalse(StaticExchangeRate.objects.filter(uuid=instance.uuid).exists()) + + def test_name_computed_field(self): + """Test name is read-only computed.""" + with tenant_context(self.tenant): + data = self.valid_data.copy() + serializer = StaticExchangeRateSerializer(data=data, context=self._make_request_context()) + self.assertTrue(serializer.is_valid(), serializer.errors) + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_create_upserts_inverse_monthly_rate(self, mock_invalidate): + """Test that creating USD->EUR also creates the inverse EUR->USD = 1/rate.""" + with tenant_context(self.tenant): + serializer = StaticExchangeRateSerializer(data=self.valid_data, context=self._make_request_context()) + self.assertTrue(serializer.is_valid(), serializer.errors) + serializer.save() + + inverse_rates = MonthlyExchangeRate.objects.filter( + base_currency="EUR", + target_currency="USD", + rate_type=RateType.STATIC, + ) + self.assertEqual(inverse_rates.count(), 3) + + expected_inverse = Decimal(1) / Decimal("0.870000000000000") + for rate in inverse_rates: + self.assertAlmostEqual(rate.exchange_rate, expected_inverse, places=15) + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_create_does_not_overwrite_explicit_reverse_rate(self, mock_invalidate): + """Test that auto-generated inverse does not overwrite an explicit reverse StaticExchangeRate.""" + with tenant_context(self.tenant): + reverse_data = { + "base_currency": "EUR", + "target_currency": "USD", + "exchange_rate": "1.150000000000000", + "start_date": "2026-01-01", + "end_date": "2026-03-31", + } + reverse_serializer = StaticExchangeRateSerializer(data=reverse_data, context=self._make_request_context()) + self.assertTrue(reverse_serializer.is_valid(), reverse_serializer.errors) + reverse_serializer.save() + + serializer = StaticExchangeRateSerializer(data=self.valid_data, context=self._make_request_context()) + self.assertTrue(serializer.is_valid(), serializer.errors) + serializer.save() + + inverse_rates = MonthlyExchangeRate.objects.filter( + base_currency="EUR", + target_currency="USD", + rate_type=RateType.STATIC, + ) + for rate in inverse_rates: + self.assertEqual( + rate.exchange_rate, + Decimal("1.150000000000000"), + "Auto-generated inverse should not overwrite explicit reverse rate", + ) + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_explicit_forward_overwrites_auto_generated_inverse(self, mock_invalidate): + """Test that creating an explicit rate overwrites a previously auto-generated inverse.""" + with tenant_context(self.tenant): + serializer = StaticExchangeRateSerializer(data=self.valid_data, context=self._make_request_context()) + self.assertTrue(serializer.is_valid(), serializer.errors) + serializer.save() + + expected_inverse = Decimal(1) / Decimal("0.870000000000000") + inverse_rates = MonthlyExchangeRate.objects.filter( + base_currency="EUR", target_currency="USD", rate_type=RateType.STATIC + ) + for rate in inverse_rates: + self.assertAlmostEqual(rate.exchange_rate, expected_inverse, places=15) + + explicit_reverse_data = { + "base_currency": "EUR", + "target_currency": "USD", + "exchange_rate": "1.150000000000000", + "start_date": "2026-01-01", + "end_date": "2026-03-31", + } + reverse_serializer = StaticExchangeRateSerializer( + data=explicit_reverse_data, context=self._make_request_context() + ) + self.assertTrue(reverse_serializer.is_valid(), reverse_serializer.errors) + reverse_serializer.save() + + inverse_rates = MonthlyExchangeRate.objects.filter( + base_currency="EUR", target_currency="USD", rate_type=RateType.STATIC + ) + for rate in inverse_rates: + self.assertEqual( + rate.exchange_rate, + Decimal("1.150000000000000"), + "Explicit forward should overwrite auto-generated inverse", + ) + + def test_delete_removes_inverse_monthly_rate(self): + """Test that deleting a static rate also removes its auto-generated inverse rows.""" + with tenant_context(self.tenant): + serializer = StaticExchangeRateSerializer(data=self.valid_data, context=self._make_request_context()) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + + self.assertTrue( + MonthlyExchangeRate.objects.filter( + base_currency="EUR", target_currency="USD", rate_type=RateType.STATIC + ).exists() + ) + + remove_static_and_backfill_dynamic( + instance.base_currency, + instance.target_currency, + instance.start_date, + instance.end_date, + ) + instance.delete() + + self.assertFalse( + MonthlyExchangeRate.objects.filter( + base_currency="EUR", target_currency="USD", rate_type=RateType.STATIC + ).exists() + ) + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_delete_preserves_explicit_reverse_rate(self, mock_invalidate): + """Test that deleting USD->EUR does not remove EUR->USD rows from an explicit StaticExchangeRate.""" + with tenant_context(self.tenant): + reverse_data = { + "base_currency": "EUR", + "target_currency": "USD", + "exchange_rate": "1.150000000000000", + "start_date": "2026-01-01", + "end_date": "2026-03-31", + } + reverse_serializer = StaticExchangeRateSerializer(data=reverse_data, context=self._make_request_context()) + self.assertTrue(reverse_serializer.is_valid(), reverse_serializer.errors) + reverse_serializer.save() + + serializer = StaticExchangeRateSerializer(data=self.valid_data, context=self._make_request_context()) + self.assertTrue(serializer.is_valid(), serializer.errors) + forward_instance = serializer.save() + + remove_static_and_backfill_dynamic( + forward_instance.base_currency, + forward_instance.target_currency, + forward_instance.start_date, + forward_instance.end_date, + ) + forward_instance.delete() + + inverse_rates = MonthlyExchangeRate.objects.filter( + base_currency="EUR", target_currency="USD", rate_type=RateType.STATIC + ) + self.assertEqual(inverse_rates.count(), 3, "Explicit reverse rates should be preserved") + for rate in inverse_rates: + self.assertEqual(rate.exchange_rate, Decimal("1.150000000000000")) diff --git a/koku/cost_models/test/test_static_exchange_rate_view.py b/koku/cost_models/test/test_static_exchange_rate_view.py new file mode 100644 index 0000000000..6adcf046b5 --- /dev/null +++ b/koku/cost_models/test/test_static_exchange_rate_view.py @@ -0,0 +1,128 @@ +# +# Copyright 2026 Red Hat Inc. +# SPDX-License-Identifier: Apache-2.0 +# +"""Tests for the StaticExchangeRate ViewSet.""" +from unittest.mock import patch + +from django.urls import reverse +from django_tenants.utils import tenant_context +from rest_framework import status +from rest_framework.test import APIClient + +from api.iam.test.iam_test_case import IamTestCase +from cost_models.models import EnabledCurrency +from cost_models.models import StaticExchangeRate + + +class StaticExchangeRateViewSetTest(IamTestCase): + """Tests for StaticExchangeRateViewSet.""" + + def setUp(self): + super().setUp() + self.client = APIClient() + self.list_url = reverse("exchange-rate-list") + self.valid_data = { + "base_currency": "USD", + "target_currency": "EUR", + "exchange_rate": "0.870000000000000", + "start_date": "2026-01-01", + "end_date": "2026-03-31", + } + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_create_static_rate(self, mock_invalidate): + """Test creating a static exchange rate via API.""" + with tenant_context(self.tenant): + response = self.client.post(self.list_url, data=self.valid_data, format="json", **self.headers) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + data = response.data + self.assertEqual(data["base_currency"], "USD") + self.assertEqual(data["target_currency"], "EUR") + self.assertEqual(data["name"], "USD-EUR") + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_list_returns_grouped_by_currency(self, mock_invalidate): + """Test that GET list returns exchange rates grouped by base currency with enabled flag.""" + with tenant_context(self.tenant): + EnabledCurrency.objects.all().delete() + EnabledCurrency.objects.create(currency_code="USD") + self.client.post(self.list_url, data=self.valid_data, format="json", **self.headers) + + response = self.client.get(self.list_url, **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.data["data"] + self.assertEqual(len(data), 1) + + usd_entry = data[0] + self.assertEqual(usd_entry["code"], "USD") + self.assertEqual(usd_entry["enabled"], True) + self.assertIn("name", usd_entry) + self.assertIn("symbol", usd_entry) + self.assertEqual(len(usd_entry["exchange_rates"]), 1) + + rate = usd_entry["exchange_rates"][0] + self.assertEqual(rate["base_currency"], "USD") + self.assertEqual(rate["target_currency"], "EUR") + self.assertIn("uuid", rate) + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_list_disabled_currency_shows_enabled_false(self, mock_invalidate): + """Test that a currency without EnabledCurrency row shows enabled=False.""" + with tenant_context(self.tenant): + EnabledCurrency.objects.filter(currency_code="USD").delete() + self.client.post(self.list_url, data=self.valid_data, format="json", **self.headers) + + response = self.client.get(self.list_url, **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + usd_entry = response.data["data"][0] + self.assertEqual(usd_entry["code"], "USD") + self.assertEqual(usd_entry["enabled"], False) + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_update_static_rate(self, mock_invalidate): + """Test updating a static exchange rate via PUT.""" + with tenant_context(self.tenant): + create_response = self.client.post(self.list_url, data=self.valid_data, format="json", **self.headers) + uuid = create_response.data["uuid"] + detail_url = reverse("exchange-rate-detail", kwargs={"uuid": uuid}) + + update_data = self.valid_data.copy() + update_data["exchange_rate"] = "0.900000000000000" + response = self.client.put(detail_url, data=update_data, format="json", **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @patch("cost_models.static_exchange_rate_serializer.invalidate_view_cache_for_tenant_and_all_source_types") + def test_delete_static_rate(self, mock_invalidate): + """Test deleting a static exchange rate.""" + with tenant_context(self.tenant): + create_response = self.client.post(self.list_url, data=self.valid_data, format="json", **self.headers) + uuid = create_response.data["uuid"] + detail_url = reverse("exchange-rate-detail", kwargs={"uuid": uuid}) + response = self.client.delete(detail_url, **self.headers) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(StaticExchangeRate.objects.filter(uuid=uuid).exists()) + + def test_create_invalid_currency(self): + """Test creating with invalid currency code returns 400.""" + with tenant_context(self.tenant): + data = self.valid_data.copy() + data["base_currency"] = "FAKE" + response = self.client.post(self.list_url, data=data, format="json", **self.headers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_mid_month_start_date(self): + """Test creating with non-first-of-month start_date returns 400.""" + with tenant_context(self.tenant): + data = self.valid_data.copy() + data["start_date"] = "2026-01-15" + response = self.client.post(self.list_url, data=data, format="json", **self.headers) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_filter_by_base_currency(self): + """Test filtering by base_currency query parameter.""" + with tenant_context(self.tenant): + response = self.client.get(self.list_url, {"base_currency": "USD"}, **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/koku/cost_models/view.py b/koku/cost_models/view.py index aa191d0bb1..f084b0d8df 100644 --- a/koku/cost_models/view.py +++ b/koku/cost_models/view.py @@ -26,7 +26,7 @@ from api.common.filters import CharListFilter from api.common.permissions.cost_models_access import CostModelsAccessPermission -from api.currency.currencies import VALID_CURRENCIES +from api.currency.currencies import get_enabled_currency_codes from cost_models.cost_model_manager import CostModelManager from cost_models.models import CostModel from cost_models.serializers import CostModelSerializer @@ -46,8 +46,8 @@ class CostModelsFilter(FilterSet): def currency_filter(self, qs, name, values): """Filter currency if a valid currency is passed in""" - if values and values[0].upper() not in VALID_CURRENCIES: - error = {"currency": f'"{values[0]}" is not a valid choice.'} + if values and values[0].upper() not in get_enabled_currency_codes(): + error = {"currency": f'"{values[0]}" is not an enabled currency.'} raise serializers.ValidationError(error) lookup = "__".join([name, "iexact"]) queries = [Q(**{lookup: val}) for val in values] diff --git a/koku/forecast/forecast.py b/koku/forecast/forecast.py index 7cb80a5079..9061fef250 100644 --- a/koku/forecast/forecast.py +++ b/koku/forecast/forecast.py @@ -16,20 +16,16 @@ import statsmodels.api as sm from django.conf import settings from django.core.exceptions import FieldDoesNotExist -from django.db.models import Case from django.db.models import CharField -from django.db.models import DecimalField from django.db.models import ExpressionWrapper from django.db.models import F from django.db.models import Q from django.db.models import Value -from django.db.models import When from django.db.models.functions import Coalesce from django_tenants.utils import tenant_context from statsmodels.sandbox.regression.predstd import wls_prediction_std from statsmodels.tools.sm_exceptions import ValueWarning -from api.currency.models import ExchangeRateDictionary from api.models import Provider from api.query_filter import QueryFilter from api.query_filter import QueryFilterCollection @@ -43,8 +39,10 @@ from api.report.ocp.provider_map import OCPProviderMap from api.utils import DateHelper from api.utils import get_cost_type -from cost_models.models import CostModel -from cost_models.models import CostModelMap +from cost_models.exchange_rate_annotations import build_exchange_rate_annotation_dict +from cost_models.exchange_rate_annotations import build_ocp_exchange_rate_annotation_dict +from cost_models.exchange_rate_annotations import ExchangeRateNotFound +from cost_models.models import MonthlyExchangeRate from reporting.provider.aws.models import AWSOrganizationalUnit LOG = logging.getLogger(__name__) @@ -149,22 +147,10 @@ def infrastructure_cost_term(self): """Return the provider map value for total inftrastructure cost.""" return self.provider_map.report_type_map.get("aggregates", {}).get("infra_total") - @cached_property - def exchange_rates(self): - try: - return ExchangeRateDictionary.objects.first().currency_exchange_dictionary - except AttributeError as err: - LOG.warning(f"Exchange rates dictionary is not populated resulting in {err}.") - return {} - @cached_property def exchange_rate_annotation_dict(self): - """Get the exchange rate annotation based on the exchange_rates property.""" - whens = [ - When(**{self.provider_map.cost_units_key: k, "then": Value(v.get(self.currency))}) - for k, v in self.exchange_rates.items() - ] - return {"exchange_rate": Case(*whens, default=1, output_field=DecimalField())} + """Get per-month exchange rate annotation from MonthlyExchangeRate via Subquery.""" + return build_exchange_rate_annotation_dict(self.provider_map.cost_units_key, self.currency) def get_data(self): """Query the database.""" @@ -184,6 +170,12 @@ def predict(self): """Define ORM query to run forecast and return prediction.""" cost_predictions = {} with tenant_context(self.params.tenant): + if ( + self.currency + and MonthlyExchangeRate.objects.exists() + and not MonthlyExchangeRate.objects.filter(target_currency=self.currency).exists() + ): + raise ExchangeRateNotFound(self.currency) data = self.get_data() for fieldname in COST_FIELD_NAMES: @@ -596,37 +588,10 @@ class OCPForecast(Forecast): provider = Provider.PROVIDER_OCP provider_map_class = OCPProviderMap - @cached_property - def source_to_currency_map(self): - """ - OCP sources do not have costs associated, so we need to - grab the base currency from the cost model, and create - a mapping of source_uuid to currency. - returns: - dict: {source_uuid: currency} - """ - source_map = defaultdict(lambda: "USD") - cost_models = CostModel.objects.all().values("uuid", "currency").distinct() - cm_to_currency = {row["uuid"]: row["currency"] for row in cost_models} - mapping = CostModelMap.objects.all().values("provider_uuid", "cost_model_id") - source_map |= {row["provider_uuid"]: cm_to_currency[row["cost_model_id"]] for row in mapping} - return source_map - @cached_property def exchange_rate_annotation_dict(self): - """Get the exchange rate annotation based on the exchange_rates property.""" - exchange_rate_whens = [ - When(**{"source_uuid": uuid, "then": Value(self.exchange_rates.get(cur, {}).get(self.currency, 1))}) - for uuid, cur in self.source_to_currency_map.items() - ] - infra_exchange_rate_whens = [ - When(**{self.provider_map.cost_units_key: k, "then": Value(v.get(self.currency))}) - for k, v in self.exchange_rates.items() - ] - return { - "exchange_rate": Case(*exchange_rate_whens, default=1, output_field=DecimalField()), - "infra_exchange_rate": Case(*infra_exchange_rate_whens, default=1, output_field=DecimalField()), - } + """Get per-month exchange rate annotations from MonthlyExchangeRate via Subquery.""" + return build_ocp_exchange_rate_annotation_dict(self.provider_map.cost_units_key, self.currency) class OCPAWSForecast(Forecast): diff --git a/koku/koku/koku_test_runner.py b/koku/koku/koku_test_runner.py index e00432d72c..2266ff3b7b 100644 --- a/koku/koku/koku_test_runner.py +++ b/koku/koku/koku_test_runner.py @@ -138,6 +138,7 @@ def setup_databases(verbosity, interactive, keepdb=False, debug_sql=False, paral Customer.objects.get_or_create( account_id=account[0], org_id=account[2], schema_name=account[1] ) + except Exception as err: LOG.error(err) raise err diff --git a/koku/koku/settings.py b/koku/koku/settings.py index e8176eb71f..67128a8bc6 100644 --- a/koku/koku/settings.py +++ b/koku/koku/settings.py @@ -174,7 +174,9 @@ MAX_GROUP_BY = ENVIRONMENT.int("MAX_GROUP_BY_OVERRIDE", default=3) ### Currency URL -CURRENCY_URL = ENVIRONMENT.get_value("CURRENCY_URL", default="https://open.er-api.com/v6/latest/USD") +CURRENCY_URL = ENVIRONMENT.get_value( + "CURRENCY_URL", default=None if ONPREM else "https://open.er-api.com/v6/latest/USD" +) ### End Middleware diff --git a/koku/masu/celery/tasks.py b/koku/masu/celery/tasks.py index 53bc409478..c55c4b3d20 100644 --- a/koku/masu/celery/tasks.py +++ b/koku/masu/celery/tasks.py @@ -16,7 +16,8 @@ from urllib3.util.retry import Retry from api.common import log_json -from api.currency.currencies import VALID_CURRENCIES +from api.currency.currencies import is_valid_iso_currency +from api.currency.models import ExchangeRateDictionary from api.currency.models import ExchangeRates from api.currency.utils import exchange_dictionary from api.iam.models import Tenant @@ -26,7 +27,11 @@ from common.queues import DownloadQueue from common.queues import PriorityQueue from common.queues import SummaryQueue +from cost_models.models import EnabledCurrency +from cost_models.models import MonthlyExchangeRate +from cost_models.models import RateType from koku import celery_app +from koku.cache import invalidate_view_cache_for_tenant_and_all_source_types from koku.notifications import NotificationService from masu.config import Config from masu.database.cost_model_db_accessor import CostModelDBAccessor @@ -263,12 +268,8 @@ def autovacuum_tune_schemas(): autovacuum_tune_schema.delay(schema_name) -@celery_app.task(name="masu.celery.tasks.get_daily_currency_rates", queue=DEFAULT) -def get_daily_currency_rates(): - """Task to get latest daily conversion rates.""" - rate_metrics = {} - - url = settings.CURRENCY_URL +def _fetch_and_store_exchange_rates(url): + """Fetch exchange rates from the configured URL. Returns rate_metrics dict or None on failure.""" retries = Retry( total=5, allowed_methods={"GET"}, @@ -278,36 +279,92 @@ def get_daily_currency_rates(): session = requests.Session() session.mount("https://", HTTPAdapter(max_retries=retries)) - # Retrieve conversion rates from URL try: response = session.get(url) response.raise_for_status() except (HTTPError, RetryError) as e: LOG.error(f"Couldn't pull latest conversion rates from {url}") LOG.error(e) + return None - return rate_metrics - + rate_metrics = {} data = response.json() - rates = data["rates"] - # Update conversion rates in database - for curr_type in rates.keys(): - if curr_type.upper() in VALID_CURRENCIES: - value = rates[curr_type] - try: - exchange = ExchangeRates.objects.get(currency_type=curr_type.lower()) - LOG.info(f"Updating currency {curr_type} to {value}") - except ExchangeRates.DoesNotExist: - LOG.info(f"Creating the exchange rate {curr_type} to {value}") - exchange = ExchangeRates(currency_type=curr_type.lower()) - rate_metrics[curr_type] = value - exchange.exchange_rate = value - exchange.save() + for curr_type, value in rates.items(): + if not is_valid_iso_currency(curr_type): + LOG.warning(f"Skipping unsupported currency {curr_type}") + continue + if not value or value <= 0: + LOG.warning(f"Skipping currency {curr_type} with invalid rate {value}") + continue + try: + exchange = ExchangeRates.objects.get(currency_type=curr_type.lower()) + LOG.info(f"Updating currency {curr_type} to {value}") + except ExchangeRates.DoesNotExist: + LOG.info(f"Creating the exchange rate {curr_type} to {value}") + exchange = ExchangeRates(currency_type=curr_type.lower()) + rate_metrics[curr_type] = value + exchange.exchange_rate = value + exchange.save() exchange_dictionary(rate_metrics) return rate_metrics +def _upsert_tenant_dynamic_exchange_rates(schema_name, exchange_dict, current_month): + """Upsert dynamic MonthlyExchangeRate rows for one tenant. + + Only writes rates where both base and target currencies are enabled. + Removes stale dynamic rows for currencies that are no longer enabled. + """ + with schema_context(schema_name): + enabled_codes = set(EnabledCurrency.objects.values_list("currency_code", flat=True)) + if not enabled_codes: + return + + static_pairs = set( + MonthlyExchangeRate.objects.filter( + effective_date=current_month, + rate_type=RateType.STATIC, + ).values_list("base_currency", "target_currency") + ) + + for base_cur, targets in exchange_dict.items(): + if base_cur not in enabled_codes: + continue + for target_cur, rate in targets.items(): + if target_cur not in enabled_codes: + continue + if base_cur != target_cur and (base_cur, target_cur) not in static_pairs: + MonthlyExchangeRate.objects.update_or_create( + effective_date=current_month, + base_currency=base_cur, + target_currency=target_cur, + defaults={"exchange_rate": rate, "rate_type": RateType.DYNAMIC}, + ) + + invalidate_view_cache_for_tenant_and_all_source_types(schema_name) + + +@celery_app.task(name="masu.celery.tasks.get_daily_currency_rates", queue=DEFAULT) +def get_daily_currency_rates(): + """Task to get latest daily conversion rates and upsert MonthlyExchangeRate per tenant.""" + url = settings.CURRENCY_URL + if not url: + LOG.info(log_json(msg="CURRENCY_URL not configured; skipping dynamic exchange rate fetch")) + return {} + + rate_metrics = _fetch_and_store_exchange_rates(url) + if not rate_metrics: + return {} + + erd = ExchangeRateDictionary.objects.first() + current_month = DateHelper().this_month_start.date() + for tenant in Tenant.objects.exclude(schema_name="public"): + _upsert_tenant_dynamic_exchange_rates(tenant.schema_name, erd.currency_exchange_dictionary, current_month) + + return rate_metrics + + @celery_app.task(name="masu.celery.tasks.scrape_azure_storage_capacities", queue=DEFAULT) def scrape_azure_storage_capacities(): """Task to retrieve the Azure disk capacities.