diff --git a/.flags b/.flags index e69de29bb..ecfccc94a 100644 --- a/.flags +++ b/.flags @@ -0,0 +1 @@ +BYPASS_CONSUMPTION=0 diff --git a/Pipfile.lock b/Pipfile.lock index 080d6dae7..3c1e90fa1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -810,11 +810,11 @@ }, "dj-database-url": { "hashes": [ - "sha256:3e792567b0aa9a4884860af05fe2aa4968071ad351e033b6db632f97ac6db9de", - "sha256:9f9b05058ddf888f1e6f840048b8d705ff9395e3b52a07165daa3d8b9360551b" + "sha256:ae52e8e634186b57e5a45e445da5dc407a819c2ceed8a53d1fac004cc5288787", + "sha256:bb0d414ba0ac5cd62773ec7f86f8cc378a9dbb00a80884c2fc08cc570452521e" ], "index": "pypi", - "version": "==2.2.0" + "version": "==2.3.0" }, "django": { "hashes": [ @@ -992,86 +992,101 @@ }, "frozenlist": { "hashes": [ - "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7", - "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98", - "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad", - "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5", - "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae", - "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e", - "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a", - "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701", - "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d", - "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6", - "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6", - "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106", - "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75", - "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868", - "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a", - "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0", - "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1", - "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826", - "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec", - "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6", - "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950", - "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19", - "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0", - "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8", - "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a", - "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09", - "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86", - "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c", - "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5", - "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b", - "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b", - "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d", - "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0", - "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea", - "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776", - "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a", - "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897", - "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7", - "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09", - "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9", - "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe", - "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd", - "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742", - "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09", - "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0", - "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932", - "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1", - "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a", - "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49", - "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d", - "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7", - "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480", - "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89", - "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e", - "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b", - "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82", - "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb", - "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068", - "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8", - "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b", - "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb", - "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2", - "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11", - "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b", - "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc", - "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0", - "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497", - "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17", - "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0", - "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2", - "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439", - "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5", - "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac", - "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825", - "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887", - "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced", - "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74" + "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", + "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", + "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6", + "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", + "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", + "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f", + "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", + "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", + "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", + "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", + "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec", + "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2", + "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c", + "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336", + "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4", + "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", + "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b", + "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c", + "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10", + "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08", + "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", + "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", + "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f", + "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10", + "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", + "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", + "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", + "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", + "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d", + "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923", + "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", + "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", + "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17", + "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0", + "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", + "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", + "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c", + "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a", + "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0", + "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", + "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab", + "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", + "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3", + "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", + "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", + "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604", + "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", + "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5", + "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", + "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", + "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", + "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", + "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d", + "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", + "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3", + "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", + "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", + "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", + "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf", + "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76", + "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba", + "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171", + "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb", + "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", + "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", + "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972", + "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", + "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", + "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9", + "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411", + "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723", + "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", + "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b", + "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99", + "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e", + "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", + "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", + "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb", + "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", + "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", + "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca", + "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", + "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", + "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f", + "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5", + "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307", + "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e", + "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2", + "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", + "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", + "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", + "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a" ], "markers": "python_version >= '3.8'", - "version": "==1.4.1" + "version": "==1.5.0" }, "gevent": { "hashes": [ @@ -1709,7 +1724,6 @@ "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" ], - "markers": "python_version >= '3.6'", "version": "==3.10" }, "incremental": { @@ -2349,42 +2363,42 @@ }, "mypy": { "hashes": [ - "sha256:02dcfe270c6ea13338210908f8cadc8d31af0f04cee8ca996438fe6a97b4ec66", - "sha256:0dcc1e843d58f444fce19da4cce5bd35c282d4bde232acdeca8279523087088a", - "sha256:0e6fe449223fa59fbee351db32283838a8fee8059e0028e9e6494a03802b4004", - "sha256:1230048fec1380faf240be6385e709c8570604d2d27ec6ca7e573e3bc09c3735", - "sha256:186e0c8346efc027ee1f9acf5ca734425fc4f7dc2b60144f0fbe27cc19dc7931", - "sha256:19bf51f87a295e7ab2894f1d8167622b063492d754e69c3c2fed6563268cb42a", - "sha256:20db6eb1ca3d1de8ece00033b12f793f1ea9da767334b7e8c626a4872090cf02", - "sha256:389e307e333879c571029d5b93932cf838b811d3f5395ed1ad05086b52148fb0", - "sha256:3d7d4371829184e22fda4015278fbfdef0327a4b955a483012bd2d423a788801", - "sha256:427878aa54f2e2c5d8db31fa9010c599ed9f994b3b49e64ae9cd9990c40bd635", - "sha256:4ee5932370ccf7ebf83f79d1c157a5929d7ea36313027b0d70a488493dc1b179", - "sha256:5fcde63ea2c9f69d6be859a1e6dd35955e87fa81de95bc240143cf00de1f7f81", - "sha256:673ba1140a478b50e6d265c03391702fa11a5c5aff3f54d69a62a48da32cb811", - "sha256:8135ffec02121a75f75dc97c81af7c14aa4ae0dda277132cfcd6abcd21551bfd", - "sha256:843826966f1d65925e8b50d2b483065c51fc16dc5d72647e0236aae51dc8d77e", - "sha256:94b2048a95a21f7a9ebc9fbd075a4fcd310410d078aa0228dbbad7f71335e042", - "sha256:96af62050971c5241afb4701c15189ea9507db89ad07794a4ee7b4e092dc0627", - "sha256:9fb83a7be97c498176fb7486cafbb81decccaef1ac339d837c377b0ce3743a7f", - "sha256:9fe20f89da41a95e14c34b1ddb09c80262edcc295ad891f22cc4b60013e8f78d", - "sha256:a5a437c9102a6a252d9e3a63edc191a3aed5f2fcb786d614722ee3f4472e33f6", - "sha256:a7b76fa83260824300cc4834a3ab93180db19876bce59af921467fd03e692810", - "sha256:b16fe09f9c741d85a2e3b14a5257a27a4f4886c171d562bc5a5e90d8591906b8", - "sha256:b947097fae68004b8328c55161ac9db7d3566abfef72d9d41b47a021c2fba6b1", - "sha256:ce561a09e3bb9863ab77edf29ae3a50e65685ad74bba1431278185b7e5d5486e", - "sha256:d34167d43613ffb1d6c6cdc0cc043bb106cac0aa5d6a4171f77ab92a3c758bcc", - "sha256:d54d840f6c052929f4a3d2aab2066af0f45a020b085fe0e40d4583db52aab4e4", - "sha256:d90da248f4c2dba6c44ddcfea94bb361e491962f05f41990ff24dbd09969ce20", - "sha256:dc6e2a2195a290a7fd5bac3e60b586d77fc88e986eba7feced8b778c373f9afe", - "sha256:de5b2a8988b4e1269a98beaf0e7cc71b510d050dce80c343b53b4955fff45f19", - "sha256:e10ba7de5c616e44ad21005fa13450cd0de7caaa303a626147d45307492e4f2d", - "sha256:f59f1dfbf497d473201356966e353ef09d4daec48caeacc0254db8ef633a28a5", - "sha256:f5b3936f7a6d0e8280c9bdef94c7ce4847f5cdfc258fbb2c29a8c1711e8bb96d" + "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", + "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", + "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", + "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74", + "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a", + "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", + "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", + "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", + "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", + "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", + "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d", + "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6", + "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", + "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", + "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", + "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", + "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", + "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc", + "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", + "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb", + "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", + "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732", + "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", + "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", + "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", + "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", + "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", + "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24", + "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", + "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b", + "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", + "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.12.1" + "version": "==1.13.0" }, "mypy-extensions": { "hashes": [ @@ -2533,12 +2547,12 @@ }, "openai": { "hashes": [ - "sha256:0c249f20920183b0a2ca4f7dba7b0452df3ecd0fa7985eb1d91ad884bc3ced9c", - "sha256:95c65a5f77559641ab8f3e4c3a050804f7b51d278870e2ec1f7444080bfe565a" + "sha256:57e9e37bc407f39bb6ec3a27d7e8fb9728b2779936daa1fcf95df17d3edfaccc", + "sha256:87b7d0f69d85f5641678d414b7ee3082363647a5c66a462ed7f3ccb59582da0d" ], "index": "pypi", "markers": "python_full_version >= '3.7.1'", - "version": "==1.52.0" + "version": "==1.52.2" }, "packaging": { "hashes": [ @@ -2854,28 +2868,28 @@ }, "proto-plus": { "hashes": [ - "sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445", - "sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12" + "sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961", + "sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91" ], "markers": "python_version >= '3.7'", - "version": "==1.24.0" + "version": "==1.25.0" }, "protobuf": { "hashes": [ - "sha256:2c69461a7fcc8e24be697624c09a839976d82ae75062b11a0972e41fd2cd9132", - "sha256:35cfcb15f213449af7ff6198d6eb5f739c37d7e4f1c09b5d0641babf2cc0c68f", - "sha256:52235802093bd8a2811abbe8bf0ab9c5f54cca0a751fdd3f6ac2a21438bffece", - "sha256:59379674ff119717404f7454647913787034f03fe7049cbef1d74a97bb4593f0", - "sha256:5e8a95246d581eef20471b5d5ba010d55f66740942b95ba9b872d918c459452f", - "sha256:87317e9bcda04a32f2ee82089a204d3a2f0d3c8aeed16568c7daf4756e4f1fe0", - "sha256:8ddc60bf374785fb7cb12510b267f59067fa10087325b8e1855b898a0d81d276", - "sha256:a8b9403fc70764b08d2f593ce44f1d2920c5077bf7d311fefec999f8c40f78b7", - "sha256:c0ea0123dac3399a2eeb1a1443d82b7afc9ff40241433296769f7da42d142ec3", - "sha256:ca53faf29896c526863366a52a8f4d88e69cd04ec9571ed6082fa117fac3ab36", - "sha256:eeea10f3dc0ac7e6b4933d32db20662902b4ab81bf28df12218aa389e9c2102d" + "sha256:0c4eec6f987338617072592b97943fdbe30d019c56126493111cf24344c1cc24", + "sha256:135658402f71bbd49500322c0f736145731b16fc79dc8f367ab544a17eab4535", + "sha256:27b246b3723692bf1068d5734ddaf2fccc2cdd6e0c9b47fe099244d80200593b", + "sha256:3e6101d095dfd119513cde7259aa703d16c6bbdfae2554dfe5cfdbe94e32d548", + "sha256:3fa2de6b8b29d12c61911505d893afe7320ce7ccba4df913e2971461fa36d584", + "sha256:64badbc49180a5e401f373f9ce7ab1d18b63f7dd4a9cdc43c92b9f0b481cef7b", + "sha256:70585a70fc2dd4818c51287ceef5bdba6387f88a578c86d47bb34669b5552c36", + "sha256:712319fbdddb46f21abb66cd33cb9e491a5763b2febd8f228251add221981135", + "sha256:91fba8f445723fcf400fdbe9ca796b19d3b1242cd873907979b9ed71e4afe868", + "sha256:a3f6857551e53ce35e60b403b8a27b0295f7d6eb63d10484f12bc6879c715687", + "sha256:cee1757663fa32a1ee673434fcf3bf24dd54763c79690201208bafec62f19eed" ], "markers": "python_version >= '3.8'", - "version": "==5.28.2" + "version": "==5.28.3" }, "psycopg": { "extras": [ @@ -3289,12 +3303,12 @@ }, "pyright": { "hashes": [ - "sha256:1bf042b8f080441534aa02101dea30f8fc2efa8f7b6f1ab05197c21317f5bfa7", - "sha256:e5b9a1b8d492e13004d822af94d07d235f2c7c158457293b51ab2214c8c5b375" + "sha256:7071ac495593b2258ccdbbf495f1a5c0e5f27951f6b429bed4e8b296eb5cd21d", + "sha256:8e9975e34948ba5f8e07792a9c9d2bdceb2c6c0b61742b068d2229ca2bc4a9d9" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==1.1.385" + "version": "==1.1.386" }, "pytest": { "hashes": [ @@ -3553,11 +3567,11 @@ "hiredis" ], "hashes": [ - "sha256:f6c997521fedbae53387307c5d0bf784d9acc28d9f1d058abeac566ec4dbed72", - "sha256:f8ea06b7482a668c6475ae202ed8d9bcaa409f6e87fb77ed1043d912afd62e24" + "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0", + "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897" ], "markers": "python_version >= '3.8'", - "version": "==5.1.1" + "version": "==5.2.0" }, "referencing": { "hashes": [ @@ -3859,11 +3873,11 @@ }, "tinycss2": { "hashes": [ - "sha256:152f9acabd296a8375fbca5b84c961ff95971fcfc32e79550c8df8e29118c54d", - "sha256:54a8dbdffb334d536851be0226030e9505965bb2f30f21a4a82c55fb2a80fae7" + "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", + "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289" ], "markers": "python_version >= '3.8'", - "version": "==1.3.0" + "version": "==1.4.0" }, "tornado": { "hashes": [ @@ -3900,12 +3914,12 @@ }, "twilio": { "hashes": [ - "sha256:2cae99f0f7aecbd9da02fa59ad8f11b360db4a9281fc3fb3237ad50be21d8a9b", - "sha256:38a6ab04752f44313dcf736eae45236a901528d3f53dfc21d3afd33539243c7f" + "sha256:608d78a903d403465aac1840c58a6546a090b7e222d2bf539a93c3831072880c", + "sha256:d6a97a77b98cc176a61c960f11894af385bc1c11b93e2e8b79fdfb9601788fb0" ], "index": "pypi", "markers": "python_full_version >= '3.7.0'", - "version": "==9.3.4" + "version": "==9.3.5" }, "twisted": { "extras": [ @@ -4407,46 +4421,46 @@ }, "zope-interface": { "hashes": [ - "sha256:07add15de0cc7e69917f7d286b64d54125c950aeb43efed7a5ea7172f000fbc1", - "sha256:0ac20581fc6cd7c754f6dff0ae06fedb060fa0e9ea6309d8be8b2701d9ea51c4", - "sha256:124149e2d42067b9c6597f4dafdc7a0983d0163868f897b7bb5dc850b14f9a87", - "sha256:27cfb5205d68b12682b6e55ab8424662d96e8ead19550aad0796b08dd2c9a45e", - "sha256:2a29ac607e970b5576547f0e3589ec156e04de17af42839eedcf478450687317", - "sha256:2b6a4924f5bad9fe21d99f66a07da60d75696a136162427951ec3cb223a5570d", - "sha256:2bd9e9f366a5df08ebbdc159f8224904c1c5ce63893984abb76954e6fbe4381a", - "sha256:3bcff5c09d0215f42ba64b49205a278e44413d9bf9fa688fd9e42bfe472b5f4f", - "sha256:3f005869a1a05e368965adb2075f97f8ee9a26c61898a9e52a9764d93774f237", - "sha256:4a00ead2e24c76436e1b457a5132d87f83858330f6c923640b7ef82d668525d1", - "sha256:4af4a12b459a273b0b34679a5c3dc5e34c1847c3dd14a628aa0668e19e638ea2", - "sha256:5501e772aff595e3c54266bc1bfc5858e8f38974ce413a8f1044aae0f32a83a3", - "sha256:5e28ea0bc4b084fc93a483877653a033062435317082cdc6388dec3438309faf", - "sha256:5e956b1fd7f3448dd5e00f273072e73e50dfafcb35e4227e6d5af208075593c9", - "sha256:5fcf379b875c610b5a41bc8a891841533f98de0520287d7f85e25386cd10d3e9", - "sha256:6159e767d224d8f18deff634a1d3722e68d27488c357f62ebeb5f3e2f5288b1f", - "sha256:661d5df403cd3c5b8699ac480fa7f58047a3253b029db690efa0c3cf209993ef", - "sha256:711eebc77f2092c6a8b304bad0b81a6ce3cf5490b25574e7309fbc07d881e3af", - "sha256:80a3c00b35f6170be5454b45abe2719ea65919a2f09e8a6e7b1362312a872cd3", - "sha256:848b6fa92d7c8143646e64124ed46818a0049a24ecc517958c520081fd147685", - "sha256:91b6c30689cfd87c8f264acb2fc16ad6b3c72caba2aec1bf189314cf1a84ca33", - "sha256:9733a9a0f94ef53d7aa64661811b20875b5bc6039034c6e42fb9732170130573", - "sha256:9940d5bc441f887c5f375ec62bcf7e7e495a2d5b1da97de1184a88fb567f06af", - "sha256:9e3e48f3dea21c147e1b10c132016cb79af1159facca9736d231694ef5a740a8", - "sha256:a14c9decf0eb61e0892631271d500c1e306c7b6901c998c7035e194d9150fdd1", - "sha256:a735f82d2e3ed47ca01a20dfc4c779b966b16352650a8036ab3955aad151ed8a", - "sha256:a99240b1d02dc469f6afbe7da1bf617645e60290c272968f4e53feec18d7dce8", - "sha256:b7b25db127db3e6b597c5f74af60309c4ad65acd826f89609662f0dc33a54728", - "sha256:b936d61dbe29572fd2cfe13e30b925e5383bed1aba867692670f5a2a2eb7b4e9", - "sha256:bec001798ab62c3fc5447162bf48496ae9fba02edc295a9e10a0b0c639a6452e", - "sha256:cc8a318162123eddbdf22fcc7b751288ce52e4ad096d3766ff1799244352449d", - "sha256:d0a45b5af9f72c805ee668d1479480ca85169312211bed6ed18c343e39307d5f", - "sha256:e53c291debef523b09e1fe3dffe5f35dde164f1c603d77f770b88a1da34b7ed6", - "sha256:ec1ef1fdb6f014d5886b97e52b16d0f852364f447d2ab0f0c6027765777b6667", - "sha256:ec59fe53db7d32abb96c6d4efeed84aab4a7c38c62d7a901a9b20c09dd936e7a", - "sha256:f245d039f72e6f802902375755846f5de1ee1e14c3e8736c078565599bcab621", - "sha256:ff115ef91c0eeac69cd92daeba36a9d8e14daee445b504eeea2b1c0b55821984" + "sha256:0de23bcb93401994ea00bc5c677ef06d420340ac0a4e9c10d80e047b9ce5af3f", + "sha256:179ad46ece518c9084cb272e4a69d266b659f7f8f48e51706746c2d8a426433e", + "sha256:190eeec67e023d5aac54d183fa145db0b898664234234ac54643a441da434616", + "sha256:1a2ed0852c25950cf430067f058f8d98df6288502ac313861d9803fe7691a9b3", + "sha256:1c4e1b4c06d9abd1037c088dae1566c85f344a3e6ae4350744c3f7f7259d9c67", + "sha256:1d0e23c6b746eb8ce04573cc47bcac60961ac138885d207bd6f57e27a1431ae8", + "sha256:2317e1d4dba68203a5227ea3057f9078ec9376275f9700086b8f0ffc0b358e1b", + "sha256:2d553e02b68c0ea5a226855f02edbc9eefd99f6a8886fa9f9bdf999d77f46585", + "sha256:3603ef82a9920bd0bfb505423cb7e937498ad971ad5a6141841e8f76d2fd5446", + "sha256:3defc925c4b22ac1272d544a49c6ba04c3eefcce3200319ee1be03d9270306dd", + "sha256:3e59f175e868f856a77c0a77ba001385c377df2104fdbda6b9f99456a01e102a", + "sha256:4284d664ef0ff7b709836d4de7b13d80873dc5faeffc073abdb280058bfac5e3", + "sha256:55c373becbd36a44d0c9be1d5271422fdaa8562d158fb44b4192297b3c67096c", + "sha256:5836b8fb044c6e75ba34dfaabc602493019eadfa0faf6ff25f4c4c356a71a853", + "sha256:5cdb7e7e5524b76d3ec037c1d81a9e2c7457b240fd4cb0a2476b65c3a5a6c81f", + "sha256:6650bd56ef350d37c8baccfd3ee8a0483ed6f8666e641e4b9ae1a1827b79f9e5", + "sha256:7395f13533318f150ee72adb55b29284b16e73b6d5f02ab21f173b3e83f242b8", + "sha256:7720322763aceb5e0a7cadcc38c67b839efe599f0887cbf6c003c55b1458c501", + "sha256:7cd5e3d910ac87652a09f6e5db8e41bc3b49cf08ddd2d73d30afc644801492cd", + "sha256:81744a7e61b598ebcf4722ac56a7a4f50502432b5b4dc7eb29075a89cf82d029", + "sha256:84e87eba6b77a3af187bae82d8de1a7c208c2a04ec9f6bd444fd091b811ad92e", + "sha256:8d0fe45be57b5219aa4b96e846631c04615d5ef068146de5a02ccd15c185321f", + "sha256:9595e478047ce752b35cfa221d7601a5283ccdaab40422e0dc1d4a334c70f580", + "sha256:99c14f0727c978639139e6cad7a60e82b7720922678d75aacb90cf4ef74a068c", + "sha256:9b1eed7670d564f1025d7cda89f99f216c30210e42e95de466135be0b4a499d9", + "sha256:9fad9bd5502221ab179f13ea251cb30eef7cf65023156967f86673aff54b53a0", + "sha256:ad339509dcfbbc99bf8e147db6686249c4032f26586699ec4c82f6e5909c9fe2", + "sha256:bcbeb44fc16e0078b3b68a95e43f821ae34dcbf976dde6985141838a5f23dd3d", + "sha256:c8e7b05dc6315a193cceaec071cc3cf1c180cea28808ccded0b1283f1c38ba73", + "sha256:ca95594d936ee349620900be5b46c0122a1ff6ce42d7d5cb2cf09dc84071ef16", + "sha256:d029fac6a80edae80f79c37e5e3abfa92968fe921886139b3ee470a1b177321a", + "sha256:d17e7fc814eaab93409b80819fd6d30342844345c27f3bc3c4b43c2425a8d267", + "sha256:d6821ef9870f32154da873fcde439274f99814ea452dd16b99fa0b66345c4b6b", + "sha256:e6503534b52bb1720ace9366ee30838a58a3413d3e197512f3338c8f34b5d89d", + "sha256:ed1df8cc01dd1e3970666a7370b8bfc7457371c58ba88c57bd5bca17ab198053", + "sha256:f1d52d052355e0c5c89e0630dd2ff7c0b823fd5f56286a663e92444761b35e25", + "sha256:f85b290e5b8b11814efb0d004d8ce6c9a483c35c462e8d9bf84abb93e79fa770" ], "markers": "python_version >= '3.8'", - "version": "==7.1.0" + "version": "==7.1.1" }, "zope.event": { "hashes": [ @@ -5249,7 +5263,6 @@ "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" ], - "markers": "python_version >= '3.6'", "version": "==3.10" }, "iniconfig": { @@ -5423,42 +5436,42 @@ }, "mypy": { "hashes": [ - "sha256:02dcfe270c6ea13338210908f8cadc8d31af0f04cee8ca996438fe6a97b4ec66", - "sha256:0dcc1e843d58f444fce19da4cce5bd35c282d4bde232acdeca8279523087088a", - "sha256:0e6fe449223fa59fbee351db32283838a8fee8059e0028e9e6494a03802b4004", - "sha256:1230048fec1380faf240be6385e709c8570604d2d27ec6ca7e573e3bc09c3735", - "sha256:186e0c8346efc027ee1f9acf5ca734425fc4f7dc2b60144f0fbe27cc19dc7931", - "sha256:19bf51f87a295e7ab2894f1d8167622b063492d754e69c3c2fed6563268cb42a", - "sha256:20db6eb1ca3d1de8ece00033b12f793f1ea9da767334b7e8c626a4872090cf02", - "sha256:389e307e333879c571029d5b93932cf838b811d3f5395ed1ad05086b52148fb0", - "sha256:3d7d4371829184e22fda4015278fbfdef0327a4b955a483012bd2d423a788801", - "sha256:427878aa54f2e2c5d8db31fa9010c599ed9f994b3b49e64ae9cd9990c40bd635", - "sha256:4ee5932370ccf7ebf83f79d1c157a5929d7ea36313027b0d70a488493dc1b179", - "sha256:5fcde63ea2c9f69d6be859a1e6dd35955e87fa81de95bc240143cf00de1f7f81", - "sha256:673ba1140a478b50e6d265c03391702fa11a5c5aff3f54d69a62a48da32cb811", - "sha256:8135ffec02121a75f75dc97c81af7c14aa4ae0dda277132cfcd6abcd21551bfd", - "sha256:843826966f1d65925e8b50d2b483065c51fc16dc5d72647e0236aae51dc8d77e", - "sha256:94b2048a95a21f7a9ebc9fbd075a4fcd310410d078aa0228dbbad7f71335e042", - "sha256:96af62050971c5241afb4701c15189ea9507db89ad07794a4ee7b4e092dc0627", - "sha256:9fb83a7be97c498176fb7486cafbb81decccaef1ac339d837c377b0ce3743a7f", - "sha256:9fe20f89da41a95e14c34b1ddb09c80262edcc295ad891f22cc4b60013e8f78d", - "sha256:a5a437c9102a6a252d9e3a63edc191a3aed5f2fcb786d614722ee3f4472e33f6", - "sha256:a7b76fa83260824300cc4834a3ab93180db19876bce59af921467fd03e692810", - "sha256:b16fe09f9c741d85a2e3b14a5257a27a4f4886c171d562bc5a5e90d8591906b8", - "sha256:b947097fae68004b8328c55161ac9db7d3566abfef72d9d41b47a021c2fba6b1", - "sha256:ce561a09e3bb9863ab77edf29ae3a50e65685ad74bba1431278185b7e5d5486e", - "sha256:d34167d43613ffb1d6c6cdc0cc043bb106cac0aa5d6a4171f77ab92a3c758bcc", - "sha256:d54d840f6c052929f4a3d2aab2066af0f45a020b085fe0e40d4583db52aab4e4", - "sha256:d90da248f4c2dba6c44ddcfea94bb361e491962f05f41990ff24dbd09969ce20", - "sha256:dc6e2a2195a290a7fd5bac3e60b586d77fc88e986eba7feced8b778c373f9afe", - "sha256:de5b2a8988b4e1269a98beaf0e7cc71b510d050dce80c343b53b4955fff45f19", - "sha256:e10ba7de5c616e44ad21005fa13450cd0de7caaa303a626147d45307492e4f2d", - "sha256:f59f1dfbf497d473201356966e353ef09d4daec48caeacc0254db8ef633a28a5", - "sha256:f5b3936f7a6d0e8280c9bdef94c7ce4847f5cdfc258fbb2c29a8c1711e8bb96d" + "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc", + "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", + "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", + "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74", + "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a", + "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", + "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", + "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", + "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", + "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", + "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d", + "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6", + "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", + "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", + "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", + "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", + "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", + "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc", + "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", + "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb", + "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", + "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732", + "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", + "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", + "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", + "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", + "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", + "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24", + "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", + "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b", + "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", + "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.12.1" + "version": "==1.13.0" }, "mypy-extensions": { "hashes": [ @@ -5685,28 +5698,28 @@ }, "proto-plus": { "hashes": [ - "sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445", - "sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12" + "sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961", + "sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91" ], "markers": "python_version >= '3.7'", - "version": "==1.24.0" + "version": "==1.25.0" }, "protobuf": { "hashes": [ - "sha256:2c69461a7fcc8e24be697624c09a839976d82ae75062b11a0972e41fd2cd9132", - "sha256:35cfcb15f213449af7ff6198d6eb5f739c37d7e4f1c09b5d0641babf2cc0c68f", - "sha256:52235802093bd8a2811abbe8bf0ab9c5f54cca0a751fdd3f6ac2a21438bffece", - "sha256:59379674ff119717404f7454647913787034f03fe7049cbef1d74a97bb4593f0", - "sha256:5e8a95246d581eef20471b5d5ba010d55f66740942b95ba9b872d918c459452f", - "sha256:87317e9bcda04a32f2ee82089a204d3a2f0d3c8aeed16568c7daf4756e4f1fe0", - "sha256:8ddc60bf374785fb7cb12510b267f59067fa10087325b8e1855b898a0d81d276", - "sha256:a8b9403fc70764b08d2f593ce44f1d2920c5077bf7d311fefec999f8c40f78b7", - "sha256:c0ea0123dac3399a2eeb1a1443d82b7afc9ff40241433296769f7da42d142ec3", - "sha256:ca53faf29896c526863366a52a8f4d88e69cd04ec9571ed6082fa117fac3ab36", - "sha256:eeea10f3dc0ac7e6b4933d32db20662902b4ab81bf28df12218aa389e9c2102d" + "sha256:0c4eec6f987338617072592b97943fdbe30d019c56126493111cf24344c1cc24", + "sha256:135658402f71bbd49500322c0f736145731b16fc79dc8f367ab544a17eab4535", + "sha256:27b246b3723692bf1068d5734ddaf2fccc2cdd6e0c9b47fe099244d80200593b", + "sha256:3e6101d095dfd119513cde7259aa703d16c6bbdfae2554dfe5cfdbe94e32d548", + "sha256:3fa2de6b8b29d12c61911505d893afe7320ce7ccba4df913e2971461fa36d584", + "sha256:64badbc49180a5e401f373f9ce7ab1d18b63f7dd4a9cdc43c92b9f0b481cef7b", + "sha256:70585a70fc2dd4818c51287ceef5bdba6387f88a578c86d47bb34669b5552c36", + "sha256:712319fbdddb46f21abb66cd33cb9e491a5763b2febd8f228251add221981135", + "sha256:91fba8f445723fcf400fdbe9ca796b19d3b1242cd873907979b9ed71e4afe868", + "sha256:a3f6857551e53ce35e60b403b8a27b0295f7d6eb63d10484f12bc6879c715687", + "sha256:cee1757663fa32a1ee673434fcf3bf24dd54763c79690201208bafec62f19eed" ], "markers": "python_version >= '3.8'", - "version": "==5.28.2" + "version": "==5.28.3" }, "pyasn1": { "hashes": [ @@ -5774,12 +5787,12 @@ }, "pyright": { "hashes": [ - "sha256:1bf042b8f080441534aa02101dea30f8fc2efa8f7b6f1ab05197c21317f5bfa7", - "sha256:e5b9a1b8d492e13004d822af94d07d235f2c7c158457293b51ab2214c8c5b375" + "sha256:7071ac495593b2258ccdbbf495f1a5c0e5f27951f6b429bed4e8b296eb5cd21d", + "sha256:8e9975e34948ba5f8e07792a9c9d2bdceb2c6c0b61742b068d2229ca2bc4a9d9" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==1.1.385" + "version": "==1.1.386" }, "pytest": { "hashes": [ @@ -6076,46 +6089,46 @@ }, "zope.interface": { "hashes": [ - "sha256:07add15de0cc7e69917f7d286b64d54125c950aeb43efed7a5ea7172f000fbc1", - "sha256:0ac20581fc6cd7c754f6dff0ae06fedb060fa0e9ea6309d8be8b2701d9ea51c4", - "sha256:124149e2d42067b9c6597f4dafdc7a0983d0163868f897b7bb5dc850b14f9a87", - "sha256:27cfb5205d68b12682b6e55ab8424662d96e8ead19550aad0796b08dd2c9a45e", - "sha256:2a29ac607e970b5576547f0e3589ec156e04de17af42839eedcf478450687317", - "sha256:2b6a4924f5bad9fe21d99f66a07da60d75696a136162427951ec3cb223a5570d", - "sha256:2bd9e9f366a5df08ebbdc159f8224904c1c5ce63893984abb76954e6fbe4381a", - "sha256:3bcff5c09d0215f42ba64b49205a278e44413d9bf9fa688fd9e42bfe472b5f4f", - "sha256:3f005869a1a05e368965adb2075f97f8ee9a26c61898a9e52a9764d93774f237", - "sha256:4a00ead2e24c76436e1b457a5132d87f83858330f6c923640b7ef82d668525d1", - "sha256:4af4a12b459a273b0b34679a5c3dc5e34c1847c3dd14a628aa0668e19e638ea2", - "sha256:5501e772aff595e3c54266bc1bfc5858e8f38974ce413a8f1044aae0f32a83a3", - "sha256:5e28ea0bc4b084fc93a483877653a033062435317082cdc6388dec3438309faf", - "sha256:5e956b1fd7f3448dd5e00f273072e73e50dfafcb35e4227e6d5af208075593c9", - "sha256:5fcf379b875c610b5a41bc8a891841533f98de0520287d7f85e25386cd10d3e9", - "sha256:6159e767d224d8f18deff634a1d3722e68d27488c357f62ebeb5f3e2f5288b1f", - "sha256:661d5df403cd3c5b8699ac480fa7f58047a3253b029db690efa0c3cf209993ef", - "sha256:711eebc77f2092c6a8b304bad0b81a6ce3cf5490b25574e7309fbc07d881e3af", - "sha256:80a3c00b35f6170be5454b45abe2719ea65919a2f09e8a6e7b1362312a872cd3", - "sha256:848b6fa92d7c8143646e64124ed46818a0049a24ecc517958c520081fd147685", - "sha256:91b6c30689cfd87c8f264acb2fc16ad6b3c72caba2aec1bf189314cf1a84ca33", - "sha256:9733a9a0f94ef53d7aa64661811b20875b5bc6039034c6e42fb9732170130573", - "sha256:9940d5bc441f887c5f375ec62bcf7e7e495a2d5b1da97de1184a88fb567f06af", - "sha256:9e3e48f3dea21c147e1b10c132016cb79af1159facca9736d231694ef5a740a8", - "sha256:a14c9decf0eb61e0892631271d500c1e306c7b6901c998c7035e194d9150fdd1", - "sha256:a735f82d2e3ed47ca01a20dfc4c779b966b16352650a8036ab3955aad151ed8a", - "sha256:a99240b1d02dc469f6afbe7da1bf617645e60290c272968f4e53feec18d7dce8", - "sha256:b7b25db127db3e6b597c5f74af60309c4ad65acd826f89609662f0dc33a54728", - "sha256:b936d61dbe29572fd2cfe13e30b925e5383bed1aba867692670f5a2a2eb7b4e9", - "sha256:bec001798ab62c3fc5447162bf48496ae9fba02edc295a9e10a0b0c639a6452e", - "sha256:cc8a318162123eddbdf22fcc7b751288ce52e4ad096d3766ff1799244352449d", - "sha256:d0a45b5af9f72c805ee668d1479480ca85169312211bed6ed18c343e39307d5f", - "sha256:e53c291debef523b09e1fe3dffe5f35dde164f1c603d77f770b88a1da34b7ed6", - "sha256:ec1ef1fdb6f014d5886b97e52b16d0f852364f447d2ab0f0c6027765777b6667", - "sha256:ec59fe53db7d32abb96c6d4efeed84aab4a7c38c62d7a901a9b20c09dd936e7a", - "sha256:f245d039f72e6f802902375755846f5de1ee1e14c3e8736c078565599bcab621", - "sha256:ff115ef91c0eeac69cd92daeba36a9d8e14daee445b504eeea2b1c0b55821984" + "sha256:0de23bcb93401994ea00bc5c677ef06d420340ac0a4e9c10d80e047b9ce5af3f", + "sha256:179ad46ece518c9084cb272e4a69d266b659f7f8f48e51706746c2d8a426433e", + "sha256:190eeec67e023d5aac54d183fa145db0b898664234234ac54643a441da434616", + "sha256:1a2ed0852c25950cf430067f058f8d98df6288502ac313861d9803fe7691a9b3", + "sha256:1c4e1b4c06d9abd1037c088dae1566c85f344a3e6ae4350744c3f7f7259d9c67", + "sha256:1d0e23c6b746eb8ce04573cc47bcac60961ac138885d207bd6f57e27a1431ae8", + "sha256:2317e1d4dba68203a5227ea3057f9078ec9376275f9700086b8f0ffc0b358e1b", + "sha256:2d553e02b68c0ea5a226855f02edbc9eefd99f6a8886fa9f9bdf999d77f46585", + "sha256:3603ef82a9920bd0bfb505423cb7e937498ad971ad5a6141841e8f76d2fd5446", + "sha256:3defc925c4b22ac1272d544a49c6ba04c3eefcce3200319ee1be03d9270306dd", + "sha256:3e59f175e868f856a77c0a77ba001385c377df2104fdbda6b9f99456a01e102a", + "sha256:4284d664ef0ff7b709836d4de7b13d80873dc5faeffc073abdb280058bfac5e3", + "sha256:55c373becbd36a44d0c9be1d5271422fdaa8562d158fb44b4192297b3c67096c", + "sha256:5836b8fb044c6e75ba34dfaabc602493019eadfa0faf6ff25f4c4c356a71a853", + "sha256:5cdb7e7e5524b76d3ec037c1d81a9e2c7457b240fd4cb0a2476b65c3a5a6c81f", + "sha256:6650bd56ef350d37c8baccfd3ee8a0483ed6f8666e641e4b9ae1a1827b79f9e5", + "sha256:7395f13533318f150ee72adb55b29284b16e73b6d5f02ab21f173b3e83f242b8", + "sha256:7720322763aceb5e0a7cadcc38c67b839efe599f0887cbf6c003c55b1458c501", + "sha256:7cd5e3d910ac87652a09f6e5db8e41bc3b49cf08ddd2d73d30afc644801492cd", + "sha256:81744a7e61b598ebcf4722ac56a7a4f50502432b5b4dc7eb29075a89cf82d029", + "sha256:84e87eba6b77a3af187bae82d8de1a7c208c2a04ec9f6bd444fd091b811ad92e", + "sha256:8d0fe45be57b5219aa4b96e846631c04615d5ef068146de5a02ccd15c185321f", + "sha256:9595e478047ce752b35cfa221d7601a5283ccdaab40422e0dc1d4a334c70f580", + "sha256:99c14f0727c978639139e6cad7a60e82b7720922678d75aacb90cf4ef74a068c", + "sha256:9b1eed7670d564f1025d7cda89f99f216c30210e42e95de466135be0b4a499d9", + "sha256:9fad9bd5502221ab179f13ea251cb30eef7cf65023156967f86673aff54b53a0", + "sha256:ad339509dcfbbc99bf8e147db6686249c4032f26586699ec4c82f6e5909c9fe2", + "sha256:bcbeb44fc16e0078b3b68a95e43f821ae34dcbf976dde6985141838a5f23dd3d", + "sha256:c8e7b05dc6315a193cceaec071cc3cf1c180cea28808ccded0b1283f1c38ba73", + "sha256:ca95594d936ee349620900be5b46c0122a1ff6ce42d7d5cb2cf09dc84071ef16", + "sha256:d029fac6a80edae80f79c37e5e3abfa92968fe921886139b3ee470a1b177321a", + "sha256:d17e7fc814eaab93409b80819fd6d30342844345c27f3bc3c4b43c2425a8d267", + "sha256:d6821ef9870f32154da873fcde439274f99814ea452dd16b99fa0b66345c4b6b", + "sha256:e6503534b52bb1720ace9366ee30838a58a3413d3e197512f3338c8f34b5d89d", + "sha256:ed1df8cc01dd1e3970666a7370b8bfc7457371c58ba88c57bd5bca17ab198053", + "sha256:f1d52d052355e0c5c89e0630dd2ff7c0b823fd5f56286a663e92444761b35e25", + "sha256:f85b290e5b8b11814efb0d004d8ce6c9a483c35c462e8d9bf84abb93e79fa770" ], "markers": "python_version >= '3.8'", - "version": "==7.1.0" + "version": "==7.1.1" } } } diff --git a/breathecode/admissions/serializers.py b/breathecode/admissions/serializers.py index 575d7fc0e..e322a2b64 100644 --- a/breathecode/admissions/serializers.py +++ b/breathecode/admissions/serializers.py @@ -1,6 +1,7 @@ import logging from collections import OrderedDict +from capyc.rest_framework.exceptions import ValidationException from django.contrib.auth.models import User from django.db.models import Q @@ -9,7 +10,6 @@ from breathecode.assignments.serializers import TaskGETSmallSerializer from breathecode.authenticate.models import CredentialsGithub, ProfileAcademy from breathecode.utils import localize_query, serializers, serpy -from capyc.rest_framework.exceptions import ValidationException from .actions import haversine, test_syllabus from .models import ( diff --git a/breathecode/mentorship/permissions/consumers.py b/breathecode/mentorship/permissions/consumers.py index ffd033a94..24ee0abeb 100644 --- a/breathecode/mentorship/permissions/consumers.py +++ b/breathecode/mentorship/permissions/consumers.py @@ -1,5 +1,8 @@ import logging +from capyc.core.managers import feature +from capyc.rest_framework.exceptions import PaymentException, ValidationException + from breathecode.admissions.actions import is_no_saas_student_up_to_date_in_any_cohort from breathecode.authenticate.actions import get_user_language from breathecode.authenticate.models import User @@ -7,7 +10,6 @@ from breathecode.payments.models import Consumable, ConsumptionSession from breathecode.utils.decorators import ServiceContext from breathecode.utils.i18n import translation -from capyc.rest_framework.exceptions import PaymentException, ValidationException logger = logging.getLogger(__name__) @@ -111,16 +113,17 @@ def mentorship_service_by_url_param(context: ServiceContext, args: tuple, kwargs ) ) ): - - raise ValidationException( - translation( - lang, - en=f'Mentee do not have enough credits to access this service: {context["service"]}', - es="El mentee no tiene suficientes créditos para acceder a este servicio: " f'{context["service"]}', - ), - slug="mentee-not-enough-consumables", - code=402, - ) + c = feature.context(context=context, kwargs=kwargs, user=mentee) + if feature.is_enabled("payments.bypass_consumption", c, False) is False: + raise ValidationException( + translation( + lang, + en=f'Mentee do not have enough credits to access this service: {context["service"]}', + es="El mentee no tiene suficientes créditos para acceder a este servicio: " f'{context["service"]}', + ), + slug="mentee-not-enough-consumables", + code=402, + ) if consumable: session = ConsumptionSession.build_session(request, consumable, mentorship_service.max_duration, mentee) diff --git a/breathecode/mentorship/tests/urls_shortner/tests_meet_slug_service_slug.py b/breathecode/mentorship/tests/urls_shortner/tests_meet_slug_service_slug.py index 6da3b58b5..d1ed9253c 100644 --- a/breathecode/mentorship/tests/urls_shortner/tests_meet_slug_service_slug.py +++ b/breathecode/mentorship/tests/urls_shortner/tests_meet_slug_service_slug.py @@ -10,6 +10,7 @@ import capyc.pytest as capy import pytest import timeago +from capyc.core.managers import feature from django.core.handlers.wsgi import WSGIRequest from django.template import loader from django.test.client import FakePayload @@ -3241,7 +3242,8 @@ def test_with_mentor_profile__redirect_to_session__no_saas(self): ), ) @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) - def test_with_mentor_profile__redirect_to_session__saas(self): + @patch("capyc.core.managers.feature.is_enabled", MagicMock(return_value=False)) + def test_with_mentor_profile__redirect_to_session__saas__bypass_consumption_false(self): mentor_profile_cases = [ { "status": x, @@ -3322,12 +3324,231 @@ def test_with_mentor_profile__redirect_to_session__saas(self): ) self.assertEqual(self.bc.database.list_of("payments.Consumable"), []) self.assertEqual(self.bc.database.list_of("payments.ConsumptionSession"), []) + calls = [ + call( + args[0], + { + **args[1], + "context": { + **args[1]["context"], + "request": type(args[1]["context"]["request"]), + "consumer": callable(args[1]["context"]["consumer"]), + "consumables": [x for x in args[1]["context"]["consumables"]], + }, + }, + *args[2:], + **kwargs, + ) + for args, kwargs in feature.is_enabled.call_args_list + ] + context1 = feature.context( + context={ + "utc_now": UTC_NOW, + "consumer": True, + "service": "join_mentorship", + "request": WSGIRequest, + "consumables": [], + "lifetime": None, + "price": 0, + "is_consumption_session": False, + "flags": {"bypass_consumption": False}, + }, + ) + context2 = feature.context( + context={ + "utc_now": UTC_NOW, + "consumer": True, + "service": "join_mentorship", + "request": WSGIRequest, + "consumables": [], + "lifetime": None, + "price": 0, + "is_consumption_session": False, + "flags": {"bypass_consumption": False}, + }, + user=base.user, + ) + assert calls == [ + call("payments.bypass_consumption", context1, False), + call("payments.bypass_consumption", context2, False), + ] + + # teardown + self.bc.database.delete("mentorship.MentorProfile") + + self.bc.database.delete("auth.Permission") + self.bc.database.delete("payments.Service") + feature.is_enabled.call_args_list = [] + + @patch("breathecode.mentorship.actions.mentor_is_ready", MagicMock()) + @patch( + "os.getenv", + MagicMock( + side_effect=apply_get_env( + { + "DAILY_API_URL": URL, + "DAILY_API_KEY": API_KEY, + } + ) + ), + ) + @patch( + "requests.request", + apply_requests_request_mock( + [ + ( + 201, + f"{URL}/v1/rooms", + { + "name": ROOM_NAME, + "url": ROOM_URL, + }, + ) + ] + ), + ) + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + @patch("capyc.core.managers.feature.is_enabled", MagicMock(side_effect=[False, True, False, True])) + def test_with_mentor_profile__redirect_to_session__saas__bypass_consumption_true(self): + mentor_profile_cases = [ + { + "status": x, + "online_meeting_url": self.bc.fake.url(), + "booking_url": self.bc.fake.url(), + } + for x in ["ACTIVE", "UNLISTED"] + ] + + id = 0 + for mentor_profile in mentor_profile_cases: + id += 1 + + user = {"first_name": "", "last_name": ""} + service = {"consumer": "JOIN_MENTORSHIP"} + base = self.bc.database.create(user=user, token=1, service=service) + + ends_at = UTC_NOW - timedelta(seconds=3600 / 2 + 1) + + academy = {"available_as_saas": True} + mentorship_session = { + "mentee_id": base.user.id, + "ends_at": ends_at, + "allow_mentee_to_extend": True, + } + token = 1 + + model = self.bc.database.create( + mentor_profile=mentor_profile, + mentorship_session=mentorship_session, + user=user, + token=token, + mentorship_service={"language": "en", "video_provider": "DAILY"}, + service=base.service, + academy=academy, + ) + + model.mentorship_session.mentee = None + model.mentorship_session.save() + + token = model.token if "token" in model else base.token + + querystring = self.bc.format.to_querystring( + { + "token": token.key, + "extend": "true", + "mentee": base.user.id, + "session": model.mentorship_session.id, + } + ) + url = ( + reverse_lazy( + "mentorship_shortner:meet_slug_service_slug", + kwargs={"mentor_slug": model.mentor_profile.slug, "service_slug": model.mentorship_service.slug}, + ) + + f"?{querystring}" + ) + response = self.client.get(url) + + content = self.bc.format.from_bytes(response.content) + expected = "" + + # dump error in external files + if content != expected: + with open("content.html", "w") as f: + f.write(content) + + with open("expected.html", "w") as f: + f.write(expected) + + self.assertEqual(content, expected) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + assert ( + response.url + == f"/mentor/session/{model.mentorship_session.id}?token={token.key}&message=You%20have%20a%20session%20that%20expired%2030%20minutes%20ago.%20Only%20sessions%20with%20less%20than%2030min%20from%20expiration%20can%20be%20extended%20(if%20allowed%20by%20the%20academy)" + ) + self.assertEqual( + self.bc.database.list_of("mentorship.MentorProfile"), + [ + self.bc.format.to_dict(model.mentor_profile), + ], + ) + self.assertEqual(self.bc.database.list_of("payments.Consumable"), []) + self.assertEqual(self.bc.database.list_of("payments.ConsumptionSession"), []) + calls = [ + call( + args[0], + { + **args[1], + "context": { + **args[1]["context"], + "request": type(args[1]["context"]["request"]), + "consumer": callable(args[1]["context"]["consumer"]), + "consumables": [x for x in args[1]["context"]["consumables"]], + }, + }, + *args[2:], + **kwargs, + ) + for args, kwargs in feature.is_enabled.call_args_list + ] + context1 = feature.context( + context={ + "utc_now": UTC_NOW, + "consumer": True, + "service": "join_mentorship", + "request": WSGIRequest, + "consumables": [], + "lifetime": None, + "price": 0, + "is_consumption_session": False, + "flags": {"bypass_consumption": False}, + }, + ) + context2 = feature.context( + context={ + "utc_now": UTC_NOW, + "consumer": True, + "service": "join_mentorship", + "request": WSGIRequest, + "consumables": [], + "lifetime": None, + "price": 0, + "is_consumption_session": False, + "flags": {"bypass_consumption": False}, + }, + user=base.user, + ) + assert calls == [ + call("payments.bypass_consumption", context1, False), + call("payments.bypass_consumption", context2, False), + ] # teardown self.bc.database.delete("mentorship.MentorProfile") self.bc.database.delete("auth.Permission") self.bc.database.delete("payments.Service") + feature.is_enabled.call_args_list = [] @patch("breathecode.mentorship.actions.mentor_is_ready", MagicMock()) @patch( diff --git a/breathecode/middlewares.py b/breathecode/middlewares.py index c1e8309b5..486f1082e 100644 --- a/breathecode/middlewares.py +++ b/breathecode/middlewares.py @@ -6,8 +6,8 @@ import brotli import zstandard -from asgiref.sync import iscoroutinefunction -from django.http import HttpResponseRedirect +from asgiref.sync import iscoroutinefunction, sync_to_async +from django.http import HttpRequest, HttpResponseRedirect from django.utils.decorators import sync_and_async_middleware from django.utils.deprecation import MiddlewareMixin @@ -148,3 +148,49 @@ def middleware(request): return response return middleware + + +NO_PAGINATED = set() + + +@sync_and_async_middleware +def detect_pagination_issues_middleware(get_response): + from breathecode.monitoring.models import NoPagination + + def instrument_no_pagination(request: HttpRequest) -> None: + if request.method not in ["GET"]: + return + + path = request.path + method = request.method + + if (path, method) in NO_PAGINATED: + return + + is_paginated = request.GET.get("limit") and request.GET.get("offset") + + if is_paginated is False and NoPagination.objects.filter(path=path, method=method).exists() is False: + NO_PAGINATED.add((path, method)) + NoPagination.objects.create(path=path, method=method) + + @sync_to_async + def ainstrument_no_pagination(request: HttpRequest) -> None: + instrument_no_pagination(request) + + if iscoroutinefunction(get_response): + + async def middleware(request: HttpRequest): + await ainstrument_no_pagination(request) + + response = await get_response(request) + return response + + else: + + def middleware(request: HttpRequest): + instrument_no_pagination(request) + + response = get_response(request) + return response + + return middleware diff --git a/breathecode/monitoring/admin.py b/breathecode/monitoring/admin.py index fd8ed7883..1a2532969 100644 --- a/breathecode/monitoring/admin.py +++ b/breathecode/monitoring/admin.py @@ -16,6 +16,7 @@ CSVUpload, Endpoint, MonitorScript, + NoPagination, RepositorySubscription, RepositoryWebhook, Supervisor, @@ -315,3 +316,15 @@ class SupervisorIssueAdmin(admin.ModelAdmin): list_filter = ["supervisor"] search_fields = ["supervisor__task_module", "supervisor__task_name"] actions = [] + + +def delete_all(modeladmin, request, queryset): + NoPagination.objects.all().delete() + + +@admin.register(NoPagination) +class NoPaginationAdmin(admin.ModelAdmin): + list_display = ("path", "method") + list_filter = ["method"] + search_fields = ["path", "method"] + actions = [delete_all] diff --git a/breathecode/monitoring/migrations/0024_nopagination.py b/breathecode/monitoring/migrations/0024_nopagination.py new file mode 100644 index 000000000..d4c4bb73b --- /dev/null +++ b/breathecode/monitoring/migrations/0024_nopagination.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.2 on 2024-10-25 16:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("monitoring", "0023_repositorysubscription_last_call"), + ] + + operations = [ + migrations.CreateModel( + name="NoPagination", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("path", models.CharField(max_length=255)), + ("method", models.CharField(max_length=9)), + ], + ), + ] diff --git a/breathecode/monitoring/models.py b/breathecode/monitoring/models.py index e564e5e93..ce2cfc44d 100644 --- a/breathecode/monitoring/models.py +++ b/breathecode/monitoring/models.py @@ -346,3 +346,8 @@ def save(self, *args, **kwargs): self.full_clean() return super().save(*args, **kwargs) + + +class NoPagination(models.Model): + path = models.CharField(max_length=255) + method = models.CharField(max_length=9) diff --git a/breathecode/payments/actions.py b/breathecode/payments/actions.py index bd9bcd67f..d4d27bee6 100644 --- a/breathecode/payments/actions.py +++ b/breathecode/payments/actions.py @@ -1,9 +1,11 @@ import os import re +from datetime import datetime from functools import lru_cache -from typing import Optional, Type +from typing import Literal, Optional, Type, TypedDict from adrf.requests import AsyncRequest +from capyc.rest_framework.exceptions import ValidationException from dateutil.relativedelta import relativedelta from django.contrib.auth.models import User from django.core.handlers.wsgi import WSGIRequest @@ -22,15 +24,17 @@ from breathecode.utils import getLogger from breathecode.utils.i18n import translation from breathecode.utils.validate_conversion_info import validate_conversion_info -from capyc.rest_framework.exceptions import ValidationException from .models import ( SERVICE_UNITS, Bag, + CohortSet, Consumable, Coupon, Currency, + EventTypeSet, Invoice, + MentorshipServiceSet, PaymentMethod, Plan, PlanFinancing, @@ -735,15 +739,20 @@ def filter_void_consumable_balance(request: WSGIRequest, items: QuerySet[Consuma } ) - return result.values() + return list(result.values()) -def get_balance_by_resource(queryset: QuerySet, key: str): +def get_balance_by_resource( + queryset: QuerySet[Consumable], + key: str, +): result = [] ids = {getattr(x, key).id for x in queryset} for id in ids: current = queryset.filter(**{f"{key}__id": id}) + # current_virtual = [x for x in x if x[key] == id] + instance = current.first() balance = {} items = [] @@ -754,6 +763,9 @@ def get_balance_by_resource(queryset: QuerySet, key: str): -1 if per_unit.filter(how_many=-1).exists() else per_unit.aggregate(Sum("how_many"))["how_many__sum"] ) + # for unit in current_virtual: + # ... + for x in queryset: valid_until = x.valid_until if valid_until: @@ -1108,3 +1120,145 @@ def validate_and_create_subscriptions( tasks.build_plan_financing.delay(bag.id, invoice.id, conversion_info=conversion_info) return invoice, coupons + + +class UnitBalance(TypedDict): + unit: int + + +class ConsumableItem(TypedDict): + id: int + how_many: int + unit_type: str + valid_until: Optional[datetime] + + +class ResourceBalance(TypedDict): + id: int + slug: str + balance: UnitBalance + items: list[ConsumableItem] + + +class ConsumableBalance(TypedDict): + mentorship_service_sets: ResourceBalance + cohort_sets: list[ResourceBalance] + event_type_sets: list[ResourceBalance] + voids: list[ResourceBalance] + + +def set_virtual_balance(balance: ConsumableBalance, user: User) -> None: + from breathecode.payments.data import get_virtual_consumables + + virtuals = get_virtual_consumables() + + event_type_set_ids = [virtual["event_type_set"]["id"] for virtual in virtuals if virtual["event_type_set"]] + cohort_set_ids = [virtual["cohort_set"]["id"] for virtual in virtuals if virtual["cohort_set"]] + mentorship_service_set_ids = [ + virtual["mentorship_service_set"]["id"] for virtual in virtuals if virtual["mentorship_service_set"] + ] + + available_services = [ + virtual["service_item"]["service"]["id"] + for virtual in virtuals + if virtual["service_item"]["service"]["type"] == Service.Type.VOID + ] + + available_event_type_sets = EventTypeSet.objects.filter( + academy__profileacademy__user=user, id__in=event_type_set_ids + ).values_list("id", flat=True) + + available_cohort_sets = CohortSet.objects.filter(cohorts__cohortuser__user=user, id__in=cohort_set_ids).values_list( + "id", flat=True + ) + + available_mentorship_service_sets = MentorshipServiceSet.objects.filter( + academy__profileacademy__user=user, id__in=mentorship_service_set_ids + ).values_list("id", flat=True) + + balance_mapping: dict[str, dict[int, int]] = { + "cohort_sets": dict( + [(v["id"], i) for (i, v) in enumerate(balance["cohort_sets"]) if v["id"] in available_cohort_sets] + ), + "event_type_sets": dict( + [(v["id"], i) for (i, v) in enumerate(balance["event_type_sets"]) if v["id"] in available_event_type_sets] + ), + "mentorship_service_sets": dict( + [ + (v["id"], i) + for (i, v) in enumerate(balance["mentorship_service_sets"]) + if v["id"] in available_mentorship_service_sets + ] + ), + "voids": dict([(v["id"], i) for (i, v) in enumerate(balance["voids"]) if v["id"] in available_services]), + } + + def append( + key: Literal["cohort_sets", "event_type_sets", "mentorship_service_sets", "voids"], + id: int, + slug: str, + how_many: int, + unit_type: str, + valid_until: Optional[datetime] = None, + ): + + index = balance_mapping[key].get(id) + + # index = balance[key].append(id) + unit_type = unit_type.lower() + if index is None: + balance[key].append({"id": id, "slug": slug, "balance": {unit_type: 0}, "items": []}) + index = len(balance[key]) - 1 + balance_mapping[key][id] = index + + obj = balance[key][index] + + if how_many == -1: + obj["balance"][unit_type] = how_many + + elif obj["balance"][unit_type] != -1: + obj["balance"][unit_type] += how_many + + obj["items"].append( + { + "id": None, + "how_many": how_many, + "unit_type": unit_type.upper(), + "valid_until": valid_until, + } + ) + + for virtual in virtuals: + if ( + virtual["service_item"]["service"]["type"] == Service.Type.VOID + and virtual["service_item"]["service"]["id"] in available_services + ): + id = virtual["service_item"]["service"]["id"] + slug = virtual["service_item"]["service"]["slug"] + how_many = virtual["service_item"]["how_many"] + unit_type = virtual["service_item"]["unit_type"] + append("voids", id, slug, how_many, unit_type) + + if virtual["event_type_set"] and virtual["event_type_set"]["id"] in available_event_type_sets: + id = virtual["event_type_set"]["id"] + slug = virtual["event_type_set"]["slug"] + how_many = virtual["service_item"]["how_many"] + unit_type = virtual["service_item"]["unit_type"] + append("event_type_sets", id, slug, how_many, unit_type) + + if ( + virtual["mentorship_service_set"] + and virtual["mentorship_service_set"]["id"] in available_mentorship_service_sets + ): + id = virtual["mentorship_service_set"]["id"] + slug = virtual["mentorship_service_set"]["slug"] + how_many = virtual["service_item"]["how_many"] + unit_type = virtual["service_item"]["unit_type"] + append("mentorship_service_sets", id, slug, how_many, unit_type) + + if virtual["cohort_set"] and virtual["cohort_set"]["id"] in available_cohort_sets: + id = virtual["cohort_set"]["id"] + slug = virtual["cohort_set"]["slug"] + how_many = virtual["service_item"]["how_many"] + unit_type = virtual["service_item"]["unit_type"] + append("cohort_sets", id, slug, how_many, unit_type) diff --git a/breathecode/payments/apps.py b/breathecode/payments/apps.py index 83529ece9..0d7c3b9d9 100644 --- a/breathecode/payments/apps.py +++ b/breathecode/payments/apps.py @@ -6,5 +6,6 @@ class PaymentsConfig(AppConfig): name = "breathecode.payments" def ready(self): + from . import flags # noqa: F401 from . import receivers # noqa: F401 from . import supervisors # noqa: F401 diff --git a/breathecode/payments/data.py b/breathecode/payments/data.py new file mode 100644 index 000000000..2697fc29b --- /dev/null +++ b/breathecode/payments/data.py @@ -0,0 +1,23 @@ +from functools import lru_cache + +from breathecode.payments.utils import ConsumableType, consumable, service_item + +__all__ = ["get_virtual_consumables"] + + +@lru_cache(maxsize=1) +def get_virtual_consumables() -> list[ConsumableType]: + return [ + consumable( + service_item=1, + cohort_set=1, + event_type_set=1, + mentorship_service_set=1, + ), + consumable( + service_item=service_item(service=1, unit_type="unit", how_many=1), + cohort_set=1, + event_type_set=1, + mentorship_service_set=1, + ), + ] diff --git a/breathecode/payments/flags.py b/breathecode/payments/flags.py new file mode 100644 index 000000000..d54ce2483 --- /dev/null +++ b/breathecode/payments/flags.py @@ -0,0 +1,130 @@ +from typing import Optional + +from capyc.core.managers import feature +from django.db.models.query_utils import Q + +from breathecode.admissions.models import Cohort +from breathecode.assignments.models import Task +from breathecode.authenticate.models import User +from breathecode.events.models import Event, LiveClass +from breathecode.payments.models import CohortSet, MentorshipServiceSet +from breathecode.registry.models import Asset +from breathecode.utils.decorators.consume import ServiceContext + +flags = feature.flags + + +@feature.availability("payments.bypass_consumption") +def bypass_consumption(context: ServiceContext, kwargs: Optional[dict] = None, user: Optional[User] = None) -> bool: + """ + This flag is used to bypass the consumption of a service. + + Arguments: + context: ServiceContext - The context of the service. + args: Optional[tuple] - The arguments of the service. + kwargs: Optional[dict] - The keyword arguments of the service. + user: Optional[User] - The user to bypass the consumption for, if none it will use request.user. + """ + from breathecode.payments.data import get_virtual_consumables + + if kwargs is None: + kwargs = {} + + if flags.get("BYPASS_CONSUMPTION") in feature.TRUE: + return True + + virtual_consumables = get_virtual_consumables() + user = user or context["request"].user + + event_id = kwargs.get("event_id") + event_slug = kwargs.get("event_slug") + if context["service"] == "event_join" and (event_id or event_slug): + + pk = Q(id=event_id) | Q(slug=event_slug, slug__isnull=False) + event_type_set_ids = [ + consumable["event_type_set"]["id"] for consumable in virtual_consumables if consumable["event_type_set"] + ] + + event = Event.objects.filter(pk, event_type__eventtypeset__in=event_type_set_ids).first() + if not event: + return False + + if event.academy and event.academy.available_as_saas: + return False + + return True + + hash = kwargs.get("hash") + if context["service"] == "live_class_join" and (hash := kwargs.get("hash")): + live_class = LiveClass.objects.filter(hash=hash).first() + if live_class is None: + return False + + cohort = live_class.cohort_time_slot.cohort + if cohort.available_as_saas is True or ( + cohort.available_as_saas is None and cohort.academy.available_as_saas is True + ): + return False + + cohort_set_ids = [consumable["cohort_set"]["id"] for consumable in virtual_consumables] + if CohortSet.objects.filter(cohorts=cohort, id__in=cohort_set_ids).exists(): + return True + + return False + + if context["service"] == "join_mentorship" and (service_slug := kwargs.get("service_slug")): + + mentorship_service_set_ids = [ + consumable["mentorship_service_set"]["id"] + for consumable in virtual_consumables + if consumable["mentorship_service_set"] + ] + + if MentorshipServiceSet.objects.filter( + mentorship_services__slug=service_slug, + mentorship_services__academy__available_as_saas=False, + id__in=mentorship_service_set_ids, + ).exists(): + return True + + return False + + if context["service"] == "add_code_review" and (task_id := kwargs.get("task_id")): + cohort_set_ids = [ + consumable["cohort_set"]["id"] for consumable in virtual_consumables if consumable["cohort_set"] + ] + + task = Task.objects.filter(id=task_id, cohort__cohortset__id__in=cohort_set_ids).first() + if task is None: + return False + + if task.cohort.available_as_saas is True or ( + task.cohort.available_as_saas is None and task.cohort.academy.available_as_saas is True + ): + return False + + return True + + if context["service"] == "read_lesson" and (asset_slug := kwargs.get("asset_slug")): + cohort_set_ids = [ + consumable["cohort_set"]["id"] for consumable in virtual_consumables if consumable["cohort_set"] + ] + request = context["request"] + asset = Asset.get_by_slug(asset_slug, request) + if asset is None: + return False + + if Cohort.objects.filter( + Q(available_as_saas=False) | Q(available_as_saas=None, academy__available_as_saas=False), + cohortuser__user=user, + syllabus_version__json__icontains=f'"{asset_slug}"', + cohortset__id__in=cohort_set_ids, + ).exists(): + return True + + return False + + return False + + +feature.add(bypass_consumption) diff --git a/breathecode/payments/models.py b/breathecode/payments/models.py index 9479bacc5..67bd33422 100644 --- a/breathecode/payments/models.py +++ b/breathecode/payments/models.py @@ -7,6 +7,7 @@ from typing import Any, Optional from asgiref.sync import sync_to_async +from capyc.rest_framework.exceptions import ValidationException from currencies import Currency as CurrencyFormatter from django import forms from django.contrib.auth.models import Group, Permission, User @@ -24,7 +25,6 @@ from breathecode.payments import signals from breathecode.utils.i18n import translation from breathecode.utils.validators.language import validate_language_code -from capyc.rest_framework.exceptions import ValidationException # https://devdocs.prestashop-project.org/1.7/webservice/resources/warehouses/ @@ -328,19 +328,6 @@ class CohortSet(models.Model): Cohort, blank=True, through="CohortSetCohort", through_fields=("cohort_set", "cohort") ) - def clean(self) -> None: - if self.academy.available_as_saas == False: - raise forms.ValidationError( - translation( - self._lang, - en="Academy is not available as SaaS", - es="La academia no está disponible como SaaS", - slug="academy-not-available-as-saas", - ) - ) - - return super().clean() - def save(self, *args, **kwargs) -> None: self.full_clean() return super().save(*args, **kwargs) diff --git a/breathecode/payments/tests/flags/tests_bypass_consumption.py b/breathecode/payments/tests/flags/tests_bypass_consumption.py new file mode 100644 index 000000000..aaf9058af --- /dev/null +++ b/breathecode/payments/tests/flags/tests_bypass_consumption.py @@ -0,0 +1,561 @@ +from datetime import datetime +from typing import Any, Callable, Optional, Tuple, Unpack +from unittest.mock import MagicMock + +import capyc.pytest as capy +import pytest +from adrf.requests import AsyncRequest +from adrf.test import AsyncAPIRequestFactory +from capyc.core.managers import feature +from django.contrib.auth.models import User +from django.core.handlers.wsgi import WSGIRequest +from django.db.models import QuerySet, Sum +from django.urls.base import reverse_lazy +from rest_framework.test import APIRequestFactory + +from breathecode.payments.models import Consumable +from breathecode.payments.utils import ConsumableType, consumable, service_item +from breathecode.utils.decorators.consume import ServiceContext + + +@pytest.fixture(autouse=True) +def setup(db: None, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("breathecode.events.models.LiveClass._get_hash", lambda self: "abc") + + yield + + +def build_context( + request: WSGIRequest | AsyncRequest, + service: str, + utc_now: datetime, + consumables: QuerySet[Consumable] = Consumable.objects.none(), + flags: Optional[dict[str, Any]] = None, + **opts: Unpack[ServiceContext], +) -> ServiceContext: + + if flags is None: + flags = {} + + return { + "utc_now": utc_now, + "consumer": None, + "service": service, + "request": request, + "consumables": consumables, + "lifetime": None, + "price": 1, + "is_consumption_session": False, + "flags": flags, + **opts, + } + + +PatchBypassConsumption = Callable[[Tuple[ConsumableType, ...]], list[ConsumableType]] + + +@pytest.fixture +def patch_bypass_consumption(monkeypatch: pytest.MonkeyPatch): + def wrapper(*args: ConsumableType) -> list[ConsumableType]: + monkeypatch.setattr("breathecode.payments.data.get_virtual_consumables", MagicMock(return_value=[*args])) + + yield wrapper + + +PatchFlag = Callable[[str], None] + + +@pytest.fixture(autouse=True) +def patch_flag(monkeypatch: pytest.MonkeyPatch): + def wrapper(v: str) -> None: + monkeypatch.setattr("breathecode.payments.flags.flags", {"BYPASS_CONSUMPTION": v}) + + wrapper("0") + + yield wrapper + + +def test_flag_true(database: capy.Database, patch_flag: PatchFlag): + patch_flag("1") + model = database.create(user=1) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {} + service_context = ServiceContext( + # consumer=False, + service="event_join", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is True + + +class TestEventJoin: + @pytest.mark.parametrize( + "models", + [ + { + "event_type": {"description": "abc"}, + "event_type_set": {"event_types": []}, + "service_item": 1, + }, + { + "event": 1, + "event_type": {"description": "abc"}, + "event_type_set": {"event_types": [1]}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + ], + ) + def test_false( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, event_type_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"event_id": 1} + service_context = ServiceContext( + # consumer=False, + service="event_join", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is False + + @pytest.mark.parametrize( + "models", + [ + { + "event": 1, + "event_type": {"description": "abc"}, + "event_type_set": {"event_types": [1]}, + "service_item": 1, + "academy": {"available_as_saas": False}, + }, + ], + ) + def test_true( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, event_type_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"event_id": 1} + service_context = ServiceContext( + # consumer=False, + service="event_join", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is True + + +class TestLiveClassJoin: + @pytest.mark.parametrize( + "models", + [ + { + "cohort": {"available_as_saas": True}, + "cohort_set": {"cohorts": [1]}, + "live_class": {"hash": "abc"}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "cohort": {"available_as_saas": None}, + "cohort_set": {"cohorts": [1]}, + "live_class": {"hash": "abc"}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "cohort": {"available_as_saas": False}, + "cohort_set": {"cohorts": []}, + "live_class": {"hash": "abc"}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "cohort": {"available_as_saas": False}, + "cohort_set": {"cohorts": [1]}, + "live_class": {"hash": "abcd"}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + ], + ) + def test_false( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, cohort_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"event_id": 1} + service_context = ServiceContext( + # consumer=False, + service="live_class_join", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is False + + @pytest.mark.parametrize( + "models", + [ + { + "cohort": {"available_as_saas": False}, + "cohort_set": {"cohorts": [1]}, + "live_class": {"hash": "abc"}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "cohort": {"available_as_saas": None}, + "cohort_set": {"cohorts": [1]}, + "live_class": {"hash": "abc"}, + "service_item": 1, + "academy": {"available_as_saas": False}, + }, + ], + ) + def test_true( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, cohort_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"hash": "abc"} + service_context = ServiceContext( + # consumer=False, + service="live_class_join", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is True + + +class TestJoinMentorship: + @pytest.mark.parametrize( + "models", + [ + { + "mentorship_service": {"slug": "abc"}, + "mentorship_service_set": {"mentorship_services": [1]}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "mentorship_service": {"slug": "abcd"}, + "mentorship_service_set": {"mentorship_services": [1]}, + "service_item": 1, + "academy": {"available_as_saas": False}, + }, + ], + ) + def test_false( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, mentorship_service_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"service_slug": "abc"} + service_context = ServiceContext( + # consumer=False, + service="join_mentorship", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is False + + @pytest.mark.parametrize( + "models", + [ + { + "mentorship_service": {"slug": "abc"}, + "mentorship_service_set": {"mentorship_services": [1]}, + "service_item": 1, + "academy": {"available_as_saas": False}, + }, + ], + ) + def test_true( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, mentorship_service_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"service_slug": "abc"} + service_context = ServiceContext( + # consumer=False, + service="join_mentorship", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is True + + +class TestAddCodeReview: + @pytest.mark.parametrize( + "models", + [ + { + "task": 1, + "cohort": {"available_as_saas": True}, + "cohort_set": {"cohorts": [1]}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "task": 1, + "cohort": {"available_as_saas": None}, + "cohort_set": {"cohorts": [1]}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "task": 1, + "cohort": {"available_as_saas": True}, + "cohort_set": {"cohorts": []}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + ], + ) + def test_false( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, cohort_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"task_id": 1} + service_context = ServiceContext( + # consumer=False, + service="add_code_review", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is False + + @pytest.mark.parametrize( + "models", + [ + { + "task": 1, + "cohort": {"available_as_saas": False}, + "cohort_set": {"cohorts": [1]}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "task": 1, + "cohort": {"available_as_saas": None}, + "cohort_set": {"cohorts": [1]}, + "service_item": 1, + "academy": {"available_as_saas": False}, + }, + ], + ) + def test_true( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, cohort_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"task_id": 1} + service_context = ServiceContext( + # consumer=False, + service="add_code_review", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is True + + +class TestReadLesson: + @pytest.mark.parametrize( + "models", + [ + { + "asset": {"slug": "abc"}, + "asset_category": 1, + "cohort": {"available_as_saas": True}, + "cohort_set": {"cohorts": [1]}, + "cohort_user": 1, + "syllabus_version": {"json": {"bla": "abc"}}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "asset": {"slug": "abc"}, + "asset_category": 1, + "cohort": {"available_as_saas": None}, + "cohort_set": {"cohorts": [1]}, + "cohort_user": 1, + "syllabus_version": {"json": {"bla": "abc"}}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "asset": {"slug": "abcd"}, + "asset_category": 1, + "cohort": {"available_as_saas": False}, + "cohort_set": {"cohorts": [1]}, + "cohort_user": 1, + "syllabus_version": {"json": {"bla": "abc"}}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "asset": {"slug": "abc"}, + "asset_category": 1, + "cohort": {"available_as_saas": False}, + "cohort_set": {"cohorts": [1]}, + "cohort_user": 1, + "syllabus_version": {"json": {"bla": "abcd"}}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + ], + ) + def test_false( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, cohort_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"asset_slug": "abc"} + service_context = ServiceContext( + # consumer=False, + service="read_lesson", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is False + + @pytest.mark.parametrize( + "models", + [ + { + "asset": {"slug": "abc"}, + "asset_category": 1, + "cohort": {"available_as_saas": False}, + "cohort_set": {"cohorts": [1]}, + "cohort_user": 1, + "syllabus_version": {"json": {"bla": "abc"}}, + "service_item": 1, + "academy": {"available_as_saas": True}, + }, + { + "asset": {"slug": "abc"}, + "asset_category": 1, + "cohort": {"available_as_saas": None}, + "cohort_set": {"cohorts": [1]}, + "cohort_user": 1, + "syllabus_version": {"json": {"bla": "abc"}}, + "service_item": 1, + "academy": {"available_as_saas": False}, + }, + ], + ) + def test_true( + self, database: capy.Database, patch_bypass_consumption: PatchBypassConsumption, models: dict[str, int] + ): + model = database.create(city=1, country=1, user=1, **models) + patch_bypass_consumption(consumable(service_item=1, cohort_set=1)) + + factory = APIRequestFactory() + url = "/my/url" + + request = factory.get(url) + request.user = model.user + + kwargs = {"asset_slug": "abc"} + service_context = ServiceContext( + # consumer=False, + service="read_lesson", + request=request, + ) + + context = feature.context(context=service_context, kwargs=kwargs) + res = feature.is_enabled("payments.bypass_consumption", context=context) + assert res is True diff --git a/breathecode/payments/tests/urls/tests_me_service_consumable.py b/breathecode/payments/tests/urls/tests_me_service_consumable.py index 3522a093b..1453a5198 100644 --- a/breathecode/payments/tests/urls/tests_me_service_consumable.py +++ b/breathecode/payments/tests/urls/tests_me_service_consumable.py @@ -1,7 +1,10 @@ import math import random +from typing import Callable, Literal, TypedDict from unittest.mock import MagicMock, patch +import pytest +from aiohttp_retry import Any from django.urls import reverse_lazy from django.utils import timezone from rest_framework import status @@ -108,6 +111,41 @@ def serialize_consumable(consumable, data={}): } +class GenericConsumableMockArg(TypedDict, total=False): + resource: Literal["cohort_set", "event_type_set", "mentorship_service_set"] + id: int + + +class ConsumableMockArg(GenericConsumableMockArg): + service: int + how_many: int + + +def get_virtual_consumables_mock( + *consumables: ConsumableMockArg, +) -> Callable[[], list[dict[str, Any]]]: + + # the wrapper avoid raising an error during the setup + def wrapper(): + from breathecode.payments.utils import consumable, service_item + + nonlocal consumables + + result = [] + for x in consumables: + kwargs = { + "service_item": service_item(service=x["service"], unit_type="unit", how_many=x["how_many"]), + } + if "resource" in x and "id" in x: + kwargs[x["resource"]] = x["id"] + + result.append(consumable(**kwargs)) + + return result + + return wrapper + + class TestSignal(LegacyAPITestCase): """ 🔽🔽🔽 GET without auth @@ -1319,3 +1357,1035 @@ def test__nine_consumables__related_to_three_services__with_cohort_slugs_in_quer assert json == expected assert response.status_code == status.HTTP_200_OK assert self.bc.database.list_of("payments.Consumable") == self.bc.format.to_dict(model.consumable) + + """ + 🔽🔽🔽 Virtual Consumables, append to the real balance + """ + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__append_to_same_balance___cohort_set__with_three_virtual_consumables(self, monkeypatch): + from breathecode.payments.utils import reset_cache + + reset_cache() + + rand1 = random.randint(1, 9) + rand2 = random.randint(1, 9) + rand3 = random.randint(1, 9) + + monkeypatch.setattr( + "breathecode.payments.data.get_virtual_consumables", + get_virtual_consumables_mock( + *[{"resource": "cohort_set", "id": 1, "service": 2 + n, "how_many": rand1 * (1 + n)} for n in range(3)], + *[{"resource": "cohort_set", "id": 2, "service": 5 + n, "how_many": rand2 * (1 + n)} for n in range(3)], + *[{"resource": "cohort_set", "id": 3, "service": 8 + n, "how_many": rand3 * (1 + n)} for n in range(3)], + ), + ) + consumables = [{"how_many": random.randint(1, 30), "cohort_set_id": math.floor(n / 3) + 1} for n in range(9)] + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + sum([rand1 * (1 + n) for n in range(3)]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + sum([rand2 * (1 + n) for n in range(3)]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + sum([rand3 * (1 + n) for n in range(3)]) + + academy = {"available_as_saas": True} + + model = self.bc.database.create( + user=1, + consumable=consumables, + cohort_set=3, + cohort_set_cohort=[{"cohort_set_id": 1 + n} for n in range(3)], + academy=academy, + service=(10, {"type": "COHORT_SET"}), + cohort={"available_as_saas": True}, + cohort_user=1, + ) + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + "?virtual=true" + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + expected = { + "mentorship_service_sets": [], + "cohort_sets": [ + { + "balance": {"unit": how_many_belong_to1}, + "id": model.cohort_set[0].id, + "slug": model.cohort_set[0].slug, + "items": [ + *[serialize_consumable(model.consumable[n]) for n in range(9)], + *[ + { + "how_many": rand1 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.cohort_set[1].id, + "slug": model.cohort_set[1].slug, + "items": [ + *[serialize_consumable(model.consumable[n]) for n in range(9)], + *[ + { + "how_many": rand2 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.cohort_set[2].id, + "slug": model.cohort_set[2].slug, + "items": [ + *[serialize_consumable(model.consumable[n]) for n in range(9)], + *[ + { + "how_many": rand3 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + ], + "event_type_sets": [], + "voids": [], + } + + assert json == expected + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.bc.database.list_of("payments.Consumable"), + self.bc.format.to_dict(model.consumable), + ) + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__append_to_same_balance___mentorship_service_set__with_three_virtual_consumables(self, monkeypatch): + from breathecode.payments.utils import reset_cache + + reset_cache() + + rand1 = random.randint(1, 9) + rand2 = random.randint(1, 9) + rand3 = random.randint(1, 9) + + monkeypatch.setattr( + "breathecode.payments.data.get_virtual_consumables", + get_virtual_consumables_mock( + *[ + {"resource": "mentorship_service_set", "id": 1, "service": 2 + n, "how_many": rand1 * (1 + n)} + for n in range(3) + ], + *[ + {"resource": "mentorship_service_set", "id": 2, "service": 5 + n, "how_many": rand2 * (1 + n)} + for n in range(3) + ], + *[ + {"resource": "mentorship_service_set", "id": 3, "service": 8 + n, "how_many": rand3 * (1 + n)} + for n in range(3) + ], + ), + ) + consumables = [ + {"how_many": random.randint(1, 30), "mentorship_service_set_id": math.floor(n / 3) + 1} for n in range(9) + ] + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + sum([rand1 * (1 + n) for n in range(3)]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + sum([rand2 * (1 + n) for n in range(3)]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + sum([rand3 * (1 + n) for n in range(3)]) + + academy = {"available_as_saas": True} + + model = self.bc.database.create( + user=1, + consumable=consumables, + mentorship_service_set=3, + profile_academy=1, + academy=academy, + service=(10, {"type": "MENTORSHIP_SERVICE_SET"}), + ) + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + "?virtual=true" + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + expected = { + "mentorship_service_sets": [ + { + "balance": {"unit": how_many_belong_to1}, + "id": model.mentorship_service_set[0].id, + "slug": model.mentorship_service_set[0].slug, + "items": [ + *[serialize_consumable(model.consumable[n]) for n in range(9)], + *[ + { + "id": None, + "how_many": rand1 * (1 + n), + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.mentorship_service_set[1].id, + "slug": model.mentorship_service_set[1].slug, + "items": [ + *[serialize_consumable(model.consumable[n]) for n in range(9)], + *[ + { + "id": None, + "how_many": rand2 * (1 + n), + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.mentorship_service_set[2].id, + "slug": model.mentorship_service_set[2].slug, + "items": [ + *[serialize_consumable(model.consumable[n]) for n in range(9)], + *[ + { + "id": None, + "how_many": rand3 * (1 + n), + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + ], + "cohort_sets": [], + "event_type_sets": [], + "voids": [], + } + + assert json == expected + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.bc.database.list_of("payments.Consumable"), + self.bc.format.to_dict(model.consumable), + ) + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__append_to_same_balance___event_type_set__with_three_virtual_consumables(self, monkeypatch): + from breathecode.payments.utils import reset_cache + + reset_cache() + + rand1 = random.randint(1, 9) + rand2 = random.randint(1, 9) + rand3 = random.randint(1, 9) + + monkeypatch.setattr( + "breathecode.payments.data.get_virtual_consumables", + get_virtual_consumables_mock( + *[ + {"resource": "event_type_set", "id": 1, "service": 2 + n, "how_many": rand1 * (1 + n)} + for n in range(3) + ], + *[ + {"resource": "event_type_set", "id": 2, "service": 5 + n, "how_many": rand2 * (1 + n)} + for n in range(3) + ], + *[ + {"resource": "event_type_set", "id": 3, "service": 8 + n, "how_many": rand3 * (1 + n)} + for n in range(3) + ], + ), + ) + consumables = [ + {"how_many": random.randint(1, 30), "event_type_set_id": math.floor(n / 3) + 1} for n in range(9) + ] + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + sum([rand1 * (1 + n) for n in range(3)]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + sum([rand2 * (1 + n) for n in range(3)]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + sum([rand3 * (1 + n) for n in range(3)]) + + academy = {"available_as_saas": True} + + model = self.bc.database.create( + user=1, + consumable=consumables, + event_type_set=3, + profile_academy=1, + academy=academy, + service=(10, {"type": "EVENT_TYPE_SET"}), + ) + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + "?virtual=true" + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + expected = { + "mentorship_service_sets": [], + "cohort_sets": [], + "event_type_sets": [ + { + "balance": {"unit": how_many_belong_to1}, + "id": model.event_type_set[0].id, + "slug": model.event_type_set[0].slug, + "items": [ + *[serialize_consumable(model.consumable[n]) for n in range(9)], + *[ + { + "id": None, + "how_many": rand1 * (1 + n), + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.event_type_set[1].id, + "slug": model.event_type_set[1].slug, + "items": [ + *[serialize_consumable(model.consumable[n]) for n in range(9)], + *[ + { + "id": None, + "how_many": rand2 * (1 + n), + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.event_type_set[2].id, + "slug": model.event_type_set[2].slug, + "items": [ + *[serialize_consumable(model.consumable[n]) for n in range(9)], + *[ + { + "id": None, + "how_many": rand3 * (1 + n), + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + ], + "voids": [], + } + + assert json == expected + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.bc.database.list_of("payments.Consumable"), + self.bc.format.to_dict(model.consumable), + ) + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__append_to_same_balance___service__with_three_virtual_consumables(self, monkeypatch): + from breathecode.payments.utils import reset_cache + + reset_cache() + + rand1 = random.randint(1, 9) + rand2 = random.randint(1, 9) + rand3 = random.randint(1, 9) + + monkeypatch.setattr( + "breathecode.payments.data.get_virtual_consumables", + get_virtual_consumables_mock( + *[{"service": 1, "how_many": rand1 * (1 + n)} for n in range(3)], + *[{"service": 2, "how_many": rand2 * (1 + n)} for n in range(3)], + *[{"service": 3, "how_many": rand3 * (1 + n)} for n in range(3)], + ), + ) + + consumables = [{"how_many": random.randint(1, 30), "service_item_id": math.floor(n / 3) + 1} for n in range(9)] + service_items = [{"service_id": n + 1} for n in range(3)] + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + sum([rand1 * (1 + n) for n in range(3)]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + sum([rand2 * (1 + n) for n in range(3)]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + sum([rand3 * (1 + n) for n in range(3)]) + + model = self.bc.database.create( + user=1, + consumable=consumables, + service_item=service_items, + service=[{"type": "VOID"} for _ in range(3)], + ) + + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + "?virtual=true" + response = self.client.get(url) + + json = response.json() + expected = { + "mentorship_service_sets": [], + "cohort_sets": [], + "event_type_sets": [], + "voids": [ + { + "balance": {"unit": how_many_belong_to1}, + "id": model.service[0].id, + "slug": model.service[0].slug, + "items": [ + *[serialize_consumable(consumable) for consumable in model.consumable[:3]], + *[ + { + "id": None, + "how_many": rand1 * (1 + n), + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.service[1].id, + "slug": model.service[1].slug, + "items": [ + *[serialize_consumable(consumable) for consumable in model.consumable[3:6]], + *[ + { + "id": None, + "how_many": rand2 * (1 + n), + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.service[2].id, + "slug": model.service[2].slug, + "items": [ + *[serialize_consumable(consumable) for consumable in model.consumable[6:]], + *[ + { + "id": None, + "how_many": rand3 * (1 + n), + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + ], + }, + ], + } + + assert json == expected + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.bc.database.list_of("payments.Consumable"), + self.bc.format.to_dict(model.consumable), + ) + + """ + 🔽🔽🔽 Virtual Consumables, append to a new balance + """ + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__append_to_new_balance___cohort_set__with_three_virtual_consumables(self, monkeypatch): + from breathecode.payments.utils import reset_cache + + reset_cache() + + rand1 = random.randint(1, 9) + rand2 = random.randint(1, 9) + rand3 = random.randint(1, 9) + + monkeypatch.setattr( + "breathecode.payments.data.get_virtual_consumables", + get_virtual_consumables_mock( + *[{"resource": "cohort_set", "id": 4, "service": 2 + n, "how_many": rand1 * (1 + n)} for n in range(3)], + *[{"resource": "cohort_set", "id": 5, "service": 5 + n, "how_many": rand2 * (1 + n)} for n in range(3)], + *[{"resource": "cohort_set", "id": 6, "service": 8 + n, "how_many": rand3 * (1 + n)} for n in range(3)], + ), + ) + consumables = [{"how_many": random.randint(1, 30), "cohort_set_id": math.floor(n / 3) + 1} for n in range(9)] + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + + academy = {"available_as_saas": True} + + model = self.bc.database.create( + user=1, + consumable=consumables, + cohort_set=6, + cohort_set_cohort=[{"cohort_set_id": 4 + n} for n in range(3)], + academy=academy, + service=(10, {"type": "COHORT_SET"}), + cohort={"available_as_saas": True}, + cohort_user=1, + ) + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + "?virtual=true" + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + expected = { + "mentorship_service_sets": [], + "cohort_sets": [ + { + "balance": {"unit": how_many_belong_to1}, + "id": model.cohort_set[0].id, + "slug": model.cohort_set[0].slug, + "items": [serialize_consumable(model.consumable[n]) for n in range(9)], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.cohort_set[1].id, + "slug": model.cohort_set[1].slug, + "items": [serialize_consumable(model.consumable[n]) for n in range(9)], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.cohort_set[2].id, + "slug": model.cohort_set[2].slug, + "items": [serialize_consumable(model.consumable[n]) for n in range(9)], + }, + # + { + "balance": { + "unit": sum([rand1 * (1 + n) for n in range(3)]), + }, + "id": model.cohort_set[3].id, + "slug": model.cohort_set[3].slug, + "items": [ + { + "how_many": rand1 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + { + "balance": { + "unit": sum([rand2 * (1 + n) for n in range(3)]), + }, + "id": model.cohort_set[4].id, + "slug": model.cohort_set[4].slug, + "items": [ + { + "how_many": rand2 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + { + "balance": { + "unit": sum([rand3 * (1 + n) for n in range(3)]), + }, + "id": model.cohort_set[5].id, + "slug": model.cohort_set[5].slug, + "items": [ + { + "how_many": rand3 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + ], + "event_type_sets": [], + "voids": [], + } + + assert json == expected + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.bc.database.list_of("payments.Consumable"), + self.bc.format.to_dict(model.consumable), + ) + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__append_to_new_balance___event_type_set__with_three_virtual_consumables(self, monkeypatch): + from breathecode.payments.utils import reset_cache + + reset_cache() + + rand1 = random.randint(1, 9) + rand2 = random.randint(1, 9) + rand3 = random.randint(1, 9) + + monkeypatch.setattr( + "breathecode.payments.data.get_virtual_consumables", + get_virtual_consumables_mock( + *[ + {"resource": "event_type_set", "id": 4, "service": 2 + n, "how_many": rand1 * (1 + n)} + for n in range(3) + ], + *[ + {"resource": "event_type_set", "id": 5, "service": 5 + n, "how_many": rand2 * (1 + n)} + for n in range(3) + ], + *[ + {"resource": "event_type_set", "id": 6, "service": 8 + n, "how_many": rand3 * (1 + n)} + for n in range(3) + ], + ), + ) + consumables = [ + {"how_many": random.randint(1, 30), "event_type_set_id": math.floor(n / 3) + 1} for n in range(9) + ] + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + + academy = {"available_as_saas": True} + + model = self.bc.database.create( + user=1, + consumable=consumables, + event_type_set=6, + academy=academy, + service=(10, {"type": "EVENT_TYPE_SET"}), + profile_academy=1, + ) + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + "?virtual=true" + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + expected = { + "mentorship_service_sets": [], + "cohort_sets": [], + "event_type_sets": [ + { + "balance": {"unit": how_many_belong_to1}, + "id": model.event_type_set[0].id, + "slug": model.event_type_set[0].slug, + "items": [serialize_consumable(model.consumable[n]) for n in range(9)], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.event_type_set[1].id, + "slug": model.event_type_set[1].slug, + "items": [serialize_consumable(model.consumable[n]) for n in range(9)], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.event_type_set[2].id, + "slug": model.event_type_set[2].slug, + "items": [serialize_consumable(model.consumable[n]) for n in range(9)], + }, + # + { + "balance": { + "unit": sum([rand1 * (1 + n) for n in range(3)]), + }, + "id": model.event_type_set[3].id, + "slug": model.event_type_set[3].slug, + "items": [ + { + "how_many": rand1 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + { + "balance": { + "unit": sum([rand2 * (1 + n) for n in range(3)]), + }, + "id": model.event_type_set[4].id, + "slug": model.event_type_set[4].slug, + "items": [ + { + "how_many": rand2 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + { + "balance": { + "unit": sum([rand3 * (1 + n) for n in range(3)]), + }, + "id": model.event_type_set[5].id, + "slug": model.event_type_set[5].slug, + "items": [ + { + "how_many": rand3 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + ], + "voids": [], + } + + assert json == expected + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.bc.database.list_of("payments.Consumable"), + self.bc.format.to_dict(model.consumable), + ) + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__append_to_new_balance___mentorship_service_set__with_three_virtual_consumables(self, monkeypatch): + from breathecode.payments.utils import reset_cache + + reset_cache() + + rand1 = random.randint(1, 9) + rand2 = random.randint(1, 9) + rand3 = random.randint(1, 9) + + monkeypatch.setattr( + "breathecode.payments.data.get_virtual_consumables", + get_virtual_consumables_mock( + *[ + {"resource": "mentorship_service_set", "id": 4, "service": 2 + n, "how_many": rand1 * (1 + n)} + for n in range(3) + ], + *[ + {"resource": "mentorship_service_set", "id": 5, "service": 5 + n, "how_many": rand2 * (1 + n)} + for n in range(3) + ], + *[ + {"resource": "mentorship_service_set", "id": 6, "service": 8 + n, "how_many": rand3 * (1 + n)} + for n in range(3) + ], + ), + ) + consumables = [ + {"how_many": random.randint(1, 30), "mentorship_service_set_id": math.floor(n / 3) + 1} for n in range(9) + ] + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + + academy = {"available_as_saas": True} + + model = self.bc.database.create( + user=1, + consumable=consumables, + mentorship_service_set=6, + academy=academy, + service=(10, {"type": "MENTORSHIP_SERVICE_SET"}), + profile_academy=1, + ) + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + "?virtual=true" + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + expected = { + "mentorship_service_sets": [ + { + "balance": {"unit": how_many_belong_to1}, + "id": model.mentorship_service_set[0].id, + "slug": model.mentorship_service_set[0].slug, + "items": [serialize_consumable(model.consumable[n]) for n in range(9)], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.mentorship_service_set[1].id, + "slug": model.mentorship_service_set[1].slug, + "items": [serialize_consumable(model.consumable[n]) for n in range(9)], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.mentorship_service_set[2].id, + "slug": model.mentorship_service_set[2].slug, + "items": [serialize_consumable(model.consumable[n]) for n in range(9)], + }, + # + { + "balance": { + "unit": sum([rand1 * (1 + n) for n in range(3)]), + }, + "id": model.mentorship_service_set[3].id, + "slug": model.mentorship_service_set[3].slug, + "items": [ + { + "how_many": rand1 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + { + "balance": { + "unit": sum([rand2 * (1 + n) for n in range(3)]), + }, + "id": model.mentorship_service_set[4].id, + "slug": model.mentorship_service_set[4].slug, + "items": [ + { + "how_many": rand2 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + { + "balance": { + "unit": sum([rand3 * (1 + n) for n in range(3)]), + }, + "id": model.mentorship_service_set[5].id, + "slug": model.mentorship_service_set[5].slug, + "items": [ + { + "how_many": rand3 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + ], + "cohort_sets": [], + "event_type_sets": [], + "voids": [], + } + + assert json == expected + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.bc.database.list_of("payments.Consumable"), + self.bc.format.to_dict(model.consumable), + ) + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__append_to_new_balance___service__with_three_virtual_consumables(self, monkeypatch): + from breathecode.payments.utils import reset_cache + + reset_cache() + + rand1 = random.randint(1, 9) + rand2 = random.randint(1, 9) + rand3 = random.randint(1, 9) + + monkeypatch.setattr( + "breathecode.payments.data.get_virtual_consumables", + get_virtual_consumables_mock( + *[{"service": 4, "how_many": rand1 * (1 + n)} for n in range(3)], + *[{"service": 5, "how_many": rand2 * (1 + n)} for n in range(3)], + *[{"service": 6, "how_many": rand3 * (1 + n)} for n in range(3)], + ), + ) + + consumables = [{"how_many": random.randint(1, 30), "service_item_id": math.floor(n / 3) + 1} for n in range(9)] + service_items = [{"service_id": n + 1} for n in range(3)] + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + + academy = {"available_as_saas": True} + + model = self.bc.database.create( + user=1, + consumable=consumables, + service_item=service_items, + academy=academy, + service=(6, {"type": "VOID"}), + ) + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + "?virtual=true" + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + consumables = [serialize_consumable(model.consumable[n]) for n in range(9)] + expected = { + "mentorship_service_sets": [], + "cohort_sets": [], + "event_type_sets": [], + "voids": [ + { + "balance": {"unit": how_many_belong_to1}, + "id": model.service[0].id, + "slug": model.service[0].slug, + "items": consumables[:3], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.service[1].id, + "slug": model.service[1].slug, + "items": consumables[3:6], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.service[2].id, + "slug": model.service[2].slug, + "items": consumables[6:9], + }, + # + { + "balance": { + "unit": sum([rand1 * (1 + n) for n in range(3)]), + }, + "id": model.service[3].id, + "slug": model.service[3].slug, + "items": [ + { + "how_many": rand1 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + { + "balance": { + "unit": sum([rand2 * (1 + n) for n in range(3)]), + }, + "id": model.service[4].id, + "slug": model.service[4].slug, + "items": [ + { + "how_many": rand2 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + { + "balance": { + "unit": sum([rand3 * (1 + n) for n in range(3)]), + }, + "id": model.service[5].id, + "slug": model.service[5].slug, + "items": [ + { + "how_many": rand3 * (1 + n), + "id": None, + "unit_type": "UNIT", + "valid_until": None, + } + for n in range(3) + ], + }, + ], + } + + assert json == expected + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.bc.database.list_of("payments.Consumable"), + self.bc.format.to_dict(model.consumable), + ) diff --git a/breathecode/payments/utils.py b/breathecode/payments/utils.py new file mode 100644 index 000000000..936687862 --- /dev/null +++ b/breathecode/payments/utils.py @@ -0,0 +1,203 @@ +import re +from typing import Any, Optional, Type, TypedDict, Unpack, overload + +from capyc.rest_framework.exceptions import ValidationException + +from breathecode.payments.models import CohortSet, EventTypeSet, MentorshipServiceSet, Service, ServiceItem + +__all__ = ["consumable", "service_item", "ConsumableType", "reset_cache"] + + +class GenericType(TypedDict): + id: int + slug: str + + +class ServiceType(GenericType): + type: Service.Type + + +class ServiceItemType(TypedDict): + service: ServiceType + unit_type: str + how_many: int + + +class ConsumableType(TypedDict): + service_item: ServiceItemType + cohort_set: Optional[GenericType] + event_type_set: Optional[GenericType] + mentorship_service_set: Optional[GenericType] + + +type ID = dict[str, Any] +SERVICES: dict[ID, ServiceType] = {} +SERVICE_ITEMS: dict[ID, ServiceItemType] = {} +# VIRTUAL_SERVICE_ITEMS: list[ServiceItemType] = [] +FIELDS: dict[str, tuple[str, ...]] = { + "Service": ("id", "slug", "type"), +} + +FIELDS["ServiceItem"] = ("id", "unit_type", "how_many", "service_id", *(f"service__{x}" for x in FIELDS["Service"])) + +type Model = Type[Service | ServiceItem] + + +def get_hash(d: dict[str, Any]) -> str: + return tuple(sorted(d.items())) + + +@overload +def serialize(model: Type[Service], **kwargs: Any) -> ServiceType: ... + + +@overload +def serialize(model: Type[ServiceItem], **kwargs: Any) -> ServiceItemType: ... + + +@overload +def serialize(instance: ServiceItem, **kwargs: Any) -> ServiceItemType: ... + + +@overload +def serialize(instance: Service, **kwargs: Any) -> ServiceType: ... + + +def serialize( + model: Optional[Type[Model]] = None, instance: Optional[Model] = None, **kwargs: Any +) -> ServiceType | ServiceItemType: + if model is None and instance is None: + raise ValueError("Either model or instance must be provided") + + if model and instance: + raise ValueError("Both model and instance cannot be provided") + + if model: + key = model.__name__ + else: + key = instance.__class__.__name__ + + fields = FIELDS.get(key, tuple()) + + result = {} + if not instance: + instance = model.objects.filter(**kwargs).only(*fields).first() + + if not instance: + raise ValidationException(f"{key} with params {kwargs} not found") + + for field in fields: + override = field + if "__" in field: + continue + + if field.endswith("_id"): + override = field.replace("_id", "") + + result[override] = getattr(instance, override) + + if key == "Service": + SERVICES[get_hash(kwargs)] = result + elif key == "ServiceItem": + SERVICE_ITEMS[get_hash(kwargs)] = result + + return result + + +def get_service(id: int) -> ServiceType: + key = {"id": id} + + if get_hash(key) in SERVICES: + return SERVICES[get_hash(key)] + + return serialize(model=Service, **key) + + +def get_service_item(id: int) -> ServiceItemType: + key = {"id": id} + if get_hash(key) in SERVICE_ITEMS: + return SERVICE_ITEMS[get_hash(key)] + + v = serialize(model=ServiceItem, **key) + v["service"] = serialize(instance=v["service"]) + return v + + +def service_item(service: ServiceType | int, **kwargs: Unpack[ServiceItemType]) -> ServiceItemType: + if isinstance(service, int): + service = get_service(service) + + kwargs["unit_type"] = kwargs["unit_type"].upper() + + return {"service": service, **kwargs} + + +class GenericType(TypedDict): + id: int + slug: str + + +# EXITS: set[str] = set() +EXISTS: dict[str, GenericType] = {} + + +# def serialize_generic(instance: EventTypeSet | CohortSet | MentorshipServiceSet) -> GenericType: +# return { +# "id": instance.id, +# "slug": instance.slug, +# } + + +def camel_to_snake(name): + # Add an underscore before each capital letter and convert the string to lowercase + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + # Handle cases where there are multiple capital letters in a row + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() + + +def resource(model: Type[EventTypeSet | CohortSet | MentorshipServiceSet], **kwargs: dict[str, Any]) -> GenericType: + name = model.__name__ + key = f"{camel_to_snake(name)}__{kwargs}" + if key not in EXISTS: + x = model.objects.filter(**kwargs).only("id", "slug").first() + if not x: + raise ValidationException(f"{name} with {kwargs} not found") + + EXISTS[key] = {"id": x.id, "slug": x.slug} + + return EXISTS[key] + + +def reset_cache(): + global EXISTS, SERVICES, SERVICE_ITEMS + EXISTS = {} + SERVICES = {} + SERVICE_ITEMS = {} + + +def consumable( + *, + service_item: ServiceItemType | int, + cohort_set: Optional[int] = None, + event_type_set: Optional[int] = None, + mentorship_service_set: Optional[int] = None, +) -> ConsumableType: + + if cohort_set: + cohort_set = resource(CohortSet, id=cohort_set) + + if event_type_set: + event_type_set = resource(EventTypeSet, id=event_type_set) + + if mentorship_service_set: + mentorship_service_set = resource(MentorshipServiceSet, id=mentorship_service_set) + + if isinstance(service_item, int): + service_item = get_service_item(service_item) + + return { + "service_item": service_item, + "cohort_set": cohort_set, + "event_type_set": event_type_set, + "mentorship_service_set": mentorship_service_set, + } diff --git a/breathecode/payments/views.py b/breathecode/payments/views.py index e9fa60b31..0b1ef1e35 100644 --- a/breathecode/payments/views.py +++ b/breathecode/payments/views.py @@ -601,6 +601,9 @@ def get(self, request): "voids": filter_void_consumable_balance(request, items), } + if request.GET.get("virtual") in ["true", "1", "y"]: + actions.set_virtual_balance(balance, request.user) + return Response(balance) diff --git a/breathecode/registry/tests/urls/v1/tests_academy_asset.py b/breathecode/registry/tests/urls/v1/tests_academy_asset.py index 3a27fa5ea..7f370a162 100644 --- a/breathecode/registry/tests/urls/v1/tests_academy_asset.py +++ b/breathecode/registry/tests/urls/v1/tests_academy_asset.py @@ -19,6 +19,8 @@ def database_item(academy, category, data={}): return { "academy_id": academy.id, + "learnpack_deploy_url": None, + "agent": None, "assessment_id": None, "asset_type": "PROJECT", "author_id": None, @@ -90,6 +92,7 @@ def post_serializer(academy, category, data={}): "slug": category.slug, "title": category.title, }, + "agent": None, "delivery_formats": "url", "delivery_instructions": None, "delivery_regex_url": None, diff --git a/breathecode/settings.py b/breathecode/settings.py index a9a372526..e4f9004a3 100644 --- a/breathecode/settings.py +++ b/breathecode/settings.py @@ -119,6 +119,7 @@ "django.middleware.security.SecurityMiddleware", "breathecode.middlewares.static_redirect_middleware", "breathecode.middlewares.set_service_header_middleware", + "breathecode.middlewares.detect_pagination_issues_middleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", # Cache diff --git a/breathecode/utils/decorators/consume.py b/breathecode/utils/decorators/consume.py index 66688bdc7..0daa382b6 100644 --- a/breathecode/utils/decorators/consume.py +++ b/breathecode/utils/decorators/consume.py @@ -7,10 +7,12 @@ from adrf.requests import AsyncRequest from asgiref.sync import sync_to_async +from capyc.core.managers import feature +from capyc.rest_framework.exceptions import PaymentException, ValidationException from django.contrib.auth.models import AnonymousUser from django.core.handlers.wsgi import WSGIRequest from django.db.models import QuerySet, Sum -from django.http import HttpRequest, JsonResponse +from django.http import JsonResponse from django.shortcuts import render from django.utils import timezone from rest_framework.response import Response @@ -18,7 +20,6 @@ from breathecode.authenticate.models import User from breathecode.payments.signals import consume_service -from capyc.rest_framework.exceptions import PaymentException, ValidationException from ..exceptions import ProgrammingError @@ -27,6 +28,14 @@ logger = logging.getLogger(__name__) +class Flags(TypedDict): + bypass_consumption: bool + + +class FlagsParams(Flags, total=False): + pass + + class ServiceContext(TypedDict): utc_now: datetime consumer: bool @@ -36,6 +45,7 @@ class ServiceContext(TypedDict): lifetime: Optional[timedelta] price: float is_consumption_session: bool + flags: Flags type Consumer = Callable[[ServiceContext, tuple, dict], tuple[ServiceContext, tuple, dict, Optional[timedelta]]] @@ -180,7 +190,7 @@ def consume(service: str, consumer: Optional[Consumer] = None, format: str = "js def decorator(function: callable) -> callable: - def validate_and_get_request(permission: str, args: Any) -> HttpRequest | AsyncRequest: + def validate_and_get_request(permission: str, args: Any) -> WSGIRequest | AsyncRequest: if isinstance(permission, str) == False: raise ProgrammingError("Service must be a string") @@ -200,8 +210,15 @@ def validate_and_get_request(permission: str, args: Any) -> HttpRequest | AsyncR return request def build_context( - request: HttpRequest | AsyncRequest, utc_now: datetime, **opts: Unpack[ServiceContext] + request: WSGIRequest | AsyncRequest, + utc_now: datetime, + flags: Optional[FlagsParams] = None, + **opts: Unpack[ServiceContext], ) -> ServiceContext: + + if flags is None: + flags = {} + return { "utc_now": utc_now, "consumer": consumer, @@ -211,6 +228,10 @@ def build_context( "lifetime": None, "price": 1, "is_consumption_session": False, + "flags": { + "bypass_consumption": False, + **flags, + }, **opts, } @@ -238,9 +259,16 @@ def wrapper(*args, **kwargs): items = Consumable.list(user=request.user, service=service) context["consumables"] = items + flag_context = feature.context(context=context, kwargs=kwargs) + bypass_consumption = feature.is_enabled("payments.bypass_consumption", flag_context, False) + context["flags"]["bypass_consumption"] = bypass_consumption + if callable(consumer): context, args, kwargs = consumer(context, args, kwargs) + if bypass_consumption: + return function(*args, **kwargs) + # exclude consumables that is being used in a session. if consumer and context["lifetime"]: consumables = context["consumables"] @@ -343,12 +371,19 @@ async def async_wrapper(*args, **kwargs): items = await Consumable.alist(user=user, service=service) context["consumables"] = items + flag_context = feature.context(context=context, kwargs=kwargs) + bypass_consumption = feature.is_enabled("payments.bypass_consumption", flag_context, False) + context["flags"]["bypass_consumption"] = bypass_consumption + if callable(consumer): if asyncio.iscoroutinefunction(consumer) is False: consumer = sync_to_async(consumer) context, args, kwargs = await consumer(context, args, kwargs) + if bypass_consumption: + return await function(*args, **kwargs) + # exclude consumables that is being used in a session. if consumer and context["lifetime"]: consumables: QuerySet[Consumable] = context["consumables"] diff --git a/breathecode/utils/tests/decorators/tests_consume.py b/breathecode/utils/tests/decorators/tests_consume.py index cc90fe533..7f740c95f 100644 --- a/breathecode/utils/tests/decorators/tests_consume.py +++ b/breathecode/utils/tests/decorators/tests_consume.py @@ -6,7 +6,9 @@ import capyc.pytest as capy import pytest from adrf.decorators import APIView, api_view +from adrf.requests import AsyncRequest from asgiref.sync import sync_to_async +from capyc.core.managers import feature from django.http.response import JsonResponse from django.utils import timezone from rest_framework import status @@ -60,6 +62,27 @@ def consumer(context: ServiceContext, args: tuple, kwargs: dict) -> tuple[dict, time_of_life = timedelta(days=random.randint(1, 100)) +@sync_to_async +def is_enabled_call_list(): + return [ + call( + args[0], + { + **args[1], + "context": { + **args[1]["context"], + "request": type(args[1]["context"]["request"]), + "consumer": callable(args[1]["context"]["consumer"]), + "consumables": [x for x in args[1]["context"]["consumables"]], + }, + }, + *args[2:], + **kwargs, + ) + for args, kwargs in feature.is_enabled.call_args_list + ] + + def consumer_with_time_of_life(context: ServiceContext, args: tuple, kwargs: dict) -> tuple[dict, tuple, dict]: # remember the objects are passed by reference, so you need to clone them to avoid modify the object # receive by the mock causing side effects @@ -398,18 +421,20 @@ def check_consume_service(): @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) - async def test_with_user__with_group_related_to_permission__consumable__how_many_0( - self, bc: Breathecode, make_view_all_cases + async def test_with_user__with_group_related_to_permission__consumable__how_many_0__bypass_consumption_false( + self, bc: Breathecode, make_view_all_cases, monkeypatch, utc_now ): + monkeypatch.setattr(feature, "is_enabled", MagicMock(return_value=False)) user = {"user_permissions": []} - services = [{}, {"consumer": SERVICE.upper()}] + consumer = SERVICE.upper() + services = [{}, {"consumer": consumer}] consumable = {"how_many": 0} model = await bc.database.acreate( user=user, service=services, service_item={"service_id": 2}, consumable=consumable ) - view, _, _, _ = await make_view_all_cases(user=model.user, decorator_params={}, url_params={}) + view, _, _, kwargs = await make_view_all_cases(user=model.user, decorator_params={}, url_params={}) response, _ = await view() expected = {"detail": "with-consumer-not-enough-consumables", "status_code": 402} @@ -426,6 +451,69 @@ def check_consume_service(): await check_consume_service() + context = feature.context( + context={ + "utc_now": utc_now, + "consumer": False, + "service": consumer, + "request": AsyncRequest, + "consumables": [], + "lifetime": None, + "price": 1, + "is_consumption_session": False, + "flags": {"bypass_consumption": False}, + }, + kwargs=kwargs, + ) + assert await is_enabled_call_list() == [call("payments.bypass_consumption", context, False)] + + @pytest.mark.asyncio + @pytest.mark.django_db(reset_sequences=True) + async def test_with_user__with_group_related_to_permission__consumable__how_many_0__bypass_consumption_true( + self, bc: Breathecode, make_view_all_cases, monkeypatch, utc_now + ): + monkeypatch.setattr(feature, "is_enabled", MagicMock(return_value=True)) + user = {"user_permissions": []} + consumer = SERVICE.upper() + services = [{}, {"consumer": consumer}] + + consumable = {"how_many": 0} + model = await bc.database.acreate( + user=user, service=services, service_item={"service_id": 2}, consumable=consumable + ) + + view, expected, _, kwargs = await make_view_all_cases(user=model.user, decorator_params={}, url_params={}) + + response, _ = await view() + + assert json.loads(response.content.decode("utf-8")) == expected + assert response.status_code == status.HTTP_200_OK + # self.assertEqual(CONSUMER_MOCK.call_args_list, []) + assert await bc.database.alist_of("payments.ConsumptionSession") == [] + assert models.ConsumptionSession.build_session.call_args_list == [] + + @sync_to_async + def check_consume_service(): + assert payments_signals.consume_service.send_robust.call_args_list == [] + + await check_consume_service() + + context = feature.context( + context={ + "utc_now": utc_now, + "consumer": False, + "service": consumer, + "request": AsyncRequest, + "consumables": [], + "lifetime": None, + "price": 1, + "is_consumption_session": False, + "flags": {"bypass_consumption": True}, + }, + kwargs=kwargs, + ) + assert await is_enabled_call_list() == [call("payments.bypass_consumption", context, False)] + @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) async def test_with_user__with_group_related_to_permission__consumable__how_many_gte_1(