diff --git a/.env.example b/.env.example index b99db3f7b..fd21e08ff 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,8 @@ FACEBOOK_REDIRECT_URL= ACTIVE_CAMPAIGN_KEY= ACTIVE_CAMPAIGN_URL= +BREVO_KEY= + GOOGLE_APPLICATION_CREDENTIALS= GOOGLE_SERVICE_KEY= GOOGLE_CLOUD_KEY= diff --git a/.gitpod.Dockerfile b/.gitpod.Dockerfile index 1b8598e06..912a268a0 100644 --- a/.gitpod.Dockerfile +++ b/.gitpod.Dockerfile @@ -11,7 +11,7 @@ RUN sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release wget --quiet -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - && \ echo "deb https://apt.llvm.org/$(lsb_release -cs)/ llvm-toolchain-$(lsb_release -cs)-18 main" | sudo tee /etc/apt/sources.list.d/llvm.list && \ wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - && \ - sudo install-packages postgresql-16 postgresql-contrib-16 redis-server + sudo install-packages postgresql-16 postgresql-contrib-16 redis-server netcat passwd # Setup PostgreSQL server for user gitpod ENV PATH="/usr/lib/postgresql/16/bin:$PATH" @@ -32,6 +32,8 @@ COPY --chown=gitpod:gitpod postgresql-hook.bash $HOME/.bashrc.d/200-postgresql-l # RUN pyenv install 3.12.3 && pyenv global 3.12.3 # RUN pip install pipenv +RUN echo "root:1234" | chpasswd + USER gitpod RUN if ! grep -q "export PIP_USER=no" "$HOME/.bashrc"; then printf '%s\n' "export PIP_USER=no" >> "$HOME/.bashrc"; fi diff --git a/Pipfile b/Pipfile index 9ee2547a7..ebde1c1e7 100644 --- a/Pipfile +++ b/Pipfile @@ -132,7 +132,6 @@ zstandard = "*" psycopg = {extras = ["pool", "binary"] } cryptography = "*" adrf = "*" -uvicorn = "*" django-minify-html = "*" django-storages = {extras = ["google"] } aiohttp = {extras = ["speedups"] } @@ -149,3 +148,5 @@ capy-core = {extras = ["django"], version = "*"} google-api-python-client = "*" python-dotenv = "*" uvicorn-worker = "*" +python-magic = "*" +uvicorn = {extras = ["standard"], version = "*"} diff --git a/Pipfile.lock b/Pipfile.lock index a41f3b51d..490604812 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f681c499e9ae8747c094000da7eb08d96edeb75e8d83e8bc15c5507a3a19f743" + "sha256": "b89513fade127bf9041302e035d0af9dbcc3eaf1019de706b8d1b9f43100dc0f" }, "pipfile-spec": 6, "requires": {}, @@ -902,12 +902,12 @@ }, "eventlet": { "hashes": [ - "sha256:d227fe76a63d9e6a6cef53beb8ad0b2dc40a5e7737c801f4b474cfae1db07bc5", - "sha256:e42d0f73b718e654c223a033b8692d1a94d778a6c1deb6c3d21442746f3f727f" + "sha256:801ac231401e41f33a799457c78fdbfabc1c2f28bf9346d4ec4188e9aebc2067", + "sha256:fa49bf5a549cdbaa06919679979ea022ac8f8f3cf0499f26849a1cd8e64c30b1" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.36.1" + "version": "==0.37.0" }, "exceptiongroup": { "hashes": [ @@ -1351,7 +1351,7 @@ "sha256:fad7a051e07f64e297e6e8399b4d6a3bdcad3d7297409e9a06ef8cbccff4f501", "sha256:ffb08f2a1e59d38c7b8b9ac8083c9c8b9875f0955b1e9b9b9a965607a51f8e54" ], - "markers": "python_version >= '3.11' and platform_python_implementation == 'CPython'", + "markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", "version": "==3.1.0" }, "grpcio": { @@ -1561,6 +1561,47 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.22.0" }, + "httptools": { + "hashes": [ + "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563", + "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142", + "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d", + "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b", + "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4", + "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb", + "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658", + "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084", + "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2", + "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97", + "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837", + "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3", + "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58", + "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da", + "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d", + "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90", + "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0", + "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1", + "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2", + "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e", + "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0", + "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf", + "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc", + "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3", + "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503", + "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a", + "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3", + "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949", + "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84", + "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb", + "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a", + "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f", + "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e", + "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81", + "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185", + "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3" + ], + "version": "==0.6.1" + }, "httpx": { "hashes": [ "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", @@ -1595,11 +1636,11 @@ }, "idna": { "hashes": [ - "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", - "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603" + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" ], "markers": "python_version >= '3.6'", - "version": "==3.8" + "version": "==3.10" }, "incremental": { "hashes": [ @@ -1718,11 +1759,11 @@ }, "jupyter-client": { "hashes": [ - "sha256:2bda14d55ee5ba58552a8c53ae43d215ad9868853489213f37da060ced54d8df", - "sha256:50cbc5c66fd1b8f65ecb66bc490ab73217993632809b6e505687de18e9dea39f" + "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", + "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f" ], "markers": "python_version >= '3.8'", - "version": "==8.6.2" + "version": "==8.6.3" }, "jupyter-core": { "hashes": [ @@ -2354,12 +2395,12 @@ }, "openai": { "hashes": [ - "sha256:07e2c2758d1c94151c740b14dab638ba0d04bcb41a2e397045c90e7661cdf741", - "sha256:e0ffdab601118329ea7529e684b606a72c6c9d4f05be9ee1116255fcf5593874" + "sha256:4a6cce402aec803ae57ae7eff4b5b94bf6c0e1703a8d85541c27243c2adeadf8", + "sha256:f79e384916b219ab2f028bbf9c778e81291c61eb0645ccfa1828a4b18b55d534" ], "index": "pypi", "markers": "python_full_version >= '3.7.1'", - "version": "==1.44.1" + "version": "==1.45.1" }, "packaging": { "hashes": [ @@ -2518,11 +2559,11 @@ }, "platformdirs": { "hashes": [ - "sha256:9e5e27a08aa095dd127b9f2e764d74254f482fef22b0970773bfba79d091ab8c", - "sha256:eb1c8582560b34ed4ba105009a4badf7f6f85768b30126f351328507b2beb617" + "sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5", + "sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0" ], "markers": "python_version >= '3.8'", - "version": "==4.3.2" + "version": "==4.3.3" }, "pluggy": { "hashes": [ @@ -2571,20 +2612,20 @@ }, "protobuf": { "hashes": [ - "sha256:018db9056b9d75eb93d12a9d35120f97a84d9a919bcab11ed56ad2d399d6e8dd", - "sha256:510ed78cd0980f6d3218099e874714cdf0d8a95582e7b059b06cabad855ed0a0", - "sha256:532627e8fdd825cf8767a2d2b94d77e874d5ddb0adefb04b237f7cc296748681", - "sha256:6206afcb2d90181ae8722798dcb56dc76675ab67458ac24c0dd7d75d632ac9bd", - "sha256:66c3edeedb774a3508ae70d87b3a19786445fe9a068dd3585e0cefa8a77b83d0", - "sha256:6d7cc9e60f976cf3e873acb9a40fed04afb5d224608ed5c1a105db4a3f09c5b6", - "sha256:853db610214e77ee817ecf0514e0d1d052dff7f63a0c157aa6eabae98db8a8de", - "sha256:d001a73c8bc2bf5b5c1360d59dd7573744e163b3607fa92788b7f3d5fefbd9a5", - "sha256:dde74af0fa774fa98892209992295adbfb91da3fa98c8f67a88afe8f5a349add", - "sha256:dde9fcaa24e7a9654f4baf2a55250b13a5ea701493d904c54069776b99a8216b", - "sha256:eef7a8a2f4318e2cb2dee8666d26e58eaf437c14788f3a2911d0c3da40405ae8" + "sha256:0dfd86d2b5edf03d91ec2a7c15b4e950258150f14f9af5f51c17fa224ee1931f", + "sha256:1b04bde117a10ff9d906841a89ec326686c48ececeb65690f15b8cabe7149495", + "sha256:42597e938f83bb7f3e4b35f03aa45208d49ae8d5bcb4bc10b9fc825e0ab5e423", + "sha256:4304e4fceb823d91699e924a1fdf95cde0e066f3b1c28edb665bda762ecde10f", + "sha256:4b4b9a0562a35773ff47a3df823177ab71a1f5eb1ff56d8f842b7432ecfd7fd2", + "sha256:4c7f5cb38c640919791c9f74ea80c5b82314c69a8409ea36f2599617d03989af", + "sha256:51f09caab818707ab91cf09cc5c156026599cf05a4520779ccbf53c1b352fb25", + "sha256:c529535e5c0effcf417682563719e5d8ac8d2b93de07a56108b4c2d436d7a29a", + "sha256:cabfe43044ee319ad6832b2fda332646f9ef1636b0130186a3ae0a52fc264bb4", + "sha256:f24e5d70e6af8ee9672ff605d5503491635f63d5db2fffb6472be78ba62efd8f", + "sha256:fc063acaf7a3d9ca13146fefb5b42ac94ab943ec6e978f543cd5637da2d57957" ], "markers": "python_version >= '3.8'", - "version": "==5.28.0" + "version": "==5.28.1" }, "psycopg": { "extras": [ @@ -2592,76 +2633,87 @@ "pool" ], "hashes": [ - "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7", - "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175" + "sha256:8bad2e497ce22d556dac1464738cb948f8d6bab450d965cf1d8a8effd52412e0", + "sha256:babf565d459d8f72fb65da5e211dd0b58a52c51e4e1fa9cadecff42d6b7619b2" ], "markers": "python_version >= '3.8'", - "version": "==3.2.1" + "version": "==3.2.2" }, "psycopg-binary": { "hashes": [ - "sha256:059cbd4e6da2337e17707178fe49464ed01de867dc86c677b30751755ec1dc51", - "sha256:06a7aae34edfe179ddc04da005e083ff6c6b0020000399a2cbf0a7121a8a22ea", - "sha256:0879b5d76b7d48678d31278242aaf951bc2d69ca4e4d7cef117e4bbf7bfefda9", - "sha256:0ab58213cc976a1666f66bc1cb2e602315cd753b7981a8e17237ac2a185bd4a1", - "sha256:0b018631e5c80ce9bc210b71ea885932f9cca6db131e4df505653d7e3873a938", - "sha256:101472468d59c74bb8565fab603e032803fd533d16be4b2d13da1bab8deb32a3", - "sha256:1d353e028b8f848b9784450fc2abf149d53a738d451eab3ee4c85703438128b9", - "sha256:1d6833f607f3fc7b22226a9e121235d3b84c0eda1d3caab174673ef698f63788", - "sha256:21927f41c4d722ae8eb30d62a6ce732c398eac230509af5ba1749a337f8a63e2", - "sha256:28ada5f610468c57d8a4a055a8ea915d0085a43d794266c4f3b9d02f4288f4db", - "sha256:2e8213bf50af073b1aa8dc3cff123bfeedac86332a16c1b7274910bc88a847c7", - "sha256:302b86f92c0d76e99fe1b5c22c492ae519ce8b98b88d37ef74fda4c9e24c6b46", - "sha256:334046a937bb086c36e2c6889fe327f9f29bfc085d678f70fac0b0618949f674", - "sha256:33e6669091d09f8ba36e10ce678a6d9916e110446236a9b92346464a3565635e", - "sha256:3c838806eeb99af39f934b7999e35f947a8e577997cc892c12b5053a97a9057f", - "sha256:40bb515d042f6a345714ec0403df68ccf13f73b05e567837d80c886c7c9d3805", - "sha256:413977d18412ff83486eeb5875eb00b185a9391c57febac45b8993bf9c0ff489", - "sha256:415c3b72ea32119163255c6504085f374e47ae7345f14bc3f0ef1f6e0976a879", - "sha256:42781ba94e8842ee98bca5a7d0c44cc9d067500fedca2d6a90fa3609b6d16b42", - "sha256:463d55345f73ff391df8177a185ad57b552915ad33f5cc2b31b930500c068b22", - "sha256:4a42b8f9ab39affcd5249b45cac763ac3cf12df962b67e23fd15a2ee2932afe5", - "sha256:4c84fcac8a3a3479ac14673095cc4e1fdba2935499f72c436785ac679bec0d1a", - "sha256:592b27d6c46a40f9eeaaeea7c1fef6f3c60b02c634365eb649b2d880669f149f", - "sha256:62b1b7b07e00ee490afb39c0a47d8282a9c2822c7cfed9553a04b0058adf7e7f", - "sha256:6418712ba63cebb0c88c050b3997185b0ef54173b36568522d5634ac06153040", - "sha256:6f9e13600647087df5928875559f0eb8f496f53e6278b7da9511b4b3d0aff960", - "sha256:7066d3dca196ed0dc6172f9777b2d62e4f138705886be656cccff2d555234d60", - "sha256:73f9c9b984be9c322b5ec1515b12df1ee5896029f5e72d46160eb6517438659c", - "sha256:74d623261655a169bc84a9669890975c229f2fa6e19a7f2d10a77675dcf1a707", - "sha256:788ffc43d7517c13e624c83e0e553b7b8823c9655e18296566d36a829bfb373f", - "sha256:78c2007caf3c90f08685c5378e3ceb142bafd5636be7495f7d86ec8a977eaeef", - "sha256:7a84b5eb194a258116154b2a4ff2962ea60ea52de089508db23a51d3d6b1c7d1", - "sha256:7ce965caf618061817f66c0906f0452aef966c293ae0933d4fa5a16ea6eaf5bb", - "sha256:84837e99353d16c6980603b362d0f03302d4b06c71672a6651f38df8a482923d", - "sha256:8f28ff0cb9f1defdc4a6f8c958bf6787274247e7dfeca811f6e2f56602695fb1", - "sha256:921f0c7f39590763d64a619de84d1b142587acc70fd11cbb5ba8fa39786f3073", - "sha256:950fd666ec9e9fe6a8eeb2b5a8f17301790e518953730ad44d715b59ffdbc67f", - "sha256:9a997efbaadb5e1a294fb5760e2f5643d7b8e4e3fe6cb6f09e6d605fd28e0291", - "sha256:aa3931f308ab4a479d0ee22dc04bea867a6365cac0172e5ddcba359da043854b", - "sha256:af0469c00f24c4bec18c3d2ede124bf62688d88d1b8a5f3c3edc2f61046fe0d7", - "sha256:b0104a72a17aa84b3b7dcab6c84826c595355bf54bb6ea6d284dcb06d99c6801", - "sha256:b09e8a576a2ac69d695032ee76f31e03b30781828b5dd6d18c6a009e5a3d1c35", - "sha256:b140182830c76c74d17eba27df3755a46442ce8d4fb299e7f1cf2f74a87c877b", - "sha256:b1f087bd84bdcac78bf9f024ebdbfacd07fc0a23ec8191448a50679e2ac4a19e", - "sha256:c1d2b6438fb83376f43ebb798bf0ad5e57bc56c03c9c29c85bc15405c8c0ac5a", - "sha256:cad2de17804c4cfee8640ae2b279d616bb9e4734ac3c17c13db5e40982bd710d", - "sha256:cc304a46be1e291031148d9d95c12451ffe783ff0cc72f18e2cc7ec43cdb8c68", - "sha256:dc314a47d44fe1a8069b075a64abffad347a3a1d8652fed1bab5d3baea37acb2", - "sha256:f092114f10f81fb6bae544a0ec027eb720e2d9c74a4fcdaa9dd3899873136935", - "sha256:f34e369891f77d0738e5d25727c307d06d5344948771e5379ea29c76c6d84555", - "sha256:f8a509aeaac364fa965454e80cd110fe6d48ba2c80f56c9b8563423f0b5c3cfd", - "sha256:f8afb07114ea9b924a4a0305ceb15354ccf0ef3c0e14d54b8dbeb03e50182dd7", - "sha256:f99e59f8a5f4dcd9cbdec445f3d8ac950a492fc0e211032384d6992ed3c17eb7" - ], - "version": "==3.2.1" + "sha256:00273dd011892e8216fcef76b42f775ddaa6348664a7fffae2a27c9557f45bfa", + "sha256:020c5154be144a1440cf87eae012b9004fb414ae4b9e7b1b9fb808fe39e96e83", + "sha256:035753f80cbbf6aceca6386f53e139df70c7aca057b0592711047b5a8cfef8bb", + "sha256:05406b96139912574571b1c56bb023839a9146cf4b57c4548f36251dd5909fa1", + "sha256:059aa5e8fa119de328b4cb02ee80775443763b25682a02dd7d026b8d4f565834", + "sha256:05a50f94e1e4fa37a0074b09263b83b0aa038c3c72068a61f1ad61ea449ef9d5", + "sha256:06963f88916a177df95aaed27101af0989ba206654743b1a0e050b9d8e734686", + "sha256:0718be095cefdad712542169d16fa58b3bd9200a3de1b0217ae761cdec1cf569", + "sha256:0ad9c09de4c262f516ae6891d042a4325649b18efa39dd82bbe0f7bc95c37bfb", + "sha256:0b32b0e838841d5b109d32fc706b8bc64e50c161fee3f1371ccf696e5598bc49", + "sha256:0dd314229885a81f9497875295d8788e651b78945627540f1e78ed71595e614a", + "sha256:1a4eb737682c02a602a12aa85a492608066f77793dab681b1c4e885fedc160b1", + "sha256:1b3c5a04eaf8866e399315cff2e810260cce10b797437a9f49fd71b5f4b94d0a", + "sha256:1e1f013bfb744023df23750fde51edcb606def8328473361db3c192c392c6060", + "sha256:1ee891287c2da57e7fee31fbe2fbcdf57125768133d811b02e9523d5a052eb28", + "sha256:2eb6f8f410dbbb71b8c633f283b8588b63bee0a7321f00ab76e9c800c593f732", + "sha256:2ec4986c4ac2503e865acd3943d179531c3bbfa5a1c8ee81fcfccb551dad645f", + "sha256:366cc4e194f7feb4e3038d6775fd4b69835e7d923972aee5baec986de972abd6", + "sha256:3c482c3236ded54add31136a91d5223b233ec301f297fa2db79747404222dca6", + "sha256:3c701507a49340de422d77a6ce95918a0019990bbf27daec35aa40050c6eadb6", + "sha256:43b209be0424e8abece428a884cb711f504e3526dfbcb0bf51529907a55eda15", + "sha256:4afbb97d64cd8078edec859b07859a18ef3de7261a3a873ba52f32548373ae92", + "sha256:4bcb489615d7e56d1de42937e6a0fc13f766505729afdb54c2947a52db295220", + "sha256:4cf64e41e238620f05aad862f06bc8424f8f320d8075f1499bd85a225d18bd57", + "sha256:4f12640ba92c538b3b64a199a918d3bb0cc0d7f7123c6ba93cb065e1a2d049f0", + "sha256:51f56ae2898acaa33623adad96ddc5acbb5e2f72f2fc020065c8be05c0e01dce", + "sha256:554d208757129d34fa47b7c890f9ef922f754e99c6b089cb3a209aa0fe282682", + "sha256:566b1c530898590f0ac9d949cf94351c08d73c89f8800c74c0a63ffd89a383c8", + "sha256:5e95e4a8076ac7611e571623e1113fa84fd48c0459601969ffbf534d7aa236e7", + "sha256:6269d79a3d7d76b6fcf0fafae8444da00e83777a6c68c43851351a571ad37155", + "sha256:66de2dd7d37bf66eb234ca9d907f5cd8caca43ff8d8a50dd5c15844d1cf0390c", + "sha256:68d03efab7e2830a0df3aa4c29a708930e3f6b9fd98774ff9c4fd1f33deafecc", + "sha256:6c7b6a8d4e1b77cdb50192b61235b33fc2f1d28c67627fc93a1d43e9130dd479", + "sha256:705da5bc4364bd7529473225fca02b795653bc5bd824dbe43e1df0b1a40fe691", + "sha256:71dc3cc10d1fd7d26a3079d0a5b4a8e8ad0d7b89a702ceb7605a52e4395be122", + "sha256:7c357cf87e8d7612cfe781225be7669f35038a765d1b53ec9605f6c5aef9ee85", + "sha256:849d518e7d4c6186e1e48ea2ac2671912edf7e732fffe6f01dfed61cf0245de4", + "sha256:87cceaf07760a04023596f9ca1d4e929d38ae8d778161cb3e8d27a0f990dd264", + "sha256:8937dc548621b336b0d8383a3470fb7192b42a108c760a152282909867bf5b26", + "sha256:8eacbf58d4f8d7bc82e0a60476afa2622b5a58f639a3cc2710e3e37b72aff3cb", + "sha256:8ee2b19152bcec8f356f989c31768702be5f139b4d51094273c4a9ddc8c55380", + "sha256:951507b3d77a64c907afe893e01e09b41051fd7e27e9462f450fb8bb64bc22b0", + "sha256:989acbe2f552769cdb780346cea32d86e7c117044238d5172ac10b025fe47194", + "sha256:9e120a576e74e4e612c48f4b021e322e320ca102534d78a0ca4db2ffd058ae8d", + "sha256:9efe0ca78be4a573b4b81226904c711cfadc4783d64bfdf58a3394da7c1a1354", + "sha256:9fee41c99312002e5d1f7462b1954aefed44c6efe5f021c3eac311640c16f6b7", + "sha256:a06136aab55a2de7dd4e2555badae276846827cfb023e6ba1b22f7a7b88e3f1b", + "sha256:a60674dff4a4194e88312b463fb84ac80924c2b9e25d0e0460f3176bf1af4a6b", + "sha256:a86f578d63f2e1fdf87c9adaed4ff23d7919bda8791cf1380fa4cf3a857ccb8b", + "sha256:b286ed65a891928bd457ffa0cd5fec09b9b5208bfd096d087e45369f07c5cb85", + "sha256:b45553c6b614d02e1486585980afdfd18f0000aac668e2e87c6e32da1adb051a", + "sha256:bf1d3582185cb43ecc27403bee2f5405b7a45ccaab46c8508d9a9327341574fc", + "sha256:c22e615ee0ecfc6687bb8a39a4ed9d6bac030b5e72ac15e7324fd6e48979af71", + "sha256:c432710bdf8ccfdd75b0bc9cdf1fd21ff394363e4daec099c667f3c5f1721e2b", + "sha256:c9ee99336151ff7c30682f2ef9cb1174d235bc1471322faabba97f9db1398167", + "sha256:d07e62476ee8c54853b2b8cfdf3858a574218103b4cd213211f64326c7812437", + "sha256:d3c147eea9f3950a34133dc187e8d3534e54ff4a178a4ebd8993b2c97e123200", + "sha256:d6dd5d21a298c3c53af20ced8da4ae4cd038c6fe88c80842a8888fa3660b2094", + "sha256:e234edc4bb746d8ac3daae8753ee38eaa7af2ee333a1d35ce6b02a02874aed18", + "sha256:ec29c7ec136263628e3f09a53e51d0a4b1ad765a6e45135707bfa848b39113f9", + "sha256:ed1ad836a0c21890c7f84e73c7ef1ed0950e0e4b0d8e49b609b6fd9c13f2ca21", + "sha256:ef341c556aeaa43a2729b07b04e20bfffdcf3d96c4a96e728ca94fe4ce632d8c", + "sha256:fb303b03c243a9041e1873b596e246f7caaf01710b312fafa65b1db5cd77dd6f", + "sha256:fdc74a83348477b28bea9e7b391c9fc189b480fe3cd0e46bb989514410b64d60" + ], + "version": "==3.2.2" }, "psycopg-pool": { "hashes": [ - "sha256:273081d0fbfaced4f35e69200c89cb8fbddfe277c38cc86c235b90a2ec2c8153", - "sha256:9e22c370045f6d7f2666a5ad1b0caf345f9f1912195b0b25d0d3bcc4f3a7389c" + "sha256:53bd8e640625e01b2927b2ad96df8ed8e8f91caea4597d45e7673fc7bbb85eb1", + "sha256:bb942f123bef4b7fbe4d55421bd3fb01829903c95c0f33fd42b7e94e5ac9b52a" ], - "version": "==3.2.2" + "version": "==3.2.3" }, "psycopg2": { "hashes": [ @@ -2725,6 +2777,7 @@ }, "pyasn1": { "hashes": [ + "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034" ], "markers": "python_version >= '3.8'", @@ -2732,6 +2785,7 @@ }, "pyasn1-modules": { "hashes": [ + "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c" ], "markers": "python_version >= '3.8'", @@ -2907,11 +2961,11 @@ }, "pyfcm": { "hashes": [ - "sha256:17f7377393514d98ea28ed56fe4360089802819699313c99bc2c80c6e29ed389", - "sha256:e063ef09f1ff0d0caaaec5f63339ec14176dd5d9cc6ffdb5c0110a9ae2665f0a" + "sha256:2d79f26f028fa0c3eff82d962532df6825eeafac621c2fbffbb771f39d3cf6e8", + "sha256:6b9382d28e88150f3a265cef79975108b2bf644063c1e8b2b90ded25b0757369" ], "index": "pypi", - "version": "==2.0.6" + "version": "==2.0.7" }, "pygithub": { "hashes": [ @@ -3038,6 +3092,15 @@ "index": "pypi", "version": "==1.1.0" }, + "python-magic": { + "hashes": [ + "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", + "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.4.27" + }, "python-slugify": { "hashes": [ "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", @@ -3416,11 +3479,11 @@ }, "setuptools": { "hashes": [ - "sha256:5f4c08aa4d3ebcb57a50c33b1b07e94315d7fc7230f7115e47fc99776c8ce308", - "sha256:95b40ed940a1c67eb70fc099094bd6e99c6ee7c23aa2306f4d2697ba7916f9c6" + "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2", + "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538" ], "markers": "python_version >= '3.8'", - "version": "==74.1.2" + "version": "==75.1.0" }, "six": { "hashes": [ @@ -3518,12 +3581,12 @@ }, "stripe": { "hashes": [ - "sha256:36ab5f75e4af790dd6888450d812f502357b3e699a93cd2f1a8fd0014a4b4fca", - "sha256:82351f9e1055c2161dc38c08a9bbf5c4b6c7f1caffcf911e999a7012369415e2" + "sha256:24300df559e547f58989e880c1f006460ed322d1c963d7b833562632b5eb956a", + "sha256:d18e1b498331fefd7738f7f72a8c0e852d7b933be9639604f81c8d575f44a1d7" ], "index": "pypi", "markers": "python_version >= '3.6'", - "version": "==10.10.0" + "version": "==10.11.0" }, "text-unidecode": { "hashes": [ @@ -3614,7 +3677,7 @@ "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" ], - "markers": "python_version >= '3.8'", + "markers": "python_version < '3.13'", "version": "==4.12.2" }, "tzdata": { @@ -3636,18 +3699,20 @@ }, "urllib3": { "hashes": [ - "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", - "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" ], "markers": "python_version >= '3.8'", - "version": "==2.2.2" + "version": "==2.2.3" }, "uvicorn": { + "extras": [ + "standard" + ], "hashes": [ "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788", "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5" ], - "index": "pypi", "markers": "python_version >= '3.8'", "version": "==0.30.6" }, @@ -3660,6 +3725,42 @@ "markers": "python_version >= '3.8'", "version": "==0.2.0" }, + "uvloop": { + "hashes": [ + "sha256:265a99a2ff41a0fd56c19c3838b29bf54d1d177964c300dad388b27e84fd7847", + "sha256:2beee18efd33fa6fdb0976e18475a4042cd31c7433c866e8a09ab604c7c22ff2", + "sha256:35968fc697b0527a06e134999eef859b4034b37aebca537daeb598b9d45a137b", + "sha256:36c530d8fa03bfa7085af54a48f2ca16ab74df3ec7108a46ba82fd8b411a2315", + "sha256:3a609780e942d43a275a617c0839d85f95c334bad29c4c0918252085113285b5", + "sha256:4603ca714a754fc8d9b197e325db25b2ea045385e8a3ad05d3463de725fdf469", + "sha256:4b75f2950ddb6feed85336412b9a0c310a2edbcf4cf931aa5cfe29034829676d", + "sha256:4f44af67bf39af25db4c1ac27e82e9665717f9c26af2369c404be865c8818dcf", + "sha256:6462c95f48e2d8d4c993a2950cd3d31ab061864d1c226bbf0ee2f1a8f36674b9", + "sha256:649c33034979273fa71aa25d0fe120ad1777c551d8c4cd2c0c9851d88fcb13ab", + "sha256:746242cd703dc2b37f9d8b9f173749c15e9a918ddb021575a0205ec29a38d31e", + "sha256:77fbc69c287596880ecec2d4c7a62346bef08b6209749bf6ce8c22bbaca0239e", + "sha256:80dc1b139516be2077b3e57ce1cb65bfed09149e1d175e0478e7a987863b68f0", + "sha256:82edbfd3df39fb3d108fc079ebc461330f7c2e33dbd002d146bf7c445ba6e756", + "sha256:89e8d33bb88d7263f74dc57d69f0063e06b5a5ce50bb9a6b32f5fcbe655f9e73", + "sha256:94707205efbe809dfa3a0d09c08bef1352f5d3d6612a506f10a319933757c006", + "sha256:95720bae002ac357202e0d866128eb1ac82545bcf0b549b9abe91b5178d9b541", + "sha256:9b04d96188d365151d1af41fa2d23257b674e7ead68cfd61c725a422764062ae", + "sha256:9d0fba61846f294bce41eb44d60d58136090ea2b5b99efd21cbdf4e21927c56a", + "sha256:9ebafa0b96c62881d5cafa02d9da2e44c23f9f0cd829f3a32a6aff771449c996", + "sha256:a0fac7be202596c7126146660725157d4813aa29a4cc990fe51346f75ff8fde7", + "sha256:aea15c78e0d9ad6555ed201344ae36db5c63d428818b4b2a42842b3870127c00", + "sha256:b10c2956efcecb981bf9cfb8184d27d5d64b9033f917115a960b83f11bfa0d6b", + "sha256:b16696f10e59d7580979b420eedf6650010a4a9c3bd8113f24a103dfdb770b10", + "sha256:d8c36fdf3e02cec92aed2d44f63565ad1522a499c654f07935c8f9d04db69e95", + "sha256:e237f9c1e8a00e7d9ddaa288e535dc337a39bcbf679f290aee9d26df9e72bce9", + "sha256:e50289c101495e0d1bb0bfcb4a60adde56e32f4449a67216a1ab2750aa84f037", + "sha256:e7d61fe8e8d9335fac1bf8d5d82820b4808dd7a43020c149b63a1ada953d48a6", + "sha256:e97152983442b499d7a71e44f29baa75b3b02e65d9c44ba53b10338e98dedb66", + "sha256:f0e94b221295b5e69de57a1bd4aeb0b3a29f61be6e1b478bb8a69a73377db7ba", + "sha256:fee6044b64c965c425b65a4e17719953b96e065c5b7e09b599ff332bb2744bdf" + ], + "version": "==0.20.0" + }, "vine": { "hashes": [ "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", @@ -3668,6 +3769,94 @@ "markers": "python_version >= '3.6'", "version": "==5.1.0" }, + "watchfiles": { + "hashes": [ + "sha256:01550ccf1d0aed6ea375ef259706af76ad009ef5b0203a3a4cce0f6024f9b68a", + "sha256:01def80eb62bd5db99a798d5e1f5f940ca0a05986dcfae21d833af7a46f7ee22", + "sha256:07cdef0c84c03375f4e24642ef8d8178e533596b229d32d2bbd69e5128ede02a", + "sha256:083dc77dbdeef09fa44bb0f4d1df571d2e12d8a8f985dccde71ac3ac9ac067a0", + "sha256:1cf1f6dd7825053f3d98f6d33f6464ebdd9ee95acd74ba2c34e183086900a827", + "sha256:21ab23fdc1208086d99ad3f69c231ba265628014d4aed31d4e8746bd59e88cd1", + "sha256:2dadf8a8014fde6addfd3c379e6ed1a981c8f0a48292d662e27cabfe4239c83c", + "sha256:2e28d91ef48eab0afb939fa446d8ebe77e2f7593f5f463fd2bb2b14132f95b6e", + "sha256:2efec17819b0046dde35d13fb8ac7a3ad877af41ae4640f4109d9154ed30a188", + "sha256:30bbd525c3262fd9f4b1865cb8d88e21161366561cd7c9e1194819e0a33ea86b", + "sha256:316449aefacf40147a9efaf3bd7c9bdd35aaba9ac5d708bd1eb5763c9a02bef5", + "sha256:327763da824817b38ad125dcd97595f942d720d32d879f6c4ddf843e3da3fe90", + "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef", + "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b", + "sha256:3770e260b18e7f4e576edca4c0a639f704088602e0bc921c5c2e721e3acb8d15", + "sha256:3d2e3ab79a1771c530233cadfd277fcc762656d50836c77abb2e5e72b88e3a48", + "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e", + "sha256:43e3e37c15a8b6fe00c1bce2473cfa8eb3484bbeecf3aefbf259227e487a03df", + "sha256:449f43f49c8ddca87c6b3980c9284cab6bd1f5c9d9a2b00012adaaccd5e7decd", + "sha256:4933a508d2f78099162da473841c652ad0de892719043d3f07cc83b33dfd9d91", + "sha256:49d617df841a63b4445790a254013aea2120357ccacbed00253f9c2b5dc24e2d", + "sha256:49fb58bcaa343fedc6a9e91f90195b20ccb3135447dc9e4e2570c3a39565853e", + "sha256:4a7fa2bc0efef3e209a8199fd111b8969fe9db9c711acc46636686331eda7dd4", + "sha256:4abf4ad269856618f82dee296ac66b0cd1d71450fc3c98532d93798e73399b7a", + "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370", + "sha256:4d28cea3c976499475f5b7a2fec6b3a36208656963c1a856d328aeae056fc5c1", + "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea", + "sha256:54ca90a9ae6597ae6dc00e7ed0a040ef723f84ec517d3e7ce13e63e4bc82fa04", + "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896", + "sha256:5c51749f3e4e269231510da426ce4a44beb98db2dce9097225c338f815b05d4f", + "sha256:632676574429bee8c26be8af52af20e0c718cc7f5f67f3fb658c71928ccd4f7f", + "sha256:6509ed3f467b79d95fc62a98229f79b1a60d1b93f101e1c61d10c95a46a84f43", + "sha256:6bdcfa3cd6fdbdd1a068a52820f46a815401cbc2cb187dd006cb076675e7b735", + "sha256:7138eff8baa883aeaa074359daabb8b6c1e73ffe69d5accdc907d62e50b1c0da", + "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a", + "sha256:73bde715f940bea845a95247ea3e5eb17769ba1010efdc938ffcb967c634fa61", + "sha256:78470906a6be5199524641f538bd2c56bb809cd4bf29a566a75051610bc982c3", + "sha256:7ae3e208b31be8ce7f4c2c0034f33406dd24fbce3467f77223d10cd86778471c", + "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f", + "sha256:82ae557a8c037c42a6ef26c494d0631cacca040934b101d001100ed93d43f361", + "sha256:82b2509f08761f29a0fdad35f7e1638b8ab1adfa2666d41b794090361fb8b855", + "sha256:8360f7314a070c30e4c976b183d1d8d1585a4a50c5cb603f431cebcbb4f66327", + "sha256:85d5f0c7771dcc7a26c7a27145059b6bb0ce06e4e751ed76cdf123d7039b60b5", + "sha256:88bcd4d0fe1d8ff43675360a72def210ebad3f3f72cabfeac08d825d2639b4ab", + "sha256:9301c689051a4857d5b10777da23fafb8e8e921bcf3abe6448a058d27fb67633", + "sha256:951088d12d339690a92cef2ec5d3cfd957692834c72ffd570ea76a6790222777", + "sha256:95cf3b95ea665ab03f5a54765fa41abf0529dbaf372c3b83d91ad2cfa695779b", + "sha256:96619302d4374de5e2345b2b622dc481257a99431277662c30f606f3e22f42be", + "sha256:999928c6434372fde16c8f27143d3e97201160b48a614071261701615a2a156f", + "sha256:9a60e2bf9dc6afe7f743e7c9b149d1fdd6dbf35153c78fe3a14ae1a9aee3d98b", + "sha256:9f895d785eb6164678ff4bb5cc60c5996b3ee6df3edb28dcdeba86a13ea0465e", + "sha256:a2a9891723a735d3e2540651184be6fd5b96880c08ffe1a98bae5017e65b544b", + "sha256:a974231b4fdd1bb7f62064a0565a6b107d27d21d9acb50c484d2cdba515b9366", + "sha256:aa0fd7248cf533c259e59dc593a60973a73e881162b1a2f73360547132742823", + "sha256:acbfa31e315a8f14fe33e3542cbcafc55703b8f5dcbb7c1eecd30f141df50db3", + "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1", + "sha256:b3ef2c69c655db63deb96b3c3e587084612f9b1fa983df5e0c3379d41307467f", + "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418", + "sha256:b665caeeda58625c3946ad7308fbd88a086ee51ccb706307e5b1fa91556ac886", + "sha256:b74fdffce9dfcf2dc296dec8743e5b0332d15df19ae464f0e249aa871fc1c571", + "sha256:b995bfa6bf01a9e09b884077a6d37070464b529d8682d7691c2d3b540d357a0c", + "sha256:bd82010f8ab451dabe36054a1622870166a67cf3fce894f68895db6f74bbdc94", + "sha256:bdcd5538e27f188dd3c804b4a8d5f52a7fc7f87e7fd6b374b8e36a4ca03db428", + "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234", + "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6", + "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968", + "sha256:d337193bbf3e45171c8025e291530fb7548a93c45253897cd764a6a71c937ed9", + "sha256:d3dcb774e3568477275cc76554b5a565024b8ba3a0322f77c246bc7111c5bb9c", + "sha256:d64ba08db72e5dfd5c33be1e1e687d5e4fcce09219e8aee893a4862034081d4e", + "sha256:d7a2e3b7f5703ffbd500dabdefcbc9eafeff4b9444bbdd5d83d79eedf8428fab", + "sha256:d831ee0a50946d24a53821819b2327d5751b0c938b12c0653ea5be7dea9c82ec", + "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444", + "sha256:e5171ef898299c657685306d8e1478a45e9303ddcd8ac5fed5bd52ad4ae0b69b", + "sha256:e94e98c7cb94cfa6e071d401ea3342767f28eb5a06a58fafdc0d2a4974f4f35c", + "sha256:ec39698c45b11d9694a1b635a70946a5bad066b593af863460a8e600f0dff1ca", + "sha256:ed9aba6e01ff6f2e8285e5aa4154e2970068fe0fc0998c4380d0e6278222269b", + "sha256:edf71b01dec9f766fb285b73930f95f730bb0943500ba0566ae234b5c1618c18", + "sha256:ee82c98bed9d97cd2f53bdb035e619309a098ea53ce525833e26b93f673bc318", + "sha256:f4c96283fca3ee09fb044f02156d9570d156698bc3734252175a38f0e8975f07", + "sha256:f7d9b87c4c55e3ea8881dfcbf6d61ea6775fffed1fedffaa60bd047d3c08c430", + "sha256:f83df90191d67af5a831da3a33dd7628b02a95450e168785586ed51e6d28943c", + "sha256:fca9433a45f18b7c779d2bae7beeec4f740d28b788b117a48368d95a3233ed83", + "sha256:fd92bbaa2ecdb7864b7600dcdb6f2f1db6e0346ed425fbd01085be04c63f0b05" + ], + "version": "==0.24.0" + }, "wcwidth": { "hashes": [ "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", @@ -3682,6 +3871,97 @@ ], "version": "==0.5.1" }, + "websockets": { + "hashes": [ + "sha256:00fd961943b6c10ee6f0b1130753e50ac5dcd906130dcd77b0003c3ab797d026", + "sha256:03d3f9ba172e0a53e37fa4e636b86cc60c3ab2cfee4935e66ed1d7acaa4625ad", + "sha256:0513c727fb8adffa6d9bf4a4463b2bade0186cbd8c3604ae5540fae18a90cb99", + "sha256:05e70fec7c54aad4d71eae8e8cab50525e899791fc389ec6f77b95312e4e9920", + "sha256:0617fd0b1d14309c7eab6ba5deae8a7179959861846cbc5cb528a7531c249448", + "sha256:06c0a667e466fcb56a0886d924b5f29a7f0886199102f0a0e1c60a02a3751cb4", + "sha256:0f52504023b1480d458adf496dc1c9e9811df4ba4752f0bc1f89ae92f4f07d0c", + "sha256:10a0dc7242215d794fb1918f69c6bb235f1f627aaf19e77f05336d147fce7c37", + "sha256:11f9976ecbc530248cf162e359a92f37b7b282de88d1d194f2167b5e7ad80ce3", + "sha256:132511bfd42e77d152c919147078460c88a795af16b50e42a0bd14f0ad71ddd2", + "sha256:139add0f98206cb74109faf3611b7783ceafc928529c62b389917a037d4cfdf4", + "sha256:14b9c006cac63772b31abbcd3e3abb6228233eec966bf062e89e7fa7ae0b7333", + "sha256:15c7d62ee071fa94a2fc52c2b472fed4af258d43f9030479d9c4a2de885fd543", + "sha256:165bedf13556f985a2aa064309baa01462aa79bf6112fbd068ae38993a0e1f1b", + "sha256:17118647c0ea14796364299e942c330d72acc4b248e07e639d34b75067b3cdd8", + "sha256:1841c9082a3ba4a05ea824cf6d99570a6a2d8849ef0db16e9c826acb28089e8f", + "sha256:1a678532018e435396e37422a95e3ab87f75028ac79570ad11f5bf23cd2a7d8c", + "sha256:1ee4cc030a4bdab482a37462dbf3ffb7e09334d01dd37d1063be1136a0d825fa", + "sha256:1f3cf6d6ec1142412d4535adabc6bd72a63f5f148c43fe559f06298bc21953c9", + "sha256:1f613289f4a94142f914aafad6c6c87903de78eae1e140fa769a7385fb232fdf", + "sha256:1fa082ea38d5de51dd409434edc27c0dcbd5fed2b09b9be982deb6f0508d25bc", + "sha256:249aab278810bee585cd0d4de2f08cfd67eed4fc75bde623be163798ed4db2eb", + "sha256:254ecf35572fca01a9f789a1d0f543898e222f7b69ecd7d5381d8d8047627bdb", + "sha256:2a02b0161c43cc9e0232711eff846569fad6ec836a7acab16b3cf97b2344c060", + "sha256:30d3a1f041360f029765d8704eae606781e673e8918e6b2c792e0775de51352f", + "sha256:3624fd8664f2577cf8de996db3250662e259bfbc870dd8ebdcf5d7c6ac0b5185", + "sha256:3f55b36d17ac50aa8a171b771e15fbe1561217510c8768af3d546f56c7576cdc", + "sha256:46af561eba6f9b0848b2c9d2427086cabadf14e0abdd9fde9d72d447df268418", + "sha256:47236c13be337ef36546004ce8c5580f4b1150d9538b27bf8a5ad8edf23ccfab", + "sha256:4a365bcb7be554e6e1f9f3ed64016e67e2fa03d7b027a33e436aecf194febb63", + "sha256:4d6ece65099411cfd9a48d13701d7438d9c34f479046b34c50ff60bb8834e43e", + "sha256:4e85f46ce287f5c52438bb3703d86162263afccf034a5ef13dbe4318e98d86e7", + "sha256:4f0426d51c8f0926a4879390f53c7f5a855e42d68df95fff6032c82c888b5f36", + "sha256:518f90e6dd089d34eaade01101fd8a990921c3ba18ebbe9b0165b46ebff947f0", + "sha256:52aed6ef21a0f1a2a5e310fb5c42d7555e9c5855476bbd7173c3aa3d8a0302f2", + "sha256:556e70e4f69be1082e6ef26dcb70efcd08d1850f5d6c5f4f2bcb4e397e68f01f", + "sha256:56a952fa2ae57a42ba7951e6b2605e08a24801a4931b5644dfc68939e041bc7f", + "sha256:59197afd478545b1f73367620407b0083303569c5f2d043afe5363676f2697c9", + "sha256:5df891c86fe68b2c38da55b7aea7095beca105933c697d719f3f45f4220a5e0e", + "sha256:63848cdb6fcc0bf09d4a155464c46c64ffdb5807ede4fb251da2c2692559ce75", + "sha256:64a11aae1de4c178fa653b07d90f2fb1a2ed31919a5ea2361a38760192e1858b", + "sha256:6724b554b70d6195ba19650fef5759ef11346f946c07dbbe390e039bcaa7cc3d", + "sha256:67494e95d6565bf395476e9d040037ff69c8b3fa356a886b21d8422ad86ae075", + "sha256:67648f5e50231b5a7f6d83b32f9c525e319f0ddc841be0de64f24928cd75a603", + "sha256:68264802399aed6fe9652e89761031acc734fc4c653137a5911c2bfa995d6d6d", + "sha256:699ba9dd6a926f82a277063603fc8d586b89f4cb128efc353b749b641fcddda7", + "sha256:6aa74a45d4cdc028561a7d6ab3272c8b3018e23723100b12e58be9dfa5a24491", + "sha256:6b41a1b3b561f1cba8321fb32987552a024a8f67f0d05f06fcf29f0090a1b956", + "sha256:71e6e5a3a3728886caee9ab8752e8113670936a193284be9d6ad2176a137f376", + "sha256:7d20516990d8ad557b5abeb48127b8b779b0b7e6771a265fa3e91767596d7d97", + "sha256:80e4ba642fc87fa532bac07e5ed7e19d56940b6af6a8c61d4429be48718a380f", + "sha256:872afa52a9f4c414d6955c365b6588bc4401272c629ff8321a55f44e3f62b553", + "sha256:8eb2b9a318542153674c6e377eb8cb9ca0fc011c04475110d3477862f15d29f0", + "sha256:9bbc525f4be3e51b89b2a700f5746c2a6907d2e2ef4513a8daafc98198b92237", + "sha256:a1a2e272d067030048e1fe41aa1ec8cfbbaabce733b3d634304fa2b19e5c897f", + "sha256:a5dc0c42ded1557cc7c3f0240b24129aefbad88af4f09346164349391dea8e58", + "sha256:acab3539a027a85d568c2573291e864333ec9d912675107d6efceb7e2be5d980", + "sha256:acbebec8cb3d4df6e2488fbf34702cbc37fc39ac7abf9449392cefb3305562e9", + "sha256:ad327ac80ba7ee61da85383ca8822ff808ab5ada0e4a030d66703cc025b021c4", + "sha256:b448a0690ef43db5ef31b3a0d9aea79043882b4632cfc3eaab20105edecf6097", + "sha256:b5a06d7f60bc2fc378a333978470dfc4e1415ee52f5f0fce4f7853eb10c1e9df", + "sha256:b74593e9acf18ea5469c3edaa6b27fa7ecf97b30e9dabd5a94c4c940637ab96e", + "sha256:b79915a1179a91f6c5f04ece1e592e2e8a6bd245a0e45d12fd56b2b59e559a32", + "sha256:b80f0c51681c517604152eb6a572f5a9378f877763231fddb883ba2f968e8817", + "sha256:b8ac5b46fd798bbbf2ac6620e0437c36a202b08e1f827832c4bf050da081b501", + "sha256:c3c493d0e5141ec055a7d6809a28ac2b88d5b878bb22df8c621ebe79a61123d0", + "sha256:c44ca9ade59b2e376612df34e837013e2b273e6c92d7ed6636d0556b6f4db93d", + "sha256:c4a6343e3b0714e80da0b0893543bf9a5b5fa71b846ae640e56e9abc6fbc4c83", + "sha256:c5870b4a11b77e4caa3937142b650fbbc0914a3e07a0cf3131f35c0587489c1c", + "sha256:ca48914cdd9f2ccd94deab5bcb5ac98025a5ddce98881e5cce762854a5de330b", + "sha256:cf2fae6d85e5dc384bf846f8243ddaa9197f3a1a70044f59399af001fd1f51d4", + "sha256:d450f5a7a35662a9b91a64aefa852f0c0308ee256122f5218a42f1d13577d71e", + "sha256:d6716c087e4aa0b9260c4e579bb82e068f84faddb9bfba9906cb87726fa2e870", + "sha256:d93572720d781331fb10d3da9ca1067817d84ad1e7c31466e9f5e59965618096", + "sha256:dbb0b697cc0655719522406c059eae233abaa3243821cfdfab1215d02ac10231", + "sha256:e33505534f3f673270dd67f81e73550b11de5b538c56fe04435d63c02c3f26b5", + "sha256:e801ca2f448850685417d723ec70298feff3ce4ff687c6f20922c7474b4746ae", + "sha256:e82db3756ccb66266504f5a3de05ac6b32f287faacff72462612120074103329", + "sha256:ef48e4137e8799998a343706531e656fdec6797b80efd029117edacb74b0a10a", + "sha256:f1d3d1f2eb79fe7b0fb02e599b2bf76a7619c79300fc55f0b5e2d382881d4f7f", + "sha256:f3fea72e4e6edb983908f0db373ae0732b275628901d909c382aae3b592589f2", + "sha256:f40de079779acbcdbb6ed4c65af9f018f8b77c5ec4e17a4b737c05c2db554491", + "sha256:f73e676a46b0fe9426612ce8caeca54c9073191a77c3e9d5c94697aef99296af", + "sha256:f9c9e258e3d5efe199ec23903f5da0eeaad58cf6fccb3547b74fd4750e5ac47a", + "sha256:fac2d146ff30d9dd2fcf917e5d147db037a5c573f0446c564f16f1f94cf87462", + "sha256:faef9ec6354fe4f9a2c0bbb52fb1ff852effc897e2a4501e25eb3a47cb0a4f89" + ], + "version": "==13.0.1" + }, "whitenoise": { "extras": [ "brotli" @@ -3874,15 +4154,7 @@ "markers": "python_version >= '3.8'", "version": "==1.11.1" }, - "zope.event": { - "hashes": [ - "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", - "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd" - ], - "markers": "python_version >= '3.7'", - "version": "==5.0" - }, - "zope.interface": { + "zope-interface": { "hashes": [ "sha256:01e6e58078ad2799130c14a1d34ec89044ada0e1495329d72ee0407b9ae5100d", "sha256:064ade95cb54c840647205987c7b557f75d2b2f7d1a84bfab4cf81822ef6e7d1", @@ -3922,6 +4194,14 @@ "markers": "python_version >= '3.8'", "version": "==7.0.3" }, + "zope.event": { + "hashes": [ + "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", + "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd" + ], + "markers": "python_version >= '3.7'", + "version": "==5.0" + }, "zstandard": { "hashes": [ "sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473", @@ -4546,16 +4826,16 @@ "sha256:fad7a051e07f64e297e6e8399b4d6a3bdcad3d7297409e9a06ef8cbccff4f501", "sha256:ffb08f2a1e59d38c7b8b9ac8083c9c8b9875f0955b1e9b9b9a965607a51f8e54" ], - "markers": "python_version >= '3.11' and platform_python_implementation == 'CPython'", + "markers": "python_version >= '3' and platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))", "version": "==3.1.0" }, "griffe": { "hashes": [ - "sha256:3c85b5704136379bed767ef9c1d7776cac50886e341b61b71c6983dfe04d7cb2", - "sha256:878cd99709b833fab7c41a6545188bcdbc1fcb3b441374449d34b69cb864de69" + "sha256:3f86a716b631a4c0f96a43cb75d05d3c85975003c20540426c0eba3b0581c56a", + "sha256:940aeb630bc3054b4369567f150b6365be6f11eef46b0ed8623aea96e6d17b19" ], "markers": "python_version >= '3.8'", - "version": "==1.3.0" + "version": "==1.3.1" }, "grpcio": { "hashes": [ @@ -4625,19 +4905,19 @@ }, "identify": { "hashes": [ - "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf", - "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0" + "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", + "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98" ], "markers": "python_version >= '3.8'", - "version": "==2.6.0" + "version": "==2.6.1" }, "idna": { "hashes": [ - "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac", - "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603" + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" ], "markers": "python_version >= '3.6'", - "version": "==3.8" + "version": "==3.10" }, "iniconfig": { "hashes": [ @@ -5012,11 +5292,11 @@ }, "platformdirs": { "hashes": [ - "sha256:9e5e27a08aa095dd127b9f2e764d74254f482fef22b0970773bfba79d091ab8c", - "sha256:eb1c8582560b34ed4ba105009a4badf7f6f85768b30126f351328507b2beb617" + "sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5", + "sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0" ], "markers": "python_version >= '3.8'", - "version": "==4.3.2" + "version": "==4.3.3" }, "pluggy": { "hashes": [ @@ -5045,23 +5325,24 @@ }, "protobuf": { "hashes": [ - "sha256:018db9056b9d75eb93d12a9d35120f97a84d9a919bcab11ed56ad2d399d6e8dd", - "sha256:510ed78cd0980f6d3218099e874714cdf0d8a95582e7b059b06cabad855ed0a0", - "sha256:532627e8fdd825cf8767a2d2b94d77e874d5ddb0adefb04b237f7cc296748681", - "sha256:6206afcb2d90181ae8722798dcb56dc76675ab67458ac24c0dd7d75d632ac9bd", - "sha256:66c3edeedb774a3508ae70d87b3a19786445fe9a068dd3585e0cefa8a77b83d0", - "sha256:6d7cc9e60f976cf3e873acb9a40fed04afb5d224608ed5c1a105db4a3f09c5b6", - "sha256:853db610214e77ee817ecf0514e0d1d052dff7f63a0c157aa6eabae98db8a8de", - "sha256:d001a73c8bc2bf5b5c1360d59dd7573744e163b3607fa92788b7f3d5fefbd9a5", - "sha256:dde74af0fa774fa98892209992295adbfb91da3fa98c8f67a88afe8f5a349add", - "sha256:dde9fcaa24e7a9654f4baf2a55250b13a5ea701493d904c54069776b99a8216b", - "sha256:eef7a8a2f4318e2cb2dee8666d26e58eaf437c14788f3a2911d0c3da40405ae8" + "sha256:0dfd86d2b5edf03d91ec2a7c15b4e950258150f14f9af5f51c17fa224ee1931f", + "sha256:1b04bde117a10ff9d906841a89ec326686c48ececeb65690f15b8cabe7149495", + "sha256:42597e938f83bb7f3e4b35f03aa45208d49ae8d5bcb4bc10b9fc825e0ab5e423", + "sha256:4304e4fceb823d91699e924a1fdf95cde0e066f3b1c28edb665bda762ecde10f", + "sha256:4b4b9a0562a35773ff47a3df823177ab71a1f5eb1ff56d8f842b7432ecfd7fd2", + "sha256:4c7f5cb38c640919791c9f74ea80c5b82314c69a8409ea36f2599617d03989af", + "sha256:51f09caab818707ab91cf09cc5c156026599cf05a4520779ccbf53c1b352fb25", + "sha256:c529535e5c0effcf417682563719e5d8ac8d2b93de07a56108b4c2d436d7a29a", + "sha256:cabfe43044ee319ad6832b2fda332646f9ef1636b0130186a3ae0a52fc264bb4", + "sha256:f24e5d70e6af8ee9672ff605d5503491635f63d5db2fffb6472be78ba62efd8f", + "sha256:fc063acaf7a3d9ca13146fefb5b42ac94ab943ec6e978f543cd5637da2d57957" ], "markers": "python_version >= '3.8'", - "version": "==5.28.0" + "version": "==5.28.1" }, "pyasn1": { "hashes": [ + "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034" ], "markers": "python_version >= '3.8'", @@ -5069,6 +5350,7 @@ }, "pyasn1-modules": { "hashes": [ + "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c" ], "markers": "python_version >= '3.8'", @@ -5252,88 +5534,103 @@ }, "regex": { "hashes": [ - "sha256:01b689e887f612610c869421241e075c02f2e3d1ae93a037cb14f88ab6a8934c", - "sha256:04ce29e2c5fedf296b1a1b0acc1724ba93a36fb14031f3abfb7abda2806c1535", - "sha256:0ffe3f9d430cd37d8fa5632ff6fb36d5b24818c5c986893063b4e5bdb84cdf24", - "sha256:18300a1d78cf1290fa583cd8b7cde26ecb73e9f5916690cf9d42de569c89b1ce", - "sha256:185e029368d6f89f36e526764cf12bf8d6f0e3a2a7737da625a76f594bdfcbfc", - "sha256:19c65b00d42804e3fbea9708f0937d157e53429a39b7c61253ff15670ff62cb5", - "sha256:228b0d3f567fafa0633aee87f08b9276c7062da9616931382993c03808bb68ce", - "sha256:23acc72f0f4e1a9e6e9843d6328177ae3074b4182167e34119ec7233dfeccf53", - "sha256:25419b70ba00a16abc90ee5fce061228206173231f004437730b67ac77323f0d", - "sha256:2dfbb8baf8ba2c2b9aa2807f44ed272f0913eeeba002478c4577b8d29cde215c", - "sha256:2f1baff13cc2521bea83ab2528e7a80cbe0ebb2c6f0bfad15be7da3aed443908", - "sha256:33e2614a7ce627f0cdf2ad104797d1f68342d967de3695678c0cb84f530709f8", - "sha256:3426de3b91d1bc73249042742f45c2148803c111d1175b283270177fdf669024", - "sha256:382281306e3adaaa7b8b9ebbb3ffb43358a7bbf585fa93821300a418bb975281", - "sha256:3d974d24edb231446f708c455fd08f94c41c1ff4f04bcf06e5f36df5ef50b95a", - "sha256:3f3b6ca8eae6d6c75a6cff525c8530c60e909a71a15e1b731723233331de4169", - "sha256:3fac296f99283ac232d8125be932c5cd7644084a30748fda013028c815ba3364", - "sha256:416c0e4f56308f34cdb18c3f59849479dde5b19febdcd6e6fa4d04b6c31c9faa", - "sha256:438d9f0f4bc64e8dea78274caa5af971ceff0f8771e1a2333620969936ba10be", - "sha256:43affe33137fcd679bdae93fb25924979517e011f9dea99163f80b82eadc7e53", - "sha256:44fc61b99035fd9b3b9453f1713234e5a7c92a04f3577252b45feefe1b327759", - "sha256:45104baae8b9f67569f0f1dca5e1f1ed77a54ae1cd8b0b07aba89272710db61e", - "sha256:4fdd1384619f406ad9037fe6b6eaa3de2749e2e12084abc80169e8e075377d3b", - "sha256:538d30cd96ed7d1416d3956f94d54e426a8daf7c14527f6e0d6d425fcb4cca52", - "sha256:558a57cfc32adcf19d3f791f62b5ff564922942e389e3cfdb538a23d65a6b610", - "sha256:5eefee9bfe23f6df09ffb6dfb23809f4d74a78acef004aa904dc7c88b9944b05", - "sha256:64bd50cf16bcc54b274e20235bf8edbb64184a30e1e53873ff8d444e7ac656b2", - "sha256:65fd3d2e228cae024c411c5ccdffae4c315271eee4a8b839291f84f796b34eca", - "sha256:66b4c0731a5c81921e938dcf1a88e978264e26e6ac4ec96a4d21ae0354581ae0", - "sha256:68a8f8c046c6466ac61a36b65bb2395c74451df2ffb8458492ef49900efed293", - "sha256:6a1141a1dcc32904c47f6846b040275c6e5de0bf73f17d7a409035d55b76f289", - "sha256:6b9fc7e9cc983e75e2518496ba1afc524227c163e43d706688a6bb9eca41617e", - "sha256:6f51f9556785e5a203713f5efd9c085b4a45aecd2a42573e2b5041881b588d1f", - "sha256:7214477bf9bd195894cf24005b1e7b496f46833337b5dedb7b2a6e33f66d962c", - "sha256:731fcd76bbdbf225e2eb85b7c38da9633ad3073822f5ab32379381e8c3c12e94", - "sha256:74007a5b25b7a678459f06559504f1eec2f0f17bca218c9d56f6a0a12bfffdad", - "sha256:7a5486ca56c8869070a966321d5ab416ff0f83f30e0e2da1ab48815c8d165d46", - "sha256:7c479f5ae937ec9985ecaf42e2e10631551d909f203e31308c12d703922742f9", - "sha256:7df9ea48641da022c2a3c9c641650cd09f0cd15e8908bf931ad538f5ca7919c9", - "sha256:7e37e809b9303ec3a179085415cb5f418ecf65ec98cdfe34f6a078b46ef823ee", - "sha256:80c811cfcb5c331237d9bad3bea2c391114588cf4131707e84d9493064d267f9", - "sha256:836d3cc225b3e8a943d0b02633fb2f28a66e281290302a79df0e1eaa984ff7c1", - "sha256:84c312cdf839e8b579f504afcd7b65f35d60b6285d892b19adea16355e8343c9", - "sha256:86b17ba823ea76256b1885652e3a141a99a5c4422f4a869189db328321b73799", - "sha256:871e3ab2838fbcb4e0865a6e01233975df3a15e6fce93b6f99d75cacbd9862d1", - "sha256:88ecc3afd7e776967fa16c80f974cb79399ee8dc6c96423321d6f7d4b881c92b", - "sha256:8bc593dcce679206b60a538c302d03c29b18e3d862609317cb560e18b66d10cf", - "sha256:8fd5afd101dcf86a270d254364e0e8dddedebe6bd1ab9d5f732f274fa00499a5", - "sha256:945352286a541406f99b2655c973852da7911b3f4264e010218bbc1cc73168f2", - "sha256:973335b1624859cb0e52f96062a28aa18f3a5fc77a96e4a3d6d76e29811a0e6e", - "sha256:994448ee01864501912abf2bad9203bffc34158e80fe8bfb5b031f4f8e16da51", - "sha256:9cfd009eed1a46b27c14039ad5bbc5e71b6367c5b2e6d5f5da0ea91600817506", - "sha256:a2ec4419a3fe6cf8a4795752596dfe0adb4aea40d3683a132bae9c30b81e8d73", - "sha256:a4997716674d36a82eab3e86f8fa77080a5d8d96a389a61ea1d0e3a94a582cf7", - "sha256:a512eed9dfd4117110b1881ba9a59b31433caed0c4101b361f768e7bcbaf93c5", - "sha256:a82465ebbc9b1c5c50738536fdfa7cab639a261a99b469c9d4c7dcbb2b3f1e57", - "sha256:ae2757ace61bc4061b69af19e4689fa4416e1a04840f33b441034202b5cd02d4", - "sha256:b16582783f44fbca6fcf46f61347340c787d7530d88b4d590a397a47583f31dd", - "sha256:ba2537ef2163db9e6ccdbeb6f6424282ae4dea43177402152c67ef869cf3978b", - "sha256:bf7a89eef64b5455835f5ed30254ec19bf41f7541cd94f266ab7cbd463f00c41", - "sha256:c0abb5e4e8ce71a61d9446040c1e86d4e6d23f9097275c5bd49ed978755ff0fe", - "sha256:c414cbda77dbf13c3bc88b073a1a9f375c7b0cb5e115e15d4b73ec3a2fbc6f59", - "sha256:c51edc3541e11fbe83f0c4d9412ef6c79f664a3745fab261457e84465ec9d5a8", - "sha256:c5e69fd3eb0b409432b537fe3c6f44ac089c458ab6b78dcec14478422879ec5f", - "sha256:c918b7a1e26b4ab40409820ddccc5d49871a82329640f5005f73572d5eaa9b5e", - "sha256:c9bb87fdf2ab2370f21e4d5636e5317775e5d51ff32ebff2cf389f71b9b13750", - "sha256:ca5b2028c2f7af4e13fb9fc29b28d0ce767c38c7facdf64f6c2cd040413055f1", - "sha256:d0a07763776188b4db4c9c7fb1b8c494049f84659bb387b71c73bbc07f189e96", - "sha256:d33a0021893ede5969876052796165bab6006559ab845fd7b515a30abdd990dc", - "sha256:d55588cba7553f0b6ec33130bc3e114b355570b45785cebdc9daed8c637dd440", - "sha256:dac8e84fff5d27420f3c1e879ce9929108e873667ec87e0c8eeb413a5311adfe", - "sha256:eaef80eac3b4cfbdd6de53c6e108b4c534c21ae055d1dbea2de6b3b8ff3def38", - "sha256:eb462f0e346fcf41a901a126b50f8781e9a474d3927930f3490f38a6e73b6950", - "sha256:eb563dd3aea54c797adf513eeec819c4213d7dbfc311874eb4fd28d10f2ff0f2", - "sha256:f273674b445bcb6e4409bf8d1be67bc4b58e8b46fd0d560055d515b8830063cd", - "sha256:f6442f0f0ff81775eaa5b05af8a0ffa1dda36e9cf6ec1e0d3d245e8564b684ce", - "sha256:fb168b5924bef397b5ba13aabd8cf5df7d3d93f10218d7b925e360d436863f66", - "sha256:fbf8c2f00904eaf63ff37718eb13acf8e178cb940520e47b2f05027f5bb34ce3", - "sha256:fe4ebef608553aff8deb845c7f4f1d0740ff76fa672c011cc0bacb2a00fbde86" + "sha256:01c2acb51f8a7d6494c8c5eafe3d8e06d76563d8a8a4643b37e9b2dd8a2ff623", + "sha256:02087ea0a03b4af1ed6ebab2c54d7118127fee8d71b26398e8e4b05b78963199", + "sha256:040562757795eeea356394a7fb13076ad4f99d3c62ab0f8bdfb21f99a1f85664", + "sha256:042c55879cfeb21a8adacc84ea347721d3d83a159da6acdf1116859e2427c43f", + "sha256:079400a8269544b955ffa9e31f186f01d96829110a3bf79dc338e9910f794fca", + "sha256:07f45f287469039ffc2c53caf6803cd506eb5f5f637f1d4acb37a738f71dd066", + "sha256:09d77559e80dcc9d24570da3745ab859a9cf91953062e4ab126ba9d5993688ca", + "sha256:0cbff728659ce4bbf4c30b2a1be040faafaa9eca6ecde40aaff86f7889f4ab39", + "sha256:0e12c481ad92d129c78f13a2a3662317e46ee7ef96c94fd332e1c29131875b7d", + "sha256:0ea51dcc0835eea2ea31d66456210a4e01a076d820e9039b04ae8d17ac11dee6", + "sha256:0ffbcf9221e04502fc35e54d1ce9567541979c3fdfb93d2c554f0ca583a19b35", + "sha256:1494fa8725c285a81d01dc8c06b55287a1ee5e0e382d8413adc0a9197aac6408", + "sha256:16e13a7929791ac1216afde26f712802e3df7bf0360b32e4914dca3ab8baeea5", + "sha256:18406efb2f5a0e57e3a5881cd9354c1512d3bb4f5c45d96d110a66114d84d23a", + "sha256:18e707ce6c92d7282dfce370cd205098384b8ee21544e7cb29b8aab955b66fa9", + "sha256:220e92a30b426daf23bb67a7962900ed4613589bab80382be09b48896d211e92", + "sha256:23b30c62d0f16827f2ae9f2bb87619bc4fba2044911e2e6c2eb1af0161cdb766", + "sha256:23f9985c8784e544d53fc2930fc1ac1a7319f5d5332d228437acc9f418f2f168", + "sha256:297f54910247508e6e5cae669f2bc308985c60540a4edd1c77203ef19bfa63ca", + "sha256:2b08fce89fbd45664d3df6ad93e554b6c16933ffa9d55cb7e01182baaf971508", + "sha256:2cce2449e5927a0bf084d346da6cd5eb016b2beca10d0013ab50e3c226ffc0df", + "sha256:313ea15e5ff2a8cbbad96ccef6be638393041b0a7863183c2d31e0c6116688cf", + "sha256:323c1f04be6b2968944d730e5c2091c8c89767903ecaa135203eec4565ed2b2b", + "sha256:35f4a6f96aa6cb3f2f7247027b07b15a374f0d5b912c0001418d1d55024d5cb4", + "sha256:3b37fa423beefa44919e009745ccbf353d8c981516e807995b2bd11c2c77d268", + "sha256:3ce4f1185db3fbde8ed8aa223fc9620f276c58de8b0d4f8cc86fd1360829edb6", + "sha256:46989629904bad940bbec2106528140a218b4a36bb3042d8406980be1941429c", + "sha256:4838e24ee015101d9f901988001038f7f0d90dc0c3b115541a1365fb439add62", + "sha256:49b0e06786ea663f933f3710a51e9385ce0cba0ea56b67107fd841a55d56a231", + "sha256:4db21ece84dfeefc5d8a3863f101995de646c6cb0536952c321a2650aa202c36", + "sha256:54c4a097b8bc5bb0dfc83ae498061d53ad7b5762e00f4adaa23bee22b012e6ba", + "sha256:54d9ff35d4515debf14bc27f1e3b38bfc453eff3220f5bce159642fa762fe5d4", + "sha256:55b96e7ce3a69a8449a66984c268062fbaa0d8ae437b285428e12797baefce7e", + "sha256:57fdd2e0b2694ce6fc2e5ccf189789c3e2962916fb38779d3e3521ff8fe7a822", + "sha256:587d4af3979376652010e400accc30404e6c16b7df574048ab1f581af82065e4", + "sha256:5b513b6997a0b2f10e4fd3a1313568e373926e8c252bd76c960f96fd039cd28d", + "sha256:5ddcd9a179c0a6fa8add279a4444015acddcd7f232a49071ae57fa6e278f1f71", + "sha256:6113c008a7780792efc80f9dfe10ba0cd043cbf8dc9a76ef757850f51b4edc50", + "sha256:635a1d96665f84b292e401c3d62775851aedc31d4f8784117b3c68c4fcd4118d", + "sha256:64ce2799bd75039b480cc0360907c4fb2f50022f030bf9e7a8705b636e408fad", + "sha256:69dee6a020693d12a3cf892aba4808fe168d2a4cef368eb9bf74f5398bfd4ee8", + "sha256:6a2644a93da36c784e546de579ec1806bfd2763ef47babc1b03d765fe560c9f8", + "sha256:6b41e1adc61fa347662b09398e31ad446afadff932a24807d3ceb955ed865cc8", + "sha256:6c188c307e8433bcb63dc1915022deb553b4203a70722fc542c363bf120a01fd", + "sha256:6edd623bae6a737f10ce853ea076f56f507fd7726bee96a41ee3d68d347e4d16", + "sha256:73d6d2f64f4d894c96626a75578b0bf7d9e56dcda8c3d037a2118fdfe9b1c664", + "sha256:7a22ccefd4db3f12b526eccb129390942fe874a3a9fdbdd24cf55773a1faab1a", + "sha256:7fb89ee5d106e4a7a51bce305ac4efb981536301895f7bdcf93ec92ae0d91c7f", + "sha256:846bc79ee753acf93aef4184c040d709940c9d001029ceb7b7a52747b80ed2dd", + "sha256:85ab7824093d8f10d44330fe1e6493f756f252d145323dd17ab6b48733ff6c0a", + "sha256:8dee5b4810a89447151999428fe096977346cf2f29f4d5e29609d2e19e0199c9", + "sha256:8e5fb5f77c8745a60105403a774fe2c1759b71d3e7b4ca237a5e67ad066c7199", + "sha256:98eeee2f2e63edae2181c886d7911ce502e1292794f4c5ee71e60e23e8d26b5d", + "sha256:9d4a76b96f398697fe01117093613166e6aa8195d63f1b4ec3f21ab637632963", + "sha256:9e8719792ca63c6b8340380352c24dcb8cd7ec49dae36e963742a275dfae6009", + "sha256:a0b2b80321c2ed3fcf0385ec9e51a12253c50f146fddb2abbb10f033fe3d049a", + "sha256:a4cc92bb6db56ab0c1cbd17294e14f5e9224f0cc6521167ef388332604e92679", + "sha256:a738b937d512b30bf75995c0159c0ddf9eec0775c9d72ac0202076c72f24aa96", + "sha256:a8f877c89719d759e52783f7fe6e1c67121076b87b40542966c02de5503ace42", + "sha256:a906ed5e47a0ce5f04b2c981af1c9acf9e8696066900bf03b9d7879a6f679fc8", + "sha256:ae2941333154baff9838e88aa71c1d84f4438189ecc6021a12c7573728b5838e", + "sha256:b0d0a6c64fcc4ef9c69bd5b3b3626cc3776520a1637d8abaa62b9edc147a58f7", + "sha256:b5b029322e6e7b94fff16cd120ab35a253236a5f99a79fb04fda7ae71ca20ae8", + "sha256:b7aaa315101c6567a9a45d2839322c51c8d6e81f67683d529512f5bcfb99c802", + "sha256:be1c8ed48c4c4065ecb19d882a0ce1afe0745dfad8ce48c49586b90a55f02366", + "sha256:c0256beda696edcf7d97ef16b2a33a8e5a875affd6fa6567b54f7c577b30a137", + "sha256:c157bb447303070f256e084668b702073db99bbb61d44f85d811025fcf38f784", + "sha256:c57d08ad67aba97af57a7263c2d9006d5c404d721c5f7542f077f109ec2a4a29", + "sha256:c69ada171c2d0e97a4b5aa78fbb835e0ffbb6b13fc5da968c09811346564f0d3", + "sha256:c94bb0a9f1db10a1d16c00880bdebd5f9faf267273b8f5bd1878126e0fbde771", + "sha256:cb130fccd1a37ed894824b8c046321540263013da72745d755f2d35114b81a60", + "sha256:ced479f601cd2f8ca1fd7b23925a7e0ad512a56d6e9476f79b8f381d9d37090a", + "sha256:d05ac6fa06959c4172eccd99a222e1fbf17b5670c4d596cb1e5cde99600674c4", + "sha256:d552c78411f60b1fdaafd117a1fca2f02e562e309223b9d44b7de8be451ec5e0", + "sha256:dd4490a33eb909ef5078ab20f5f000087afa2a4daa27b4c072ccb3cb3050ad84", + "sha256:df5cbb1fbc74a8305b6065d4ade43b993be03dbe0f8b30032cced0d7740994bd", + "sha256:e28f9faeb14b6f23ac55bfbbfd3643f5c7c18ede093977f1df249f73fd22c7b1", + "sha256:e464b467f1588e2c42d26814231edecbcfe77f5ac414d92cbf4e7b55b2c2a776", + "sha256:e4c22e1ac1f1ec1e09f72e6c44d8f2244173db7eb9629cc3a346a8d7ccc31142", + "sha256:e53b5fbab5d675aec9f0c501274c467c0f9a5d23696cfc94247e1fb56501ed89", + "sha256:e93f1c331ca8e86fe877a48ad64e77882c0c4da0097f2212873a69bbfea95d0c", + "sha256:e997fd30430c57138adc06bba4c7c2968fb13d101e57dd5bb9355bf8ce3fa7e8", + "sha256:e9a091b0550b3b0207784a7d6d0f1a00d1d1c8a11699c1a4d93db3fbefc3ad35", + "sha256:eab4bb380f15e189d1313195b062a6aa908f5bd687a0ceccd47c8211e9cf0d4a", + "sha256:eb1ae19e64c14c7ec1995f40bd932448713d3c73509e82d8cd7744dc00e29e86", + "sha256:ecea58b43a67b1b79805f1a0255730edaf5191ecef84dbc4cc85eb30bc8b63b9", + "sha256:ee439691d8c23e76f9802c42a95cfeebf9d47cf4ffd06f18489122dbb0a7ad64", + "sha256:eee9130eaad130649fd73e5cd92f60e55708952260ede70da64de420cdcad554", + "sha256:f47cd43a5bfa48f86925fe26fbdd0a488ff15b62468abb5d2a1e092a4fb10e85", + "sha256:f6fff13ef6b5f29221d6904aa816c34701462956aa72a77f1f151a8ec4f56aeb", + "sha256:f745ec09bc1b0bd15cfc73df6fa4f726dcc26bb16c23a03f9e3367d357eeedd0", + "sha256:f8404bf61298bb6f8224bb9176c1424548ee1181130818fcd2cbffddc768bed8", + "sha256:f9268774428ec173654985ce55fc6caf4c6d11ade0f6f914d48ef4719eb05ebb", + "sha256:faa3c142464efec496967359ca99696c896c591c56c53506bac1ad465f66e919" ], "markers": "python_version >= '3.8'", - "version": "==2024.7.24" + "version": "==2024.9.11" }, "requests": { "hashes": [ @@ -5362,11 +5659,11 @@ }, "setuptools": { "hashes": [ - "sha256:5f4c08aa4d3ebcb57a50c33b1b07e94315d7fc7230f7115e47fc99776c8ce308", - "sha256:95b40ed940a1c67eb70fc099094bd6e99c6ee7c23aa2306f4d2697ba7916f9c6" + "sha256:35ab7fd3bcd95e6b7fd704e4a1539513edad446c097797f2985e0e4b960772f2", + "sha256:d59a21b17a275fb872a9c3dae73963160ae079f1049ed956880cd7c09b120538" ], "markers": "python_version >= '3.8'", - "version": "==74.1.2" + "version": "==75.1.0" }, "six": { "hashes": [ @@ -5385,11 +5682,11 @@ }, "urllib3": { "hashes": [ - "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", - "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" ], "markers": "python_version >= '3.8'", - "version": "==2.2.2" + "version": "==2.2.3" }, "virtualenv": { "hashes": [ diff --git a/breathecode/admissions/serializers.py b/breathecode/admissions/serializers.py index 9d82b16cf..575d7fc0e 100644 --- a/breathecode/admissions/serializers.py +++ b/breathecode/admissions/serializers.py @@ -999,7 +999,7 @@ def validate(self, data: OrderedDict): mandatory_slugs.append(assignment["slug"]) has_tasks = ( - Task.objects.filter(associated_slug__in=mandatory_slugs) + Task.objects.filter(associated_slug__in=mandatory_slugs, user_id=user.id, cohort__id=cohort.id) .exclude(revision_status__in=["APPROVED", "IGNORED"]) .count() ) diff --git a/breathecode/asgi.py b/breathecode/asgi.py index b9bc862b6..af048cb0e 100644 --- a/breathecode/asgi.py +++ b/breathecode/asgi.py @@ -7,11 +7,21 @@ https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ """ -# the rest of your ASGI file contents go here import os +from channels.routing import ProtocolTypeRouter from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "breathecode.settings") -application = get_asgi_application() +http_application = get_asgi_application() + +from .websocket.router import routes + +application = ProtocolTypeRouter( + { + "http": http_application, + "websocket": routes, + # Just HTTP for now. (We can add other protocols later.) + } +) diff --git a/breathecode/assignments/views.py b/breathecode/assignments/views.py index 706fa720e..a04e4a210 100644 --- a/breathecode/assignments/views.py +++ b/breathecode/assignments/views.py @@ -4,6 +4,7 @@ from adrf.views import APIView from asgiref.sync import sync_to_async +from capyc.rest_framework.exceptions import ValidationException from circuitbreaker import CircuitBreakerError from django.contrib import messages from django.db.models import Q @@ -31,7 +32,6 @@ from breathecode.utils.decorators.capable_of import acapable_of from breathecode.utils.i18n import translation from breathecode.utils.multi_status_response import MultiStatusResponse -from capyc.rest_framework.exceptions import ValidationException from .actions import deliver_task, sync_cohort_tasks from .caches import TaskCache @@ -62,6 +62,8 @@ "application/pdf", "image/jpg", "application/octet-stream", + "application/json", + "text/plain", ] IMAGES_MIME_ALLOW = ["image/png", "image/svg+xml", "image/jpeg", "image/jpg"] diff --git a/breathecode/marketing/actions.py b/breathecode/marketing/actions.py index bd0c4bcb6..87cd8e78f 100644 --- a/breathecode/marketing/actions.py +++ b/breathecode/marketing/actions.py @@ -6,6 +6,7 @@ import numpy as np import requests +from capyc.rest_framework.exceptions import ValidationException from django.db.models import Q from django.utils import timezone from rest_framework.exceptions import APIException @@ -14,9 +15,9 @@ from breathecode.authenticate.models import CredentialsFacebook from breathecode.notify.actions import send_email_message from breathecode.services.activecampaign import ACOldClient, ActiveCampaign, ActiveCampaignClient, acp_ids, map_ids +from breathecode.services.brevo import Brevo from breathecode.utils import getLogger from breathecode.utils.i18n import translation -from capyc.rest_framework.exceptions import ValidationException from .models import AcademyAlias, ActiveCampaignAcademy, Automation, FormEntry, Tag @@ -152,12 +153,16 @@ def validate_email(email, lang): return email_status -def set_optional(contact, key, data, custom_key=None): +def set_optional(contact, key, data, custom_key=None, crm_vendor="ACTIVE_CAMPAIGN"): if custom_key is None: custom_key = key - if custom_key in data: - contact["field[" + acp_ids[key] + ",0]"] = data[custom_key] + if crm_vendor == "ACTIVE_CAMPAIGN": + if custom_key in data: + contact["field[" + acp_ids[key] + ",0]"] = data[custom_key] + else: + if custom_key in data: + contact[key] = data[custom_key] return contact @@ -177,7 +182,7 @@ def get_lead_tags(ac_academy, form_entry): tags = list(chain(strong_tags, soft_tags, dicovery_tags, other_tags)) if len(tags) != len(_tags): - message = "Some tag applied to the contact not found or have tag_type different than [STRONG, SOFT, DISCOVER, OTHER]: " + message = f"Some tag applied to the contact not found or have tag_type different than [STRONG, SOFT, DISCOVER, OTHER] for this academy {ac_academy.academy.name}. " message += f'Check for the follow tags: {",".join(_tags)}' raise Exception(message) @@ -198,7 +203,7 @@ def get_lead_automations(ac_academy, form_entry): raise Exception(f"The specified automation {_name} was not found for this AC Academy") logger.debug(f"found {str(count)} automations") - return automations.values_list("acp_id", flat=True) + return automations def add_to_active_campaign(contact, academy_id: int, automation_id: int): @@ -250,7 +255,7 @@ def add_to_active_campaign(contact, academy_id: int, automation_id: int): logger.error(f"error triggering automation with id {str(acp_id)}", response) raise APIException("Could not add contact to Automation") - logger.info(f"Triggered automation with id {str(acp_id)}", response) + logger.debug(f"Triggered automation with id {str(acp_id)}", response) def register_new_lead(form_entry=None): @@ -275,7 +280,9 @@ def register_new_lead(form_entry=None): ac_academy = ActiveCampaignAcademy.objects.filter(academy__slug=form_entry["location"]).first() if ac_academy is None: - raise RetryTask(f"No academy found with slug {form_entry['location']}") + raise RetryTask( + f"No CRM vendor information for academy with slug {form_entry['location']}. Is Active Campaign or Brevo used?" + ) automations = get_lead_automations(ac_academy, form_entry) @@ -285,13 +292,26 @@ def register_new_lead(form_entry=None): else: logger.info("automations not found") - tags = get_lead_tags(ac_academy, form_entry) - logger.info("found tags") - logger.info(set(t.slug for t in tags)) + # Tags are only for ACTIVE CAMPAIGN + tags = [] + if ac_academy.crm_vendor == "BREVO": + # brevo uses slugs instead of ID for automations + automations = automations.values_list("slug", flat=True) + if "tags" in form_entry and len(form_entry["tags"]) > 0: + raise Exception("Brevo CRM does not support tags, please remove them from the contact payload") + else: + if hasattr(automations, "values_list"): + automations = automations.values_list("acp_id", flat=True) + + tags = get_lead_tags(ac_academy, form_entry) + logger.info("found tags") + logger.info(set(t.slug for t in tags)) if (automations is None or len(automations) == 0) and len(tags) > 0: if tags[0].automation is None: - raise ValidationException("No automation was specified and the the specified tag has no automation either") + raise ValidationException( + "No automation was specified and the specified tag (if any) has no automation either" + ) automations = [tags[0].automation.acp_id] @@ -326,30 +346,33 @@ def register_new_lead(form_entry=None): "phone": form_entry["phone"], } - contact = set_optional(contact, "utm_url", form_entry) - contact = set_optional(contact, "utm_location", form_entry, "location") - contact = set_optional(contact, "course", form_entry) - contact = set_optional(contact, "utm_language", form_entry, "language") - contact = set_optional(contact, "utm_country", form_entry, "country") - contact = set_optional(contact, "utm_campaign", form_entry) - contact = set_optional(contact, "utm_source", form_entry) - contact = set_optional(contact, "utm_content", form_entry) - contact = set_optional(contact, "utm_medium", form_entry) - contact = set_optional(contact, "utm_plan", form_entry) - contact = set_optional(contact, "utm_placement", form_entry) - contact = set_optional(contact, "utm_term", form_entry) - contact = set_optional(contact, "gender", form_entry, "sex") - contact = set_optional(contact, "client_comments", form_entry) - contact = set_optional(contact, "gclid", form_entry) - contact = set_optional(contact, "current_download", form_entry) - contact = set_optional(contact, "referral_key", form_entry) + contact = set_optional(contact, "utm_url", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_location", form_entry, "location", crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "course", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_language", form_entry, "language", crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_country", form_entry, "country", crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_campaign", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_source", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_content", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_medium", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_plan", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_placement", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_term", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "gender", form_entry, "sex", crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "client_comments", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "gclid", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "current_download", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "referral_key", form_entry, crm_vendor=ac_academy.crm_vendor) + + # only for brevo + if ac_academy.crm_vendor == "BREVO": + contact = set_optional(contact, "utm_landing", form_entry, crm_vendor=ac_academy.crm_vendor) entry = FormEntry.objects.filter(id=form_entry["id"]).first() - if not entry: raise ValidationException("FormEntry not found (id: " + str(form_entry["id"]) + ")") - if "contact-us" == tags[0].slug: + if len(tags) > 0 and "contact-us" == tags[0].slug: obj = {} if ac_academy.academy: @@ -370,37 +393,54 @@ def register_new_lead(form_entry=None): ) is_duplicate = entry.is_duplicate(form_entry) - # ENV Variable to fake lead storage + if is_duplicate: + entry.storage_status = "DUPLICATED" + entry.save() + logger.info("FormEntry is considered a duplicate, not sent to CRM and no automations or tags added") + return entry + # ENV Variable to fake lead storage if get_save_leads() == "FALSE": - entry.storage_status_text = "Saved but not send to AC because SAVE_LEADS is FALSE" + entry.storage_status_text = "Saved but not send to CRM because SAVE_LEADS is FALSE" entry.storage_status = "PERSISTED" if not is_duplicate else "DUPLICATED" entry.save() return entry - logger.info("ready to send contact with following details: " + str(contact)) + if ac_academy.crm_vendor == "ACTIVE_CAMPAIGN": + entry = send_to_active_campaign(entry, ac_academy, contact, automations, tags) + elif ac_academy.crm_vendor == "BREVO": + entry = send_to_brevo(entry, ac_academy, contact, automations) + + if entry.storage_status in ["ERROR"]: + return entry + + entry.storage_status = "PERSISTED" + entry.save() + + form_entry["storage_status"] = "PERSISTED" + + return entry + + +def send_to_active_campaign(form_entry, ac_academy, contact, automations, tags): + old_client = ACOldClient(ac_academy.ac_url, ac_academy.ac_key) response = old_client.contacts.create_contact(contact) contact_id = response["subscriber_id"] # save contact_id from active campaign - entry.ac_contact_id = contact_id - entry.save() + form_entry.ac_contact_id = contact_id + form_entry.save() if "subscriber_id" not in response: logger.error("error adding contact", response) - entry.storage_status = "ERROR" - entry.storage_status_text = "Could not save contact in CRM: Subscriber_id not found" - entry.save() - - if is_duplicate: - entry.storage_status = "DUPLICATED" - entry.save() - logger.info("FormEntry is considered a duplicate, no automations or tags added") - return entry + form_entry.storage_status = "ERROR" + form_entry.storage_status_text = "Could not save contact in CRM: Subscriber_id not found" + form_entry.save() + return form_entry client = ActiveCampaignClient(ac_academy.ac_url, ac_academy.ac_key) - if automations and not is_duplicate: + if automations: for automation_id in automations: data = {"contactAutomation": {"contact": contact_id, "automation": automation_id}} response = client.contacts.add_a_contact_to_an_automation(data) @@ -408,22 +448,36 @@ def register_new_lead(form_entry=None): if "contacts" not in response: logger.error(f"error triggering automation with id {str(automation_id)}", response) raise APIException("Could not add contact to Automation") - logger.info(f"Triggered automation with id {str(automation_id)} " + str(response)) + logger.debug(f"Triggered automation with id {str(automation_id)} " + str(response)) logger.info("automations was executed successfully") - if tags and not is_duplicate: + if tags: for t in tags: data = {"contactTag": {"contact": contact_id, "tag": t.acp_id}} response = client.contacts.add_a_tag_to_contact(data) logger.info("contact was tagged successfully") - entry.storage_status = "PERSISTED" - entry.save() + return form_entry - form_entry["storage_status"] = "PERSISTED" - return entry +def send_to_brevo(form_entry, ac_academy, contact, automations): + + if automations.count() > 1: + raise Exception("Only one automation at a time is allowed for Brevo") + + _a = automations.first() + + brevo_client = Brevo(ac_academy.ac_key) + response = brevo_client.create_contact(contact, _a) + + # Brevo does not answer with the contact ID when the create_contact + # is being made thru triggering a brevo event + if response and "id" in response: + form_entry.ac_contact_id = response["id"] + form_entry.save() + + return form_entry def test_ac_connection(ac_academy): @@ -487,6 +541,9 @@ def update_deal_custom_fields(formentry_id: int): def sync_tags(ac_academy): + if ac_academy.crm_vendor == "BREVO": + raise Exception("Sync method has not been implemented for Brevo Tags") + client = ActiveCampaignClient(ac_academy.ac_url, ac_academy.ac_key) response = client.tags.list_all_tags(limit=100) @@ -518,6 +575,9 @@ def sync_tags(ac_academy): def sync_automations(ac_academy): + if ac_academy.crm_vendor == "BREVO": + raise Exception("Sync method has not been implemented for Brevo Automations") + client = ActiveCampaignClient(ac_academy.ac_url, ac_academy.ac_key) response = client.automations.list_all_automations(limit=100) diff --git a/breathecode/marketing/admin.py b/breathecode/marketing/admin.py index cef151573..53cb77200 100644 --- a/breathecode/marketing/admin.py +++ b/breathecode/marketing/admin.py @@ -1,6 +1,7 @@ import logging import secrets +from capyc.rest_framework.exceptions import ValidationException from django import forms from django.contrib import admin, messages from django.contrib.admin import SimpleListFilter @@ -10,7 +11,6 @@ from breathecode.services.activecampaign import ActiveCampaign from breathecode.utils import AdminExportCsvMixin from breathecode.utils.admin import change_field -from capyc.rest_framework.exceptions import ValidationException from .actions import ( bind_formentry_with_webhook, @@ -104,7 +104,7 @@ def __init__(self, *args, **kwargs): class ACAcademyAdmin(admin.ModelAdmin, AdminExportCsvMixin): form = CustomForm search_fields = ["academy__name", "academy__slug"] - list_display = ("id", "academy", "ac_url", "sync_status", "last_interaction_at", "sync_message") + list_display = ("id", "academy", "crm_vendor", "ac_url", "sync_status", "last_interaction_at", "sync_message") list_filter = ["academy__slug", "sync_status"] actions = [test_ac, sync_ac_tags, sync_ac_automations] @@ -255,11 +255,12 @@ class FormEntryAdmin(admin.ModelAdmin, AdminExportCsvMixin): "buenosaires-argentina", "caracas-venezuela", "online", + "4geeks-com", ], name="location", ) + change_field(["full-stack", "datascience-ml", "cybersecurity"], name="course") - + change_field(["REJECTED", "DUPLICATED", "ERROR"], name="storage_status") + + change_field(["REJECTED", "DUPLICATED", "ERROR", "MANUALLY_PERSISTED"], name="storage_status") ) def _attribution_id(self, obj): diff --git a/breathecode/marketing/migrations/0087_alter_formentry_storage_status.py b/breathecode/marketing/migrations/0087_alter_formentry_storage_status.py new file mode 100644 index 000000000..4b5a5597a --- /dev/null +++ b/breathecode/marketing/migrations/0087_alter_formentry_storage_status.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.1 on 2024-09-24 16:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("marketing", "0086_coursetranslation_landing_variables"), + ] + + operations = [ + migrations.AlterField( + model_name="formentry", + name="storage_status", + field=models.CharField( + choices=[ + ("PENDING", "Pending"), + ("PERSISTED", "Persisted"), + ("REJECTED", "Rejected"), + ("DUPLICATED", "Duplicated"), + ("ERROR", "Error"), + ], + default="PENDING", + help_text="MANUALLY_PERSISTED means it was copy pasted into active campaign", + max_length=15, + ), + ), + ] diff --git a/breathecode/marketing/migrations/0088_alter_formentry_storage_status.py b/breathecode/marketing/migrations/0088_alter_formentry_storage_status.py new file mode 100644 index 000000000..99d2a3cce --- /dev/null +++ b/breathecode/marketing/migrations/0088_alter_formentry_storage_status.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.1 on 2024-09-24 21:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("marketing", "0087_alter_formentry_storage_status"), + ] + + operations = [ + migrations.AlterField( + model_name="formentry", + name="storage_status", + field=models.CharField( + choices=[ + ("PENDING", "Pending"), + ("PERSISTED", "Persisted"), + ("REJECTED", "Rejected"), + ("DUPLICATED", "Duplicated"), + ("ERROR", "Error"), + ], + default="PENDING", + help_text="MANUALLY_PERSISTED means it was copy pasted into active campaign", + max_length=20, + ), + ), + ] diff --git a/breathecode/marketing/migrations/0089_activecampaignacademy_crm_vendor.py b/breathecode/marketing/migrations/0089_activecampaignacademy_crm_vendor.py new file mode 100644 index 000000000..9e6bce32c --- /dev/null +++ b/breathecode/marketing/migrations/0089_activecampaignacademy_crm_vendor.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.1 on 2024-09-25 18:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("marketing", "0088_alter_formentry_storage_status"), + ] + + operations = [ + migrations.AddField( + model_name="activecampaignacademy", + name="crm_vendor", + field=models.CharField( + choices=[("ACTIVE_CAMPAIGN", "Active Campaign"), ("BREVO", "Brevo")], + default="ACTIVE_CAMPAIGN", + help_text="Only one vendor allowed per academy, defaults to active campaign", + max_length=20, + ), + ), + ] diff --git a/breathecode/marketing/models.py b/breathecode/marketing/models.py index 571614323..66bfa3ae7 100644 --- a/breathecode/marketing/models.py +++ b/breathecode/marketing/models.py @@ -38,6 +38,13 @@ class Meta: (COMPLETED, "Completed"), ) +ACTIVE_CAMPAIGN = "ACTIVE_CAMPAIGN" +BREVO = "BREVO" +CRM_VENDORS = ( + (ACTIVE_CAMPAIGN, "Active Campaign"), + (BREVO, "Brevo"), +) + class ActiveCampaignAcademy(models.Model): ac_key = models.CharField(max_length=150) @@ -48,6 +55,13 @@ class ActiveCampaignAcademy(models.Model): academy = models.OneToOneField(Academy, on_delete=models.CASCADE) + crm_vendor = models.CharField( + max_length=20, + choices=CRM_VENDORS, + default=ACTIVE_CAMPAIGN, + help_text="Only one vendor allowed per academy, defaults to active campaign", + ) + duplicate_leads_delta_avoidance = models.DurationField( default=timedelta(minutes=30), help_text="Leads that apply to the same course on this timedelta will not be sent to AC", @@ -283,6 +297,7 @@ def save(self, *args, **kwargs): DUPLICATED = "DUPLICATED" REJECTED = "REJECTED" ERROR = "ERROR" +MANUAL = "MANUALLY_PERSISTED" STORAGE_STATUS = ( (PENDING, "Pending"), (PERSISTED, "Persisted"), @@ -402,7 +417,12 @@ def __init__(self, *args, **kwargs): sex = models.CharField(max_length=15, null=True, default=None, blank=True, help_text="M=male,F=female,O=other") # is it saved into active campaign? - storage_status = models.CharField(max_length=15, choices=STORAGE_STATUS, default=PENDING) + storage_status = models.CharField( + max_length=20, + choices=STORAGE_STATUS, + default=PENDING, + help_text="MANUALLY_PERSISTED means it was copy pasted into active campaign", + ) storage_status_text = models.CharField( default="", blank=True, diff --git a/breathecode/marketing/views.py b/breathecode/marketing/views.py index dbbbff317..4f153596f 100644 --- a/breathecode/marketing/views.py +++ b/breathecode/marketing/views.py @@ -7,9 +7,10 @@ import re from datetime import timedelta from urllib import parse -from slugify import slugify + import pandas as pd import pytz +from capyc.rest_framework.exceptions import ValidationException from circuitbreaker import CircuitBreakerError from django.contrib.auth.models import AnonymousUser from django.db.models import CharField, Count, F, Func, Q, Value @@ -23,6 +24,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_csv.renderers import CSVRenderer +from slugify import slugify import breathecode.marketing.tasks as tasks from breathecode.admissions.models import Academy @@ -36,7 +38,6 @@ from breathecode.utils.decorators import validate_captcha, validate_captcha_challenge from breathecode.utils.find_by_full_name import query_like_by_full_name from breathecode.utils.i18n import translation -from capyc.rest_framework.exceptions import ValidationException from .actions import convert_data_frame, sync_automations, sync_tags, validate_email from .models import ( diff --git a/breathecode/media/settings.py b/breathecode/media/settings.py index 1a5da6f4d..fb1d946d0 100644 --- a/breathecode/media/settings.py +++ b/breathecode/media/settings.py @@ -1,13 +1,17 @@ import os from io import BytesIO -from typing import Any, Awaitable, Callable, Optional, Type, TypedDict +from typing import Any, Awaitable, Callable, Literal, Optional, Type, TypedDict from adrf.requests import AsyncRequest +from capyc.rest_framework.exceptions import ValidationException from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile +from PIL import Image +from breathecode.authenticate.actions import get_user_settings from breathecode.media.models import Chunk, File +from breathecode.notify.models import Notification from breathecode.services.google_cloud.storage import Storage -from capyc.rest_framework.exceptions import ValidationException +from breathecode.utils.i18n import translation type TypeValidator = Callable[[str, Any], None] type TypeValidatorWrapper = Callable[[Type[Any]], TypeValidator] @@ -22,7 +26,7 @@ class MediaSettings(TypedDict): is_mime_supported: Callable[[InMemoryUploadedFile | TemporaryUploadedFile, Optional[int]], Awaitable[bool]] get_schema = Optional[Callable[[AsyncRequest, Optional[int]], Awaitable[Schema]]] # this callback is sync because it'll be called within celery what doesn't support async operations properly - process = Optional[Callable[[File, dict[str, Any], Optional[int]], None]] + process = Optional[Callable[[File, dict[str, Any], Optional[int]], tuple[Literal["INFO", "WARNING", "ERROR"], str]]] MEDIA_MIME_ALLOWED = [ @@ -52,11 +56,30 @@ class MediaSettings(TypedDict): "image/jpeg", ] +EXT_MAP = { + # 'mime': 'format', + "image/gif": "gif", + "image/x-icon": "ico", + "image/jpeg": "jpeg", + # 'image/svg+xml': 'svg', not have sense resize a svg + # 'image/tiff': 'tiff', don't work + "image/webp": "webp", + "image/png": "png", +} + async def allow_any(request: AsyncRequest, academy_id: Optional[int] = None) -> bool: return True +async def dont_allow_users(request: AsyncRequest, academy_id: Optional[int] = None) -> bool: + return academy_id is not None + + +async def dont_allow_academies(request: AsyncRequest, academy_id: Optional[int] = None) -> bool: + return academy_id is None + + async def no_quota_limit(request: AsyncRequest, academy_id: Optional[int] = None) -> bool: return False @@ -134,7 +157,28 @@ def del_temp_file(file: File | Chunk): uploaded_file.delete() -def process_media(file: File) -> None: +def get_file(file: File) -> BytesIO: + storage = Storage() + uploaded_file = storage.file(file.bucket, file.file_name) + if uploaded_file.exists() is False: + raise Exception("File does not exists") + + f = BytesIO() + uploaded_file.download(f) + return f + + +def save_file(f: BytesIO, bucket: str, name: str, mime: str) -> str: + storage = Storage() + file = storage.file(bucket, name) + if file.exists() is True: + return file.url() + + file.upload(f, content_type=mime) + return file.url() + + +def process_media(file: File) -> tuple[Literal["INFO", "WARNING", "ERROR"], str]: from .models import Category, Media academy_id = file.academy.id if file.academy else None @@ -142,7 +186,7 @@ def process_media(file: File) -> None: if Media.objects.filter(hash=file.hash, academy__id=academy_id).exists(): del_temp_file(file) - return + return Notification.info("Media already exists") if url := Media.objects.filter(hash=file.hash).values_list("url", flat=True).first(): del_temp_file(file) @@ -162,17 +206,50 @@ def process_media(file: File) -> None: categories = Category.objects.filter(slug__in=meta["categories"]) media.categories.set(categories) + return Notification.info("Media processed") + + +def process_profile(file: File) -> tuple[Literal["INFO", "WARNING", "ERROR"], str]: + from breathecode.authenticate.models import Profile + + f = get_file(file) + image = Image.open(f) + width, height = image.size + + user = file.user + settings = get_user_settings(user.id) + lang = settings.lang + if width != height: + return Notification.error( + translation(lang, en="Profile picture must be square", es="La foto de perfil debe ser cuadrada") + ) -# disabled -# def process_profile(file: File, meta: dict[str, Any]) -> None: -# url = transfer(file, os.getenv("MEDIA_GALLERY_BUCKET")) -# storage = Storage() -# cloud_file = storage.file(get_profile_bucket(), hash) -# cloud_file_thumbnail = storage.file(get_profile_bucket(), f"{hash}-100x100") + size = 120 + image.resize((size, size)) -# if thumb_exists := cloud_file_thumbnail.exists(): -# cloud_file_thumbnail_url = cloud_file_thumbnail.url() + resized_image = BytesIO() + ext = EXT_MAP[file.mime] + image.save(resized_image, format=ext) + f.close() + name = f"{file.file_name}-{size}x{size}" + + url = save_file(resized_image, os.getenv("PROFILE_BUCKET"), name, file.mime) + resized_image.close() + + profile = Profile.objects.filter(user=user).first() + if profile and profile.avatar_url == url: + return Notification.info( + translation(lang, en="You uploaded the same profile picture", es="Subiste la misma foto de perfil") + ) + + elif profile is None: + profile = Profile(user=user) + + profile.avatar_url = url + profile.save() + + return Notification.info(translation(lang, en="Profile picture was updated", es="Foto de perfil fue actualizada")) MB = 1024 * 1024 @@ -184,7 +261,7 @@ def process_media(file: File) -> None: "chunk_size": CHUNK_SIZE, "max_chunks": None, "is_quota_exceeded": no_quota_limit, - "is_authorized": allow_any, + "is_authorized": dont_allow_users, "is_mime_supported": media_is_mime_supported, "get_schema": media_schema, "process": process_media, @@ -193,19 +270,18 @@ def process_media(file: File) -> None: "chunk_size": CHUNK_SIZE, "max_chunks": None, "is_quota_exceeded": no_quota_limit, - "is_authorized": allow_any, + "is_authorized": dont_allow_users, "is_mime_supported": proof_of_payment_is_mime_supported, "get_schema": None, "process": None, }, - # disabled - # "profile-pictures": { - # "chunk_size": CHUNK_SIZE, - # "max_chunks": 25, # because currently it accepts 4K photos - # "is_quota_exceeded": no_quota_limit, # change it in a future - # "is_authorized": allow_any, - # "is_mime_supported": profile_is_mime_supported, - # "schema": no_schema, - # "process": process_profile, - # }, + "profile-picture": { + "chunk_size": CHUNK_SIZE, + "max_chunks": 25, # because currently it accepts 4K photos + "is_quota_exceeded": no_quota_limit, # change it in a future + "is_authorized": dont_allow_academies, + "is_mime_supported": profile_is_mime_supported, + "get_schema": None, + "process": process_profile, + }, } diff --git a/breathecode/media/tasks.py b/breathecode/media/tasks.py index 2a14d03b3..4b7c30400 100644 --- a/breathecode/media/tasks.py +++ b/breathecode/media/tasks.py @@ -1,11 +1,14 @@ import logging -from typing import Any +from typing import Any, Optional from django.core.cache import cache from task_manager.core.exceptions import AbortTask, RetryTask from task_manager.django.decorators import task +from breathecode.authenticate.actions import get_user_settings +from breathecode.notify.models import Notification from breathecode.utils.decorators import TaskPriority +from breathecode.utils.i18n import translation from .models import File from .utils import media_settings @@ -15,7 +18,7 @@ @task(bind=False, priority=TaskPriority.STUDENT.value) -def process_file(file_id: int, **_: Any): +def process_file(file_id: int, notification_id: Optional[int] = None, **_: Any): """Renew consumables belongs to a subscription.""" logger.info(f"Starting process_file for id {file_id}") @@ -43,13 +46,26 @@ def process_file(file_id: int, **_: Any): file.save() raise AbortTask(message) + notification = None + if notification_id: + notification = Notification.objects.filter(id=notification_id).first() + try: - process(file) + msg = process(file) file.status = File.Status.TRANSFERRED file.save() + if notification: + notification.send(msg, notification) except Exception as e: message = f"Error processing file {file_id}: {str(e)}" + + if notification: + settings = get_user_settings(file.user.id) + lang = settings.lang + msg = Notification.error(translation(lang, en="Error processing file", es="Error procesando el archivo")) + notification.send(msg, notification) + file.status = File.Status.ERROR file.status_message = message file.save() diff --git a/breathecode/media/tests/settings/tests_get_file.py b/breathecode/media/tests/settings/tests_get_file.py new file mode 100644 index 000000000..38e667655 --- /dev/null +++ b/breathecode/media/tests/settings/tests_get_file.py @@ -0,0 +1,82 @@ +""" +Test /answer +""" + +from io import BytesIO +from unittest.mock import MagicMock, PropertyMock, call + +import capyc.pytest as capy +import pytest + +from breathecode.media.settings import get_file +from breathecode.services.google_cloud import File, Storage + + +@pytest.fixture(autouse=True) +def setup(db, monkeypatch: pytest.MonkeyPatch): + def download(f: BytesIO): + f.write(b"123") + f.seek(0) + + monkeypatch.setattr("breathecode.services.google_cloud.Storage.__init__", MagicMock(return_value=None)) + monkeypatch.setattr("breathecode.services.google_cloud.Storage.client", PropertyMock(), raising=False) + monkeypatch.setattr("breathecode.services.google_cloud.File.download", MagicMock(side_effect=download)) + + +def test_file_does_not_exists(monkeypatch: pytest.MonkeyPatch, database: capy.Database, fake: capy.Fake): + monkeypatch.setattr("breathecode.services.google_cloud.File.exists", MagicMock(return_value=False)) + + meta = { + "slug": fake.slug(), + "name": fake.name(), + } + categories = [{"slug": fake.slug()} for _ in range(2)] + model = database.create( + file={ + "meta": { + **meta, + "categories": [x["slug"] for x in categories], + } + }, + academy=2, + city=1, + country=1, + ) + + with pytest.raises(Exception, match="File does not exists"): + get_file(model.file) + + assert Storage.__init__.call_args_list == [call()] + assert File.exists.call_args_list == [call()] + assert File.download.call_args_list == [] + + +def test_file_exists(monkeypatch: pytest.MonkeyPatch, database: capy.Database, fake: capy.Fake): + monkeypatch.setattr("breathecode.services.google_cloud.File.exists", MagicMock(return_value=True)) + + meta = { + "slug": fake.slug(), + "name": fake.name(), + } + categories = [{"slug": fake.slug()} for _ in range(2)] + model = database.create( + file={ + "meta": { + **meta, + "categories": [x["slug"] for x in categories], + } + }, + academy=2, + city=1, + country=1, + ) + + res = get_file(model.file) + + assert res.read() == b"123" + assert Storage.__init__.call_args_list == [call()] + assert File.exists.call_args_list == [call()] + assert File.download.call_count == 1 + + for x in File.download.call_args_list: + assert call(type(x[0][0]), *[0][1:], **x[1]) == call(BytesIO) diff --git a/breathecode/media/tests/settings/tests_process_media.py b/breathecode/media/tests/settings/tests_process_media.py index d8f80f8a8..08c98fd2f 100644 --- a/breathecode/media/tests/settings/tests_process_media.py +++ b/breathecode/media/tests/settings/tests_process_media.py @@ -9,6 +9,7 @@ from breathecode.media import settings from breathecode.media.settings import MEDIA_SETTINGS, process_media +from breathecode.notify.models import Notification @pytest.fixture(autouse=True) @@ -42,8 +43,9 @@ def test_no_media(database: capy.Database, url: str, fake: capy.Fake): country=1, ) - process_media(model.file) + res = process_media(model.file) + assert res == Notification.info("Media processed") assert settings.transfer.call_args_list == [call(model.file, "galery-bucket")] assert settings.del_temp_file.call_args_list == [] assert database.list_of("media.Media") == [ @@ -82,8 +84,9 @@ def test_media__same_academy(database: capy.Database, format: capy.Format, query category=categories, ) - process_media(model.file) + res = process_media(model.file) + assert res == Notification.info("Media already exists") assert settings.transfer.call_args_list == [] assert settings.del_temp_file.call_args_list == [call(model.file)] assert database.list_of("media.Media") == [format.to_obj_repr(model.media)] @@ -116,8 +119,9 @@ def test_media__other_academy(database: capy.Database, format: capy.Format, quer category=categories, ) - process_media(model.file) + res = process_media(model.file) + assert res == Notification.info("Media processed") assert settings.transfer.call_args_list == [] assert settings.del_temp_file.call_args_list == [call(model.file)] assert database.list_of("media.Media") == [ diff --git a/breathecode/media/tests/settings/tests_process_profile.py b/breathecode/media/tests/settings/tests_process_profile.py new file mode 100644 index 000000000..c2d94768e --- /dev/null +++ b/breathecode/media/tests/settings/tests_process_profile.py @@ -0,0 +1,163 @@ +""" +Test /answer +""" + +from io import BytesIO +from unittest.mock import MagicMock, call + +import capyc.pytest as capy +import pytest + +from breathecode.media import settings +from breathecode.media.settings import MEDIA_SETTINGS, process_profile +from breathecode.notify.models import Notification + + +@pytest.fixture(autouse=True) +def url(db, monkeypatch: pytest.MonkeyPatch, fake: capy.Fake, image: capy.Image): + url = fake.url() + monkeypatch.setenv("PROFILE_BUCKET", "profile-bucket") + monkeypatch.setattr("breathecode.media.settings.save_file", MagicMock(return_value=url)) + yield url + + +def test_is_profile_process(): + assert MEDIA_SETTINGS["profile-picture"]["process"] is process_profile + + +@pytest.mark.parametrize("width, height", [(10, 20), (20, 10)]) +def test_no_square_image( + database: capy.Database, + image: capy.Image, + monkeypatch: pytest.MonkeyPatch, + width: int, + height: int, +): + + f = image.random(width, height) + monkeypatch.setattr("breathecode.media.settings.get_file", MagicMock(return_value=f)) + + model = database.create( + file={ + "mime": "image/png", + }, + ) + + res = process_profile(model.file) + + assert res == Notification.error("Profile picture must be square") + + assert settings.get_file.call_args_list == [call(model.file)] + assert settings.save_file.call_args_list == [] + assert database.list_of("authenticate.Profile") == [] + + +def test_no_profile( + database: capy.Database, url: str, fake: capy.Fake, image: capy.Image, monkeypatch: pytest.MonkeyPatch +): + + f = image.random(10, 10) + monkeypatch.setattr("breathecode.media.settings.get_file", MagicMock(return_value=f)) + + model = database.create( + file={ + "mime": "image/png", + }, + ) + + res = process_profile(model.file) + + assert res == Notification.info("Profile picture was updated") + + assert settings.get_file.call_args_list == [call(model.file)] + assert settings.save_file.call_count == 1 + for x in settings.save_file.call_args_list: + # because the file was closed we can't assert its content + assert call(type(x[0][0]), *x[0][1:], **x[1]) == call( + BytesIO, "profile-bucket", f"{model.file.file_name}-120x120", "image/png" + ) + + assert database.list_of("authenticate.Profile") == [ + { + "avatar_url": url, + "bio": None, + "blog": None, + "github_username": None, + "id": 1, + "linkedin_url": None, + "phone": "", + "portfolio_url": None, + "show_tutorial": True, + "twitter_username": None, + "user_id": 1, + }, + ] + + +def test_with_profile__different_image( + database: capy.Database, url: str, format: capy.Format, image: capy.Image, monkeypatch: pytest.MonkeyPatch +): + + f = image.random(10, 10) + monkeypatch.setattr("breathecode.media.settings.get_file", MagicMock(return_value=f)) + + model = database.create( + file={ + "mime": "image/png", + }, + user=1, + profile=1, + ) + + res = process_profile(model.file) + + assert res == Notification.info("Profile picture was updated") + + assert settings.get_file.call_args_list == [call(model.file)] + assert settings.save_file.call_count == 1 + for x in settings.save_file.call_args_list: + # because the file was closed we can't assert its content + assert call(type(x[0][0]), *x[0][1:], **x[1]) == call( + BytesIO, "profile-bucket", f"{model.file.file_name}-120x120", "image/png" + ) + + assert database.list_of("authenticate.Profile") == [ + { + **format.to_obj_repr(model.profile), + "avatar_url": url, + }, + ] + + +def test_with_profile__same_image( + database: capy.Database, url: str, format: capy.Format, image: capy.Image, monkeypatch: pytest.MonkeyPatch +): + + f = image.random(10, 10) + monkeypatch.setattr("breathecode.media.settings.get_file", MagicMock(return_value=f)) + + model = database.create( + file={ + "mime": "image/png", + }, + user=1, + profile={ + "avatar_url": url, + }, + ) + + res = process_profile(model.file) + + assert res == Notification.info("You uploaded the same profile picture") + + assert settings.get_file.call_args_list == [call(model.file)] + assert settings.save_file.call_count == 1 + for x in settings.save_file.call_args_list: + # because the file was closed we can't assert its content + assert call(type(x[0][0]), *x[0][1:], **x[1]) == call( + BytesIO, "profile-bucket", f"{model.file.file_name}-120x120", "image/png" + ) + + assert database.list_of("authenticate.Profile") == [ + format.to_obj_repr(model.profile), + ] diff --git a/breathecode/media/tests/settings/tests_save_file.py b/breathecode/media/tests/settings/tests_save_file.py new file mode 100644 index 000000000..5b7099ec9 --- /dev/null +++ b/breathecode/media/tests/settings/tests_save_file.py @@ -0,0 +1,82 @@ +""" +Test /answer +""" + +from io import BytesIO +from unittest.mock import MagicMock, PropertyMock, call + +import capyc.pytest as capy +import pytest + +from breathecode.media.settings import save_file +from breathecode.services.google_cloud import File, Storage + + +@pytest.fixture(autouse=True) +def setup(db, monkeypatch: pytest.MonkeyPatch): + def download(f: BytesIO): + f.write(b"123") + f.seek(0) + + monkeypatch.setattr("breathecode.services.google_cloud.Storage.__init__", MagicMock(return_value=None)) + monkeypatch.setattr("breathecode.services.google_cloud.Storage.client", PropertyMock(), raising=False) + monkeypatch.setattr("breathecode.services.google_cloud.File.upload", MagicMock()) + monkeypatch.setattr("breathecode.services.google_cloud.File.url", MagicMock(return_value="123456")) + + +def test_file_does_not_exists(monkeypatch: pytest.MonkeyPatch, database: capy.Database, fake: capy.Fake): + monkeypatch.setattr("breathecode.services.google_cloud.File.exists", MagicMock(return_value=False)) + + meta = { + "slug": fake.slug(), + "name": fake.name(), + } + categories = [{"slug": fake.slug()} for _ in range(2)] + model = database.create( + file={ + "meta": { + **meta, + "categories": [x["slug"] for x in categories], + } + }, + academy=2, + city=1, + country=1, + ) + + f = BytesIO() + res = save_file(f, "my-bucket", "my-file", "image/png") + + assert res == "123456" + assert Storage.__init__.call_args_list == [call()] + assert File.exists.call_args_list == [call()] + assert File.upload.call_args_list == [call(f, content_type="image/png")] + + +def test_file_exists(monkeypatch: pytest.MonkeyPatch, database: capy.Database, fake: capy.Fake): + monkeypatch.setattr("breathecode.services.google_cloud.File.exists", MagicMock(return_value=True)) + + meta = { + "slug": fake.slug(), + "name": fake.name(), + } + categories = [{"slug": fake.slug()} for _ in range(2)] + model = database.create( + file={ + "meta": { + **meta, + "categories": [x["slug"] for x in categories], + } + }, + academy=2, + city=1, + country=1, + ) + + f = BytesIO() + res = save_file(f, "my-bucket", "my-file", "image/png") + + assert res == "123456" + assert Storage.__init__.call_args_list == [call()] + assert File.exists.call_args_list == [call()] + assert File.upload.call_args_list == [] diff --git a/breathecode/media/tests/tasks/tests_process_file.py b/breathecode/media/tests/tasks/tests_process_file.py index 0dd1e876e..e299a9346 100644 --- a/breathecode/media/tests/tasks/tests_process_file.py +++ b/breathecode/media/tests/tasks/tests_process_file.py @@ -3,6 +3,7 @@ """ from logging import Logger +from typing import Any from unittest.mock import MagicMock, call import capyc.pytest as capy @@ -10,12 +11,14 @@ from breathecode.media.settings import MEDIA_SETTINGS from breathecode.media.tasks import process_file +from breathecode.notify.models import Notification @pytest.fixture(autouse=True) def setup(db, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("logging.Logger.warning", MagicMock()) monkeypatch.setattr("logging.Logger.error", MagicMock()) + monkeypatch.setattr("breathecode.notify.models.Notification.send", MagicMock(wraps=Notification.send)) def test_no_file(): @@ -23,6 +26,7 @@ def test_no_file(): assert Logger.warning.call_args_list == [call("File with id 1 not found")] assert Logger.error.call_args_list == [call("File with id 1 not found", exc_info=True)] + assert Notification.send.call_args_list == [] @pytest.mark.parametrize("extra", [{}, {"academy": 1, "city": 1, "country": 1}]) @@ -35,6 +39,7 @@ def test_bad_op_type(database: capy.Database, extra: dict): assert Logger.error.call_args_list == [ call(f"No settings found for operation type {model.file.operation_type}", exc_info=True) ] + assert Notification.send.call_args_list == [] @pytest.mark.parametrize("extra", [{}, {"academy": 1, "city": 1, "country": 1}]) @@ -45,10 +50,11 @@ def test_no_process_fn(database: capy.Database, extra: dict): assert Logger.warning.call_args_list == [] assert Logger.error.call_args_list == [call("Invalid process for operation type proof-of-payment", exc_info=True)] + assert Notification.send.call_args_list == [] @pytest.mark.parametrize("extra", [{}, {"academy": 1, "city": 1, "country": 1}]) -@pytest.mark.parametrize("op_type", ["media"]) +@pytest.mark.parametrize("op_type", ["media", "profile-picture"]) def test_process_file(database: capy.Database, extra: dict, op_type: str, monkeypatch: pytest.MonkeyPatch): m = MagicMock() monkeypatch.setitem(MEDIA_SETTINGS[op_type], "process", m) @@ -60,3 +66,45 @@ def test_process_file(database: capy.Database, extra: dict, op_type: str, monkey assert Logger.warning.call_args_list == [] assert Logger.error.call_args_list == [] assert m.call_args_list == [call(model.file)] + assert Notification.send.call_args_list == [] + + +@pytest.mark.parametrize("op_type", ["media", "profile-picture"]) +def test_process_file__notification_not_found(database: capy.Database, op_type: str, monkeypatch: pytest.MonkeyPatch): + m = MagicMock() + monkeypatch.setitem(MEDIA_SETTINGS[op_type], "process", m) + + model = database.create(file={"status": "TRANSFERRING", "operation_type": op_type}, academy=1, city=1, country=1) + + process_file.delay(1, 1) + + assert Logger.warning.call_args_list == [] + assert Logger.error.call_args_list == [] + assert m.call_args_list == [call(model.file)] + assert Notification.send.call_args_list == [] + + +@pytest.mark.parametrize("op_type", ["media", "profile-picture"]) +@pytest.mark.parametrize( + "msg", [Notification.error("test1"), Notification.warning("test2"), Notification.info("test3")] +) +def test_process_file__send_notification( + database: capy.Database, op_type: str, monkeypatch: pytest.MonkeyPatch, msg: Any +): + m = MagicMock(return_value=msg) + monkeypatch.setitem(MEDIA_SETTINGS[op_type], "process", m) + + model = database.create( + file={"status": "TRANSFERRING", "operation_type": op_type}, + academy=1, + city=1, + country=1, + notification=1, + ) + + process_file.delay(1, 1) + + assert Logger.warning.call_args_list == [] + assert Logger.error.call_args_list == [] + assert m.call_args_list == [call(model.file)] + assert Notification.send.call_args_list == [call(msg, model.notification)] diff --git a/breathecode/media/tests/urls/v2/tests_academy_chunk.py b/breathecode/media/tests/urls/v2/tests_academy_chunk.py index 844117042..580a04686 100644 --- a/breathecode/media/tests/urls/v2/tests_academy_chunk.py +++ b/breathecode/media/tests/urls/v2/tests_academy_chunk.py @@ -124,6 +124,42 @@ async def test_op_type_not_provided(aclient: capy.AsyncClient, database: capy.Da assert File.upload.call_args_list == [] +@pytest.mark.asyncio +@pytest.mark.django_db(reset_sequences=True) +@pytest.mark.parametrize("op_type", ["profile-picture"]) +async def test_no_authorized(aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): + url = reverse_lazy("v2:media:academy_chunk") + model = await database.acreate( + user=1, + token={"token_type": "login", "key": fake.slug()}, + academy=1, + role=1, + profile_academy=1, + capability={"slug": "crud_file"}, + city=1, + country=1, + ) + + data = {"operation_type": op_type} + + response = await aclient.put( + url, data, headers={"Authorization": f"Token {model.token.key}", "Academy": "1"}, format="multipart" + ) + + json = response.json() + expected = { + "detail": "unauthorized-media-upload", + "status_code": 403, + } + + assert json == expected + assert response.status_code == status.HTTP_403_FORBIDDEN + assert await database.alist_of("media.Chunk") == [] + + assert Storage.__init__.call_args_list == [] + assert File.upload.call_args_list == [] + + @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) @pytest.mark.parametrize("op_type", ["media", "proof-of-payment"]) diff --git a/breathecode/media/tests/urls/v2/tests_academy_chunk_upload.py b/breathecode/media/tests/urls/v2/tests_academy_chunk_upload.py index 1d17f3851..846380ca2 100644 --- a/breathecode/media/tests/urls/v2/tests_academy_chunk_upload.py +++ b/breathecode/media/tests/urls/v2/tests_academy_chunk_upload.py @@ -133,6 +133,44 @@ async def test_op_type_not_provided(aclient: capy.AsyncClient, database: capy.Da assert process_file.delay.call_args_list == [] +@pytest.mark.asyncio +@pytest.mark.django_db(reset_sequences=True) +@pytest.mark.parametrize("op_type", ["profile-picture"]) +async def test_no_authorized(aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): + url = reverse_lazy("v2:media:academy_chunk_upload") + model = await database.acreate( + user=1, + token={"token_type": "login", "key": fake.slug()}, + role=1, + profile_academy=1, + capability={"slug": "crud_file"}, + city=1, + country=1, + ) + + data = {"operation_type": op_type} + + response = await aclient.put( + url, data, headers={"Authorization": f"Token {model.token.key}", "Academy": "1"}, format="multipart" + ) + + json = response.json() + expected = { + "detail": "unauthorized-media-upload", + "status_code": 403, + } + + assert json == expected + assert response.status_code == status.HTTP_403_FORBIDDEN + assert await database.alist_of("media.Chunk") == [] + assert await database.alist_of("media.File") == [] + + assert Storage.__init__.call_args_list == [] + assert File.upload.call_args_list == [] + + assert process_file.delay.call_args_list == [] + + @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) @pytest.mark.parametrize("op_type", ["media", "proof-of-payment"]) @@ -328,6 +366,7 @@ async def test_upload_file__schedule_deletions( "id": 1, "status": "CREATED", "academy": model.academy.slug, + "notification": None, "mime": "text/plain", "name": "a291e39ac495b2effd38d508417cd731", "operation_type": op_type, @@ -584,6 +623,7 @@ async def test_upload_file__schedule_deletions( "id": 1, "status": "TRANSFERRING", "academy": model.academy.slug, + "notification": 1, "mime": "text/plain", "name": "a291e39ac495b2effd38d508417cd731", "operation_type": op_type, @@ -635,4 +675,4 @@ async def test_upload_file__schedule_deletions( call(instance=chunk, sender=chunk.__class__) for chunk in model.chunk ] - assert process_file.delay.call_args_list == [call(1)] + assert process_file.delay.call_args_list == [call(1, 1)] diff --git a/breathecode/media/tests/urls/v2/tests_me_chunk.py b/breathecode/media/tests/urls/v2/tests_me_chunk.py index 591bfc4dc..3566ce6f6 100644 --- a/breathecode/media/tests/urls/v2/tests_me_chunk.py +++ b/breathecode/media/tests/urls/v2/tests_me_chunk.py @@ -117,6 +117,33 @@ async def test_op_type_not_provided(aclient: capy.AsyncClient, database: capy.Da @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) @pytest.mark.parametrize("op_type", ["media", "proof-of-payment"]) +async def test_no_authorized(aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): + url = reverse_lazy("v2:media:me_chunk") + model = await database.acreate( + user=1, token={"token_type": "login", "key": fake.slug()}, permission={"codename": "upload_media"} + ) + + data = {"operation_type": op_type} + + response = await aclient.put(url, data, headers={"Authorization": f"Token {model.token.key}"}, format="multipart") + + json = response.json() + expected = { + "detail": "unauthorized-media-upload", + "status_code": 403, + } + + assert json == expected + assert response.status_code == status.HTTP_403_FORBIDDEN + assert await database.alist_of("media.Chunk") == [] + + assert Storage.__init__.call_args_list == [] + assert File.upload.call_args_list == [] + + +@pytest.mark.asyncio +@pytest.mark.django_db(reset_sequences=True) +@pytest.mark.parametrize("op_type", ["profile-picture"]) async def test_no_total_chunks(aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): url = reverse_lazy("v2:media:me_chunk") model = await database.acreate( @@ -143,7 +170,7 @@ async def test_no_total_chunks(aclient: capy.AsyncClient, database: capy.Databas @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) -@pytest.mark.parametrize("op_type", ["media", "proof-of-payment"]) +@pytest.mark.parametrize("op_type", ["profile-picture"]) async def test_no_chunk(aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): url = reverse_lazy("v2:media:me_chunk") model = await database.acreate( @@ -170,7 +197,7 @@ async def test_no_chunk(aclient: capy.AsyncClient, database: capy.Database, fake @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) -@pytest.mark.parametrize("op_type", ["media", "proof-of-payment"]) +@pytest.mark.parametrize("op_type", ["profile-picture"]) async def test_unsupported_mime( aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str, file: Callable ): @@ -204,8 +231,7 @@ async def test_unsupported_mime( @pytest.mark.parametrize( "op_type, op_props", [ - ("media", {"size": 1024 * 1024}), - ("proof-of-payment", {"size": 1024 * 1024}), + ("profile-picture", {"size": 1024 * 1024}), ], ) async def test_no_chunk_index( @@ -246,8 +272,7 @@ async def test_no_chunk_index( @pytest.mark.parametrize( "op_type, op_props", [ - ("media", {"size": 1024 * 1024}), - ("proof-of-payment", {"size": 1024 * 1024}), + ("profile-picture", {"size": 1024 * 1024}), ], ) async def test_chunk_uploaded( diff --git a/breathecode/media/tests/urls/v2/tests_me_chunk_upload.py b/breathecode/media/tests/urls/v2/tests_me_chunk_upload.py index 6567aa942..68d06e3bd 100644 --- a/breathecode/media/tests/urls/v2/tests_me_chunk_upload.py +++ b/breathecode/media/tests/urls/v2/tests_me_chunk_upload.py @@ -133,6 +133,38 @@ async def test_op_type_not_provided(aclient: capy.AsyncClient, database: capy.Da @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) @pytest.mark.parametrize("op_type", ["media", "proof-of-payment"]) +async def test_no_authorized(aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): + url = reverse_lazy("v2:media:me_chunk_upload") + model = await database.acreate( + user=1, + token={"token_type": "login", "key": fake.slug()}, + permission={"codename": "upload_media"}, + ) + + data = {"operation_type": op_type} + + response = await aclient.put(url, data, headers={"Authorization": f"Token {model.token.key}"}, format="multipart") + + json = response.json() + expected = { + "detail": "unauthorized-media-upload", + "status_code": 403, + } + + assert json == expected + assert response.status_code == status.HTTP_403_FORBIDDEN + assert await database.alist_of("media.Chunk") == [] + assert await database.alist_of("media.File") == [] + + assert Storage.__init__.call_args_list == [] + assert File.upload.call_args_list == [] + + assert process_file.delay.call_args_list == [] + + +@pytest.mark.asyncio +@pytest.mark.django_db(reset_sequences=True) +@pytest.mark.parametrize("op_type", ["profile-picture"]) async def test_no_total_chunks(aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): url = reverse_lazy("v2:media:me_chunk_upload") model = await database.acreate( @@ -164,7 +196,7 @@ async def test_no_total_chunks(aclient: capy.AsyncClient, database: capy.Databas @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) -@pytest.mark.parametrize("op_type", ["media", "proof-of-payment"]) +@pytest.mark.parametrize("op_type", ["profile-picture"]) async def test_no_filename(aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): url = reverse_lazy("v2:media:me_chunk_upload") model = await database.acreate( @@ -196,7 +228,7 @@ async def test_no_filename(aclient: capy.AsyncClient, database: capy.Database, f @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) -@pytest.mark.parametrize("op_type", ["media", "proof-of-payment"]) +@pytest.mark.parametrize("op_type", ["profile-picture"]) async def test_no_mime(aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): url = reverse_lazy("v2:media:me_chunk_upload") model = await database.acreate( @@ -229,7 +261,7 @@ async def test_no_mime(aclient: capy.AsyncClient, database: capy.Database, fake: class TestNoSchema: @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) - @pytest.mark.parametrize("op_type", ["proof-of-payment"]) + @pytest.mark.parametrize("op_type", ["profile-picture"]) async def test_no_chunks(self, aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): url = reverse_lazy("v2:media:me_chunk_upload") model = await database.acreate( @@ -262,7 +294,7 @@ async def test_no_chunks(self, aclient: capy.AsyncClient, database: capy.Databas @pytest.mark.asyncio @pytest.mark.django_db(reset_sequences=True) - @pytest.mark.parametrize("op_type", ["proof-of-payment"]) + @pytest.mark.parametrize("op_type", ["profile-picture"]) async def test_upload_file__schedule_deletions( self, aclient: capy.AsyncClient, database: capy.Database, format: capy.Format, fake: capy.Fake, op_type: str ): @@ -296,242 +328,7 @@ async def test_upload_file__schedule_deletions( expected = { "id": 1, "academy": None, - "mime": "text/plain", - "name": "a291e39ac495b2effd38d508417cd731", - "operation_type": op_type, - "user": 1, - "status": "CREATED", - } - - assert json == expected - assert response.status_code == status.HTTP_201_CREATED - assert await database.alist_of("media.Chunk") == [format.to_obj_repr(chunk) for chunk in model.chunk] - assert await database.alist_of("media.File") == [ - { - "academy_id": None, - "bucket": "upload-bucket", - "hash": "a291e39ac495b2effd38d508417cd731", - "id": 1, - "meta": None, - "mime": "text/plain", - "name": filename, - "operation_type": op_type, - "size": 24, - "status": "CREATED", - "user_id": 1, - "status_message": None, - }, - ] - - assert Storage.__init__.call_args_list == [call()] - assert len(File.download.call_args_list) == 3 - - for n in range(3): - args, kwargs = File.download.call_args_list[0] - assert len(args) == 1 - assert isinstance(args[0], BytesIO) - assert kwargs == {} - - assert len(File.upload.call_args_list) == 1 - - args, kwargs = File.upload.call_args_list[0] - - assert len(args) == 1 - assert isinstance(args[0], BytesIO) - file: BytesIO = args[0] - assert file.getvalue() == b"my_line\nmy_line\nmy_line\n" - - assert kwargs == {"content_type": "text/plain"} - - assert schedule_deletion.adelay.call_args_list == [ - call(instance=chunk, sender=chunk.__class__) for chunk in model.chunk - ] - - assert process_file.delay.call_args_list == [] - - -class TestMediaSchema: - @pytest.mark.asyncio - @pytest.mark.django_db(reset_sequences=True) - @pytest.mark.parametrize("op_type", ["media"]) - async def test_no_meta_keys( - self, aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str - ): - url = reverse_lazy("v2:media:me_chunk_upload") - model = await database.acreate( - user=1, - token={"token_type": "login", "key": fake.slug()}, - permission={"codename": "upload_media"}, - ) - - data = {"operation_type": op_type, "total_chunks": 3, "filename": "a.txt", "mime": "text/plain"} - - response = await aclient.put( - url, data, headers={"Authorization": f"Token {model.token.key}"}, format="multipart" - ) - - json = response.json() - expected = { - "detail": "missing-required-meta-key", - "status_code": 400, - } - - assert json == expected - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert await database.alist_of("media.Chunk") == [] - assert await database.alist_of("media.File") == [] - - assert Storage.__init__.call_args_list == [] - assert File.upload.call_args_list == [] - - assert process_file.delay.call_args_list == [] - - @pytest.mark.asyncio - @pytest.mark.django_db(reset_sequences=True) - @pytest.mark.parametrize("op_type", ["media"]) - async def test_bad_meta_keys( - self, aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str - ): - url = reverse_lazy("v2:media:me_chunk_upload") - model = await database.acreate( - user=1, - token={"token_type": "login", "key": fake.slug()}, - permission={"codename": "upload_media"}, - ) - - data = { - "operation_type": op_type, - "total_chunks": 3, - "filename": "a.txt", - "mime": "text/plain", - "meta": json_utils.dumps( - { - "x": "y", - "slug": 1, - "name": 1, - "categories": 7, - "academy": "a", - } - ), - } - - response = await aclient.put( - url, data, headers={"Authorization": f"Token {model.token.key}"}, format="multipart" - ) - - json = response.json() - expected = { - "detail": "invalid-meta-value-type", - "status_code": 400, - } - - assert json == expected - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert await database.alist_of("media.Chunk") == [] - assert await database.alist_of("media.File") == [] - - assert Storage.__init__.call_args_list == [] - assert File.upload.call_args_list == [] - - assert process_file.delay.call_args_list == [] - - @pytest.mark.asyncio - @pytest.mark.django_db(reset_sequences=True) - @pytest.mark.parametrize("op_type", ["media"]) - async def test_no_chunks(self, aclient: capy.AsyncClient, database: capy.Database, fake: capy.Fake, op_type: str): - url = reverse_lazy("v2:media:me_chunk_upload") - model = await database.acreate( - user=1, - token={"token_type": "login", "key": fake.slug()}, - permission={"codename": "upload_media"}, - ) - - data = { - "operation_type": op_type, - "total_chunks": 3, - "filename": "a.txt", - "mime": "text/plain", - "meta": json_utils.dumps( - { - "x": "y", - "slug": fake.slug(), - "name": fake.name(), - "categories": [fake.slug()], - "academy": 1, - } - ), - } - - response = await aclient.put( - url, data, headers={"Authorization": f"Token {model.token.key}"}, format="multipart" - ) - - json = response.json() - expected = { - "detail": "some-chunks-not-found", - "status_code": 400, - } - - assert json == expected - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert await database.alist_of("media.Chunk") == [] - assert await database.alist_of("media.File") == [] - - assert Storage.__init__.call_args_list == [] - assert File.upload.call_args_list == [] - - assert process_file.delay.call_args_list == [] - - @pytest.mark.asyncio - @pytest.mark.django_db(reset_sequences=True) - @pytest.mark.parametrize("op_type", ["media"]) - async def test_upload_file__schedule_deletions( - self, aclient: capy.AsyncClient, database: capy.Database, format: capy.Format, fake: capy.Fake, op_type: str - ): - url = reverse_lazy("v2:media:me_chunk_upload") - filename = fake.slug() + ".txt" - mime = "text/plain" - chunks = [ - { - "name": filename, - "mime": mime, - "operation_type": op_type, - "total_chunks": 3, - "chunk_index": index, - } - for index in range(3) - ] - model = await database.acreate( - user=1, - token={"token_type": "login", "key": fake.slug()}, - permission={"codename": "upload_media"}, - chunk=chunks, - ) - - data = { - "operation_type": op_type, - "total_chunks": 3, - "filename": filename, - "mime": "text/plain", - "meta": json_utils.dumps( - { - "x": "y", - "slug": fake.slug(), - "name": fake.name(), - "categories": [fake.slug()], - "academy": 1, - } - ), - } - - response = await aclient.put( - url, data, headers={"Authorization": f"Token {model.token.key}"}, format="multipart" - ) - - json = response.json() - expected = { - "id": 1, - "academy": None, + "notification": 1, "mime": "text/plain", "name": "a291e39ac495b2effd38d508417cd731", "operation_type": op_type, @@ -583,4 +380,4 @@ async def test_upload_file__schedule_deletions( call(instance=chunk, sender=chunk.__class__) for chunk in model.chunk ] - assert process_file.delay.call_args_list == [call(1)] + assert process_file.delay.call_args_list == [call(1, 1)] diff --git a/breathecode/media/tests/urls/v2/tests_operationtype.py b/breathecode/media/tests/urls/v2/tests_operationtype.py index 24b104e4c..89ae900c1 100644 --- a/breathecode/media/tests/urls/v2/tests_operationtype.py +++ b/breathecode/media/tests/urls/v2/tests_operationtype.py @@ -19,6 +19,7 @@ def test_list_op_types(client: capy.Client): expected = [ "media", "proof-of-payment", + "profile-picture", ] assert json == expected diff --git a/breathecode/media/tests/urls/v2/tests_operationtype_type.py b/breathecode/media/tests/urls/v2/tests_operationtype_type.py index 1585aabd7..8bee4d9d2 100644 --- a/breathecode/media/tests/urls/v2/tests_operationtype_type.py +++ b/breathecode/media/tests/urls/v2/tests_operationtype_type.py @@ -16,6 +16,7 @@ [ ("media", {"chunk_size": 10485760, "max_chunks": None}), ("proof-of-payment", {"chunk_size": 10485760, "max_chunks": None}), + ("profile-picture", {"chunk_size": 10485760, "max_chunks": 25}), ], ) def test_op_type_desc(client: capy.Client, op_type: str, expected: dict): diff --git a/breathecode/media/utils.py b/breathecode/media/utils.py index d5722ca00..d02e15223 100644 --- a/breathecode/media/utils.py +++ b/breathecode/media/utils.py @@ -6,6 +6,7 @@ from typing import Any, Optional, Tuple, overload from adrf.views import APIView +from capyc.rest_framework.exceptions import ValidationException from rest_framework import status from rest_framework.response import Response @@ -15,7 +16,6 @@ from breathecode.media.signals import schedule_deletion from breathecode.services.google_cloud.storage import Storage from breathecode.utils.i18n import translation -from capyc.rest_framework.exceptions import ValidationException from .settings import MEDIA_MIME_ALLOWED, MEDIA_SETTINGS, MediaSettings, Schema @@ -316,6 +316,7 @@ async def validate_meta(self, schema: Schema, meta: dict[str, Any]): async def upload(self, academy_id: Optional[int] = None): from breathecode.media.tasks import process_file + from breathecode.notify.models import Notification await super().upload(academy_id) request = self.request @@ -360,7 +361,6 @@ async def upload(self, academy_id: Optional[int] = None): code=400, ) - #### if isinstance(meta, dict) is False: raise ValidationException( translation( @@ -450,6 +450,8 @@ async def upload(self, academy_id: Optional[int] = None): new_file = storage.file(bucket, hash) new_file.upload(res, content_type=mime) + notification_id = None + file = await File.objects.acreate( academy=academy, user=request.user, @@ -464,11 +466,20 @@ async def upload(self, academy_id: Optional[int] = None): file.status = File.Status.TRANSFERRING await file.asave() - process_file.delay(file.id) + notification = await Notification.objects.acreate( + operation_code=f"up-{self.op_type}", + user=request.user, + academy=academy, + meta={"file_id": file.id}, + ) + notification_id = notification.id + + process_file.delay(file.id, notification.id) return Response( { "id": file.id, + "notification": notification_id, "academy": academy.slug if academy else None, "user": request.user.id, "status": file.status, diff --git a/breathecode/media/views.py b/breathecode/media/views.py index e78482fb1..d237d2ff7 100644 --- a/breathecode/media/views.py +++ b/breathecode/media/views.py @@ -7,6 +7,7 @@ import requests from adrf.views import APIView from adrf.viewsets import ViewSet +from capyc.rest_framework.exceptions import ValidationException from circuitbreaker import CircuitBreakerError from django.db.models import Q from django.http import StreamingHttpResponse @@ -35,7 +36,6 @@ from breathecode.utils.decorators import has_permission from breathecode.utils.decorators.capable_of import acapable_of from breathecode.utils.i18n import translation -from capyc.rest_framework.exceptions import ValidationException logger = logging.getLogger(__name__) MIME_ALLOWED = [ @@ -47,6 +47,8 @@ "video/mp4", "audio/mpeg", "application/pdf", + "application/json", + "text/plain", "image/jpg", "application/octet-stream", "application/x-pka", diff --git a/breathecode/notify/admin.py b/breathecode/notify/admin.py index 662cbef2a..640a0982e 100644 --- a/breathecode/notify/admin.py +++ b/breathecode/notify/admin.py @@ -11,7 +11,17 @@ from breathecode.utils import AdminExportCsvMixin from .actions import send_slack, sync_slack_team_channel -from .models import CohortProxy, Device, HookError, SlackChannel, SlackTeam, SlackUser, SlackUserTeam, UserProxy +from .models import ( + CohortProxy, + Device, + HookError, + Notification, + SlackChannel, + SlackTeam, + SlackUser, + SlackUserTeam, + UserProxy, +) from .tasks import async_slack_team_users from .utils.hook_manager import HookManager @@ -181,3 +191,11 @@ class HookErrorAdmin(admin.ModelAdmin): list_display = ["event", "message", "created_at", "updated_at"] search_fields = ["message", "event"] list_filter = ["event"] + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ("operation_code", "status", "type", "user", "academy", "done_at", "sent_at", "seen_at") + search_fields = ("operation_code", "message", "user__username", "user__email", "academy__name") + list_filter = ("status", "type") + raw_id_fields = ("user", "academy") diff --git a/breathecode/notify/migrations/0013_notification.py b/breathecode/notify/migrations/0013_notification.py new file mode 100644 index 000000000..5519dedd4 --- /dev/null +++ b/breathecode/notify/migrations/0013_notification.py @@ -0,0 +1,65 @@ +# Generated by Django 5.1.1 on 2024-09-18 18:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("admissions", "0064_academy_legal_name"), + ("notify", "0012_hookerror"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("operation_code", models.CharField(max_length=20)), + ("message", models.TextField(blank=True, default=None, null=True)), + ("meta", models.JSONField(blank=True, default=None, null=True)), + ( + "status", + models.CharField( + choices=[("PENDING", "Pending"), ("DONE", "Done"), ("SENT", "Sent"), ("SEEN", "Seen")], + default="PENDING", + max_length=10, + ), + ), + ( + "type", + models.CharField( + choices=[("INFO", "Info"), ("WARNING", "Warning"), ("ERROR", "Error")], + default="INFO", + max_length=10, + ), + ), + ("done_at", models.DateTimeField(blank=True, default=None, null=True)), + ("sent_at", models.DateTimeField(blank=True, default=None, null=True)), + ("seen_at", models.DateTimeField(blank=True, default=None, null=True)), + ( + "academy", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="admissions.academy", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/breathecode/notify/models.py b/breathecode/notify/models.py index cdebf3c70..00fab79f9 100644 --- a/breathecode/notify/models.py +++ b/breathecode/notify/models.py @@ -1,9 +1,14 @@ from collections import OrderedDict +from typing import Literal, Optional +from asgiref.sync import async_to_sync, sync_to_async +from channels.layers import get_channel_layer +from django import forms from django.conf import settings from django.contrib.auth.models import User from django.core import serializers from django.db import models +from django.utils import timezone from rest_framework.exceptions import ValidationError from breathecode.admissions.models import Academy, Cohort @@ -215,3 +220,151 @@ class HookError(models.Model): hooks = models.ManyToManyField(Hook, related_name="errors", blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + + +class Notification(models.Model): + """ + This model works like: + 1. a promise of delivery a notification to a user or academy. + 2. a stateless way to emit notifications to the frontend. + """ + + class Status(models.TextChoices): + PENDING = "PENDING", "Pending" + DONE = "DONE", "Done" + SENT = "SENT", "Sent" + SEEN = "SEEN", "Seen" + + class Type(models.TextChoices): + INFO = "INFO", "Info" + WARNING = "WARNING", "Warning" + ERROR = "ERROR", "Error" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._status = self.status + + operation_code = models.CharField(max_length=20) + message = models.TextField(blank=True, null=True, default=None) + + user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, default=None) + academy = models.ForeignKey(Academy, on_delete=models.CASCADE, null=True, blank=True, default=None) + meta = models.JSONField(blank=True, null=True, default=None) + + status = models.CharField(max_length=10, choices=Status.choices, default=Status.PENDING) + type = models.CharField(max_length=10, choices=Type.choices, default=Type.INFO) + done_at = models.DateTimeField(blank=True, null=True, default=None) + sent_at = models.DateTimeField(blank=True, null=True, default=None) + seen_at = models.DateTimeField(blank=True, null=True, default=None) + + def __str__(self): + return self.operation_code + + def clean(self): + if any([self.user, self.academy]) is False: + raise forms.ValidationError("Either user or academy must be provided") + + if self.status == self.Status.DONE: + self.sent_at = timezone.now() + + if self.status == self.Status.PENDING: + self.sent_at = None + + super().clean() + + @async_to_sync + async def _send_notification(self): + channel_layer = get_channel_layer() + + user_id = self.user.id if self.user else None + academy_id = self.academy.id if self.academy else None + + await channel_layer.send( + f"notification_{user_id}_{academy_id}", + { + "type": "notification.refresh", + "user": user_id, + "academy": academy_id, + }, + ) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + if self.status != self._status and self.status == self.Status.DONE: + self._send_notification() + + self._status = self.status + + @classmethod + def send(cls, message: tuple[Literal["INFO", "WARNING", "ERROR"], str], notification: "Notification"): + if notification.status == Notification.Status.PENDING: + notification.message = message[1] + notification.status = Notification.Status.DONE + notification.type = message[0] + notification.save() + + @classmethod + @sync_to_async + def asend(cls, message: tuple[Literal["INFO", "WARNING", "ERROR"], str], notification: "Notification"): + cls.send(message, notification) + + @classmethod + async def aemit( + cls, + message: tuple[Literal["INFO", "WARNING", "ERROR"], str], + user: Optional[User] = None, + academy: Optional[Academy] = None, + ) -> None: + """Emit a notification that won't be saved""" + + user_id = None + academy_id = None + + if isinstance(user, User): + user_id = user.id + + elif isinstance(user, int): + user_id = user + + if isinstance(academy, Academy): + academy_id = academy.id + + elif isinstance(academy, int): + academy_id = academy + + channel_layer = get_channel_layer() + + await channel_layer.send( + f"notification_{user_id}_{academy_id}", + { + "type": "notification.push", + "user": user_id, + "academy": academy_id, + "level": message[0], + "message": message[1], + }, + ) + + @classmethod + @async_to_sync + async def emit( + cls, + message: tuple[Literal["INFO", "WARNING", "ERROR"], str], + user: Optional[User] = None, + academy: Optional[Academy] = None, + ) -> None: + await cls.aemit(message, user, academy) + + @classmethod + def info(cls, message: str) -> tuple[Literal["INFO", "WARNING", "ERROR"], str]: + return ("INFO", message) + + @classmethod + def warning(cls, message: str) -> tuple[Literal["INFO", "WARNING", "ERROR"], str]: + return ("WARNING", message) + + @classmethod + def error(cls, message: str) -> tuple[Literal["INFO", "WARNING", "ERROR"], str]: + return ("ERROR", message) diff --git a/breathecode/payments/views.py b/breathecode/payments/views.py index c7dfe75fe..e9fa60b31 100644 --- a/breathecode/payments/views.py +++ b/breathecode/payments/views.py @@ -1,5 +1,7 @@ from datetime import timedelta +from capyc.core.shorteners import C +from capyc.rest_framework.exceptions import PaymentException, ValidationException from django.core.cache import cache from django.db import transaction from django.db.models import CharField, Q, Value @@ -78,8 +80,6 @@ from breathecode.utils.decorators.capable_of import capable_of from breathecode.utils.i18n import translation from breathecode.utils.redis import Lock -from capyc.core.shorteners import C -from capyc.rest_framework.exceptions import PaymentException, ValidationException logger = getLogger(__name__) @@ -1944,8 +1944,9 @@ def post(self, request): try: plan = bag.plans.filter().first() option = plan.financing_options.filter(how_many_months=bag.how_many_installments).first() + original_price = option.monthly_price coupons = bag.coupons.all() - amount = get_discounted_price(option.monthly_price, coupons) + amount = get_discounted_price(original_price, coupons) bag.monthly_price = option.monthly_price except Exception: @@ -1962,12 +1963,17 @@ def post(self, request): elif not available_for_free_trial and not available_free: amount = get_amount_by_chosen_period(bag, chosen_period, lang) coupons = bag.coupons.all() + original_price = amount amount = get_discounted_price(amount, coupons) else: + original_price = 0 amount = 0 - if amount == 0 and Subscription.objects.filter(user=request.user, plans__in=bag.plans.all()).count(): + if ( + original_price == 0 + and Subscription.objects.filter(user=request.user, plans__in=bag.plans.all()).count() + ): raise ValidationException( translation( lang, @@ -1981,7 +1987,7 @@ def post(self, request): # actions.check_dependencies_in_bag(bag, lang) if ( - amount == 0 + original_price == 0 and not available_free and available_for_free_trial and not bag.plans.filter(plan_offer_from__id__gte=1).exists() @@ -2028,7 +2034,7 @@ def post(self, request): transaction.savepoint_commit(sid) - if amount == 0: + if original_price == 0: tasks.build_free_subscription.delay(bag.id, invoice.id, conversion_info=conversion_info) elif bag.how_many_installments > 0: diff --git a/breathecode/provisioning/actions.py b/breathecode/provisioning/actions.py index aa06d12aa..966d7fc19 100644 --- a/breathecode/provisioning/actions.py +++ b/breathecode/provisioning/actions.py @@ -767,3 +767,10 @@ def add_rigobot_activity(context: ActivityContext, field: dict, position: int) - pa.bills.add(provisioning_bill) pa.events.add(item) + + +def extract_repo_name(url): + match = re.search(r"github\.com/[^/]+/([^/]+)(\.git)?$", url) + if match: + return match.group(1) + return None diff --git a/breathecode/provisioning/templates/base_provisioning.html b/breathecode/provisioning/templates/base_provisioning.html new file mode 100644 index 000000000..c06ff5262 --- /dev/null +++ b/breathecode/provisioning/templates/base_provisioning.html @@ -0,0 +1,224 @@ + + + + + + + + + + + + + {{ subject }} + + {% block head %} + {% endblock %} + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + +
+ {% block content %} + {% endblock %} + + + +
  +
+ + + + + + + + + + + + +
+ + + + + + + {% if TRACKER_URL %} + + {% endif %} + + + + + + + diff --git a/breathecode/provisioning/templates/choose_vendor.html b/breathecode/provisioning/templates/choose_vendor.html index 261e3f1b7..e35d70c85 100644 --- a/breathecode/provisioning/templates/choose_vendor.html +++ b/breathecode/provisioning/templates/choose_vendor.html @@ -1,67 +1,647 @@ -{% extends "base.html" %} +{% extends "base_provisioning.html" %} {% load math %} +{% load static %} {% block head %} - - - - + + + + {% endblock %} {% block content %} -
-
-
-

Start working immediately

-

Now, you can instantly open this project on VSCode, with no installations and instant feedback from Rigobot (our internally developed AI). Choose one of the following vendors:

+ provisioning bridge logo +

Start coding in just one click

+

+ Now, you can instantly open this project on VSCode, with no installations and instant feedback from Rigobot + (our internally developed AI). Choose one of the following vendors: +

+
+ {% if buttons %} + {% for button in buttons %} + + {% endfor %} + {% else %} +

No buttons available.

+ {% endif %} + +
+
+
+

Setup and configuration are big barriers to learning coding-related skills;

+

That's why we developed the 4Geeks Click + and Code + and LearnPack.

-
-
- {% if buttons %} - {% for button in buttons %} - - {% endfor %} - {% else %} -

No buttons available.

- {% endif %} +
+
+ +
+ + -
- + + {% endblock %} diff --git a/breathecode/provisioning/views.py b/breathecode/provisioning/views.py index 4231ada18..bd13274d5 100644 --- a/breathecode/provisioning/views.py +++ b/breathecode/provisioning/views.py @@ -5,6 +5,7 @@ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import pandas as pd +from capyc.rest_framework.exceptions import ValidationException from circuitbreaker import CircuitBreakerError from dateutil.relativedelta import relativedelta from django.http import HttpResponse @@ -35,9 +36,8 @@ from breathecode.utils.i18n import translation from breathecode.utils.io.file import count_csv_rows from breathecode.utils.views import private_view, render_message -from capyc.rest_framework.exceptions import ValidationException -from .actions import get_provisioning_vendor +from .actions import get_provisioning_vendor, extract_repo_name from .models import BILL_STATUS, ProvisioningBill, ProvisioningUserConsumption @@ -99,33 +99,69 @@ def redirect_new_container(request, token): def redirect_new_container_public(request): + from breathecode.registry.models import Asset + # user = token.user + lang = request.GET.get("lang", None) repo = request.GET.get("repo", None) if repo is None: return render_message(request, "Please specify a repository in the URL") urls = {"gitpod": "https://gitpod.io/#", "codespaces": "https://github.com/codespaces/new/?repo="} - get_urls = {"codespaces": lambda x: x.replace("https://github.com/", "")} + url_modifiers = {"codespaces": lambda x: x.replace("https://github.com/", "")} vendors = request.GET.get("vendor", "codespaces,gitpod").split(",") buttons = [] - for v in vendors: - if v not in urls: - return render_message(request, f"Invalid provisioning vendor: {v}") + asset = Asset.objects.filter(readme_url__icontains=repo) + if lang is not None: + asset = asset.filter(lang=lang) + asset = asset.first() + + if asset and asset.learnpack_deploy_url: buttons.append( { - "label": f"Open in {v.capitalize()}", - "url": (get_urls[v](urls[v]) if v in get_urls else urls[v] + repo), - "icon": f"/static/img/{v}.svg", + "label": "Start now in the cloud", + "url": asset.learnpack_deploy_url, + "icon": "/static/img/learnpack.svg", } ) + else: + for v in vendors: + if v not in urls: + return render_message(request, f"Invalid provisioning vendor: {v}") + + _url = urls[v] + repo + if v in url_modifiers: + _url = urls[v] + url_modifiers[v](repo) + + buttons.append( + { + "label": f"Open in {v.capitalize()}", + "url": _url, + "icon": f"/static/img/{v}.svg", + } + ) + data = { # 'title': item.academy.name, "buttons": buttons, # 'COMPANY_INFO_EMAIL': item.academy.feedback_email, } + + if asset and asset.url: + data["repo_url"] = asset.url + ".git" + data["repo_slug"] = extract_repo_name(asset.url) + else: + data["repo_url"] = "repo_url" + data["repo_slug"] = "repo_name" + + if asset and asset.agent: + data["agent"] = asset.agent + else: + data["agent"] = "vscode" + template = get_template_content("choose_vendor", data) return HttpResponse(template["html"]) diff --git a/breathecode/registry/actions.py b/breathecode/registry/actions.py index e23f4aeec..71019ea97 100644 --- a/breathecode/registry/actions.py +++ b/breathecode/registry/actions.py @@ -779,6 +779,9 @@ def process_asset_config(asset, config): asset.with_solutions = True asset.with_video = True + if "agent" in config: + asset.agent = config["agent"] + if "solution" in config: asset.with_solutions = True if isinstance(config["solution"], str): diff --git a/breathecode/registry/migrations/0044_asset_learnpack_deploy_url.py b/breathecode/registry/migrations/0044_asset_learnpack_deploy_url.py new file mode 100644 index 000000000..0f4c7c5bf --- /dev/null +++ b/breathecode/registry/migrations/0044_asset_learnpack_deploy_url.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.1 on 2024-09-25 15:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registry", "0043_asset_superseded_by"), + ] + + operations = [ + migrations.AddField( + model_name="asset", + name="learnpack_deploy_url", + field=models.URLField( + blank=True, + default=None, + help_text="Only applies to LearnPack tutorials that have been published in the LearnPack cloud", + null=True, + ), + ), + ] diff --git a/breathecode/registry/migrations/0045_asset_agent.py b/breathecode/registry/migrations/0045_asset_agent.py new file mode 100644 index 000000000..983771806 --- /dev/null +++ b/breathecode/registry/migrations/0045_asset_agent.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.1 on 2024-09-26 08:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registry", "0044_asset_learnpack_deploy_url"), + ] + + operations = [ + migrations.AddField( + model_name="asset", + name="agent", + field=models.CharField( + blank=True, + default=None, + help_text="If value is vscode, then we recommend to open this exercise/project in vscode and instructions will be different. If it is standalone, then you can open it directly from the terminal", + max_length=20, + null=True, + ), + ), + ] diff --git a/breathecode/registry/models.py b/breathecode/registry/models.py index bccf2ce08..b41624235 100644 --- a/breathecode/registry/models.py +++ b/breathecode/registry/models.py @@ -311,6 +311,13 @@ def __init__(self, *args, **kwargs): help_text="Brief for the copywriters, mainly used to describe what this lessons needs to be about", ) + learnpack_deploy_url = models.URLField( + null=True, + blank=True, + default=None, + help_text="Only applies to LearnPack tutorials that have been published in the LearnPack cloud", + ) + readme_url = models.URLField( null=True, blank=True, @@ -346,6 +353,13 @@ def __init__(self, *args, **kwargs): default=False, help_text="If true, it means it can be opened on cloud provisioning vendors like Gitpod or Codespaces", ) + agent = models.CharField( + max_length=20, + null=True, + blank=True, + default=None, + help_text="If value is vscode, then we recommend to open this exercise/project in vscode and instructions will be different. If it is standalone, then you can open it directly from the terminal", + ) duration = models.IntegerField(null=True, blank=True, default=None, help_text="In hours") difficulty = models.CharField(max_length=20, choices=DIFFICULTY, default=None, null=True, blank=True) diff --git a/breathecode/registry/serializers.py b/breathecode/registry/serializers.py index fdf0259b9..eaf0cfbcf 100644 --- a/breathecode/registry/serializers.py +++ b/breathecode/registry/serializers.py @@ -344,6 +344,7 @@ class AssetMidSerializer(AssetSerializer): with_solutions = serpy.Field() with_video = serpy.Field() updated_at = serpy.Field() + agent = serpy.Field() class AssetBigSerializer(AssetMidSerializer): diff --git a/breathecode/services/activecampaign/actions/deal_add.py b/breathecode/services/activecampaign/actions/deal_add.py index 9444a9a23..136913ec5 100644 --- a/breathecode/services/activecampaign/actions/deal_add.py +++ b/breathecode/services/activecampaign/actions/deal_add.py @@ -34,7 +34,9 @@ def deal_add(self, webhook, payload: dict, acp_ids): if entry is None and "deal[contact_email]" in payload: entry = ( FormEntry.objects.filter( - email=payload["deal[contact_email]"], ac_deal_id__isnull=True, storage_status="PERSISTED" + email=payload["deal[contact_email]"], + ac_deal_id__isnull=True, + storage_status__in=["PERSISTED", "MANUALLY_PERSISTED"], ) .order_by("-created_at") .first() diff --git a/breathecode/services/activecampaign/actions/deal_update.py b/breathecode/services/activecampaign/actions/deal_update.py index b73fc5124..1f3bff869 100644 --- a/breathecode/services/activecampaign/actions/deal_update.py +++ b/breathecode/services/activecampaign/actions/deal_update.py @@ -31,7 +31,7 @@ def deal_update(ac_cls, webhook, payload: dict, acp_ids): ) if entry is None and "deal[contact_email]" in payload: entry = ( - FormEntry.objects.filter(email=payload["deal[contact_email]"], storage_status="PERSISTED") + FormEntry.objects.filter(email=payload["deal[contact_email]"], storage_status__in=["PERSISTED", "MANUALLY_PERSISTED"]) .order_by("-created_at") .first() ) diff --git a/breathecode/services/brevo.py b/breathecode/services/brevo.py new file mode 100644 index 000000000..4d4bf1b41 --- /dev/null +++ b/breathecode/services/brevo.py @@ -0,0 +1,163 @@ +import logging + +import requests +from requests.exceptions import JSONDecodeError + +logger = logging.getLogger(__name__) + +AC_MAPS = { + "email": "EMAIL", + "first_name": "FIRSTNAME", + "last_name": "LASTNAME", + "phone": "PHONE", # or "WHATSAPP", "SMS" depending on your needs + "utm_location": "UTM_LOCATION", + "utm_country": "COUNTRY", + "utm_campaign": "UTM_CAMPAIGN", + "utm_content": "UTM_CONTENT", + "utm_medium": "UTM_MEDIUM", + "utm_placement": "UTM_PLACEMENT", + "utm_term": "UTM_TERM", + "utm_source": "UTM_SOURCE", + "utm_plan": "PLAN", + "gender": "GENDER", + "course": "COURSE", + "gclid": "GCLID", + "utm_url": "CONVERSION_URL", + "utm_language": "LANGUAGE", + "utm_landing": "LANDING_URL", + "referral_key": "REFERRAL_KEY", + "client_comments": None, # It will be ignored because its none + "current_download": None, # It will be ignored because its none +} + + +def map_contact_keys(_contact): + # Check if all keys in contact exist in AC_MAPS + missing = [] + for key in _contact: + if key not in AC_MAPS: + missing.append(key) + + if len(missing) > 0: + _keys = ",".join(missing) + raise KeyError(f"The following keys are missing on AC_MAPS dictionary: '{_keys}'") + + # Replace keys in the contact dictionary based on AC_MAPS + mapped_contact = {AC_MAPS[key]: value for key, value in _contact.items() if AC_MAPS[key] is not None} + + return mapped_contact + + +class BrevoAuthException(Exception): + pass + + +class Brevo: + HOST = "https://api.brevo.com/v3" + headers = {} + + def __init__(self, token=None, org=None, host=None): + self.token = token + self.org = org + self.page_size = 100 + if host is not None: + self.HOST = host + + def get(self, action_name, request_data=None): + + if request_data is None: + request_data = {} + + return self._call("GET", action_name, params=request_data) + + def head(self, action_name, request_data=None): + + if request_data is None: + request_data = {} + + return self._call("HEAD", action_name, params=request_data) + + def post(self, action_name, request_data=None): + + if request_data is None: + request_data = {} + + return self._call("POST", action_name, json=request_data) + + def delete(self, action_name, request_data=None): + + if request_data is None: + request_data = {} + + return self._call("DELETE", action_name, params=request_data) + + def _call(self, method_name, action_name, params=None, json=None): + + self.headers = { + "api-key": self.token, + "Content-type": "application/json", + } + + url = self.HOST + action_name + resp = requests.request(method=method_name, url=url, headers=self.headers, params=params, json=json, timeout=2) + + if resp.status_code >= 200 and resp.status_code < 300: + if method_name in ["DELETE", "HEAD"]: + return resp + + try: + data = resp.json() + return data + except JSONDecodeError: + payload = resp.text + return payload + else: + logger.debug(f"Error call {method_name}: /{action_name}") + if resp.status_code == 401: + raise BrevoAuthException("Invalid credentials when calling the Brevo API") + + error_message = str(resp.status_code) + try: + error = resp.json() + error_message = error["message"] + logger.debug(error) + except Exception: + pass + + raise Exception( + f"Unable to communicate with Brevo API for {method_name} {action_name}, error: {error_message}" + ) + + # def create_contact(self, email: str, contact: dict, lists: list): + + # try: + # body = { + # "attributes": { + # **map_contact_keys(contact) + # }, + # "updateEnabled": True, + # "email": email, + # "ext_id": attribution_id, + # "listIds": lists, + # } + # response = self.post("/contacts", request_data=body) + # return response.status_code == 201 + # except Exception: + # return False + + def create_contact(self, contact: dict, automation_slug): + try: + body = { + "event_name": "add_to_automation", + "identifiers": {"email_id": contact["email"]}, + "contact_properties": {**map_contact_keys(contact)}, + "event_properties": { + "automation_slug": automation_slug, + }, + } + data = self.post("/events", request_data=body) + return data + except Exception as e: + logger.exception("Error while creating contact in Brevo") + raise e + # return False diff --git a/breathecode/settings.py b/breathecode/settings.py index 4e5f02bda..dcee1e5c9 100644 --- a/breathecode/settings.py +++ b/breathecode/settings.py @@ -78,6 +78,7 @@ "breathecode.commons", "breathecode.payments", "breathecode.provisioning", + "breathecode.websocket", "explorer", "graphene_django", "task_manager", @@ -525,6 +526,14 @@ def get(self, key, *args, **kwargs): # Websocket ASGI_APPLICATION = "breathecode.asgi.application" REDIS_URL_PATTERN = r"^redis://(.+):(\d+)$" +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer", + "CONFIG": { + "hosts": [("127.0.0.1", 6379)], + }, + }, +} heroku_redis_ssl_host = { "address": REDIS_URL, # The 'rediss' schema denotes a SSL connection. diff --git a/breathecode/static/img/button-locally.svg b/breathecode/static/img/button-locally.svg new file mode 100644 index 000000000..cca688223 --- /dev/null +++ b/breathecode/static/img/button-locally.svg @@ -0,0 +1,3 @@ + + + diff --git a/breathecode/static/img/chevron.svg b/breathecode/static/img/chevron.svg new file mode 100644 index 000000000..7a5723d9c --- /dev/null +++ b/breathecode/static/img/chevron.svg @@ -0,0 +1,3 @@ + + + diff --git a/breathecode/static/img/learnpack.svg b/breathecode/static/img/learnpack.svg new file mode 100644 index 000000000..889b92970 --- /dev/null +++ b/breathecode/static/img/learnpack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/breathecode/static/img/os-ios.svg b/breathecode/static/img/os-ios.svg new file mode 100644 index 000000000..4cc9b2a10 --- /dev/null +++ b/breathecode/static/img/os-ios.svg @@ -0,0 +1,3 @@ + + + diff --git a/breathecode/static/img/os-linux.svg b/breathecode/static/img/os-linux.svg new file mode 100644 index 000000000..7fa16460b --- /dev/null +++ b/breathecode/static/img/os-linux.svg @@ -0,0 +1,472 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/breathecode/static/img/os-windows.svg b/breathecode/static/img/os-windows.svg new file mode 100644 index 000000000..2a83a8ef2 --- /dev/null +++ b/breathecode/static/img/os-windows.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/breathecode/static/img/provisioning.png b/breathecode/static/img/provisioning.png new file mode 100644 index 000000000..853388fe9 Binary files /dev/null and b/breathecode/static/img/provisioning.png differ diff --git a/breathecode/websocket/__init__.py b/breathecode/websocket/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/breathecode/websocket/admin.py b/breathecode/websocket/admin.py new file mode 100644 index 000000000..846f6b406 --- /dev/null +++ b/breathecode/websocket/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/breathecode/websocket/apps.py b/breathecode/websocket/apps.py new file mode 100644 index 000000000..1a260a7bc --- /dev/null +++ b/breathecode/websocket/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class WebsocketConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "breathecode.websocket" diff --git a/breathecode/websocket/consumers.py b/breathecode/websocket/consumers.py new file mode 100644 index 000000000..c71f8c428 --- /dev/null +++ b/breathecode/websocket/consumers.py @@ -0,0 +1,47 @@ +# chat/consumers.py +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +# from channels.layers import ChannelLayerManager +from channels_redis.pubsub import RedisPubSubChannelLayer + + +class NotificationConsumer(AsyncJsonWebsocketConsumer): + channel_layer: RedisPubSubChannelLayer + + async def connect(self): + # self.room_name = self.scope["url_route"]["kwargs"]["room_name"] + self.user_group_name = f"notification_{self.user_id}" + self.academy_group_name = f"notification_{self.user_id}_{self.academy_id}" + + self.user_id = 1 + self.academy_id = 1 + + # Join room group + await self.channel_layer.group_add(self.user_group_name, self.channel_name) + await self.channel_layer.group_add(f"notification_{self.user_id}_{self.academy_id}", self.channel_name) + + await self.accept() + + async def disconnect(self, close_code): + + await self.channel_layer.group_discard(f"notification_{self.user_id}", self.channel_name) + await self.channel_layer.group_discard(f"notification_{self.user_id}_{self.academy_id}", self.channel_name) + ... + # # Leave room group + # await self.channel_layer.group_discard(self.room_group_name, self.channel_name) + + # Receive message from WebSocket + async def receive_json(self, content): + ... + # text_data_json = json.loads(text_data) + # message = text_data_json["message"] + + # # Send message to room group + # await self.channel_layer.group_send(self.room_group_name, {"type": "chat.message", "message": message}) + + # Receive message from room group + async def chat_message(self, event): + message = event["message"] + + # Send message to WebSocket + await self.send_json({"message": message}) diff --git a/breathecode/websocket/migrations/__init__.py b/breathecode/websocket/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/breathecode/websocket/models.py b/breathecode/websocket/models.py new file mode 100644 index 000000000..6b2021999 --- /dev/null +++ b/breathecode/websocket/models.py @@ -0,0 +1 @@ +# Create your models here. diff --git a/breathecode/websocket/router.py b/breathecode/websocket/router.py new file mode 100644 index 000000000..bbf6a2883 --- /dev/null +++ b/breathecode/websocket/router.py @@ -0,0 +1,17 @@ +from channels.auth import AuthMiddlewareStack +from channels.routing import URLRouter +from channels.security.websocket import AllowedHostsOriginValidator + +from django.urls import path + +from .consumers import NotificationConsumer + +routes = AllowedHostsOriginValidator( + AuthMiddlewareStack( + URLRouter( + [ + path("ws/notification", NotificationConsumer.as_asgi(), name="ws_notification"), + ] + ) + ) +) diff --git a/breathecode/websocket/tests.py b/breathecode/websocket/tests.py new file mode 100644 index 000000000..a39b155ac --- /dev/null +++ b/breathecode/websocket/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/breathecode/websocket/views.py b/breathecode/websocket/views.py new file mode 100644 index 000000000..60f00ef0e --- /dev/null +++ b/breathecode/websocket/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/scripts/dyno/web.sh b/scripts/dyno/web.sh index 296170bbb..aacd00e2f 100755 --- a/scripts/dyno/web.sh +++ b/scripts/dyno/web.sh @@ -2,7 +2,6 @@ WEB_WORKER_CONNECTION=${WEB_WORKER_CONNECTION:-200} # uvicorn_worker.UvicornWorker is incompatible with new relic -uvicorn.workers.UvicornWorker WEB_WORKER_CLASS=${WEB_WORKER_CLASS:-uvicorn.workers.UvicornWorker} CELERY_POOL=${CELERY_POOL:-prefork} WEB_WORKERS=${WEB_WORKERS:-2} diff --git a/test_settings.py b/test_settings.py index a8efe3536..7a67f602d 100644 --- a/test_settings.py +++ b/test_settings.py @@ -23,3 +23,5 @@ CACHE_MIDDLEWARE_SECONDS = 60 * int(os.getenv("CACHE_MIDDLEWARE_MINUTES", 120)) SECURE_SSL_REDIRECT = False + +CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}