diff --git a/.env.default b/.env.default index 9a36299a553..d9ec456e3bd 100644 --- a/.env.default +++ b/.env.default @@ -25,8 +25,8 @@ CORS__ALLOW_HEADERS=* # Authentication -AUTHENTICATION__ACCESS_TOKEN__SECRET_KEY="e51bcf5f4cb8550ff3f6a8bb4dfe112a3da2cf5142929e1b281cd974c88fa66c" -AUTHENTICATION__REFRESH_TOKEN__SECRET_KEY="5da342d54ed5659f123cdd1cefe439c5aaf7e317a0aba1405375c07d32e097cc" +AUTHENTICATION__ACCESS_TOKEN__SECRET_KEY="secret1" +AUTHENTICATION__REFRESH_TOKEN__SECRET_KEY="secret2" AUTHENTICATION__ACCESS_TOKEN__EXPIRATION=30 AUTHENTICATION__REFRESH_TOKEN__EXPIRATION=540 AUTHENTICATION__ALGORITHM="HS256" @@ -53,12 +53,14 @@ MAILING__VALIDATE_CERTS=False # FCM Notification api key NOTIFICATION__API_KEY= -# CDN configs -CDN__SECRET_KEY= -CDN__ACCESS_KEY= +# CDN configs (container by default) +CDN__ENDPOINT_URL=http://localhost:9000 +CDN__SECRET_KEY=miniosecret +CDN__ACCESS_KEY=minioaccess CDN__REGION= -CDN__BUCKET_ANSWERS= -CDN__BUCKET= +CDN__BUCKET_ANSWER=media +CDN__BUCKET=media +CDN__STORAGE_ADDRESS=http://localhost:9000 CDN__LEGACY_REGION= CDN__LEGACY_BUCKET= CDN__LEGACY_SECRET_KEY= diff --git a/.github/workflows/code_quality.yaml b/.github/workflows/code_quality.yaml index 29c6af089f9..ecc6d29da8b 100644 --- a/.github/workflows/code_quality.yaml +++ b/.github/workflows/code_quality.yaml @@ -6,10 +6,22 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + repository: 'awslabs/git-secrets' + ref: 'master' + - name: Install git-secrets + run: sudo make install + - uses: actions/checkout@v4 - uses: actions/setup-python@v3 with: python-version: '3.10' + - name: Install git-secrets in the repository + run: git secrets --install + - name: Install git-secrets aws register in the repository + run: git secrets --register-aws + - name: Scan aws secrets + run: git secrets --scan - name: Install pipenv run: python -m pip install --upgrade pip && pip install pipenv - name: Install deps diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 44ae2959685..963edee9d84 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,8 +29,8 @@ repos: entry: mypy language: system types: [python] - exclude: "apps/girderformindlogger" - + exclude: "apps/girderformindlogger|apps/migrate" + - id: git-secrets name: git-secrets entry: git secrets --scan diff --git a/Makefile b/Makefile index 09ba5d17403..7c802b461be 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,10 @@ cq: migrate: python src/apps/migrate/run.py +.PHONY: migrate_answer +migrate_answer: + python src/apps/migrate/answers/run.py + # ############### # Docker # ############### diff --git a/Pipfile b/Pipfile index 35ba3cd2d4b..4c352ab54ee 100644 --- a/Pipfile +++ b/Pipfile @@ -7,7 +7,7 @@ name = "pypi" aioredis = "~=2.0.1" alembic = "~=1.8.1" asyncpg = "~=0.27.0" -asynctest = "*" +asynctest = "==0.13.0" boto3 = "==1.26.10" fastapi = "==0.87.0" # The latest version of the fastapi is not taken because of the issue @@ -16,6 +16,7 @@ fastapi = "==0.87.0" fastapi-mail = "~=1.2.2" httpx = "~=0.23" jinja2 = "~=3.1.2" +bcrypt = "==4.0.1" passlib = {version = "~=1.7.4", extras = ["bcrypt"]} pillow = "~=9.3.0" psutil = "~=5.9.4" @@ -24,17 +25,20 @@ pydantic = {extras = ["email"], version = "~=1.10.2"} python-jose = {version = "~=3.3.0", extras = ["cryptography"]} python-multipart = "~=0.0.5" sentry-sdk = "~=1.6" -sqlalchemy = {extras = ["asyncio"], version = "~=1.4.46"} +sqlalchemy = {extras = ["asyncio"], version = "==1.4.49"} uvicorn = {extras = ["standard"], version = "==0.19"} -aiohttp = "*" -firebase-admin = "*" -aio-pika = "*" -pyld = "*" -azure-storage-blob = "*" -taskiq-fastapi = "*" -taskiq-redis = "*" -taskiq-aio-pika = "*" +taskiq = {extras = ["reload"], version = "==0.9.1"} +aiohttp = "==3.8.5" +firebase-admin = "==6.2.0" +aio-pika = "==9.3.0" +pyld = "==2.0.3" +azure-storage-blob = "==12.18.2" +taskiq-fastapi = "==0.3.0" +taskiq-redis = "==0.5.0" +taskiq-aio-pika = "==0.4.0" sqlalchemy-utils = "==0.41.1" +typer = {extras = ["all"], version = "==0.9.0"} +aiofiles = "==23.2.1" [dev-packages] black = "~=22.6" @@ -78,9 +82,10 @@ pycryptodomex = "==3.9.7" psycopg2-binary = "==2.9.6" cachetools = "==5.3.0" pyld = "==2.0.3" -types-requests = "*" -taskiq = {extras = ["reload"], version = "*"} -types-pytz = "*" - +types-requests = "==2.31.0.10" +types-pytz = "==2023.3.1.1" +gevent = "~=23.9" +types-aiofiles = "==23.2.0.0" +types-cachetools = "==5.3.0.7" [requires] python_version = "3.10" diff --git a/Pipfile.lock b/Pipfile.lock index 7dd0980716e..ffa44e13f62 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1f4dbf02e0bdc65866f8d2d788e59bb5534c221dfc09b2d57b8473d9522077f4" + "sha256": "9f2fb3c8611b8b3b0a3597e56d0a732a477471a86be9802076f3beae419aafad" }, "pipfile-spec": 6, "requires": { @@ -22,9 +22,16 @@ "sha256:3aeb60410403bb61c0c0483f6487a471bfcf1f2cc7b738d6f3f466b18641a8f0" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4.0'", "version": "==9.3.0" }, + "aiofiles": { + "hashes": [ + "sha256:19297512c647d4b27a2cf7c34caa7e405c0d60b5560618a29a9fe027b18b0107", + "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a" + ], + "index": "pypi", + "version": "==23.2.1" + }, "aiohttp": { "hashes": [ "sha256:00ad4b6f185ec67f3e6562e8a1d2b69660be43070bd0ef6fcec5211154c7df67", @@ -116,7 +123,6 @@ "sha256:fd1ed388ea7fbed22c4968dd64bab0198de60750a25fe8c0c9d4bef5abe13824" ], "index": "pypi", - "markers": "python_version >= '3.6'", "version": "==3.8.5" }, "aioredis": { @@ -125,7 +131,6 @@ "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e" ], "index": "pypi", - "markers": "python_version >= '3.6'", "version": "==2.0.1" }, "aiormq": { @@ -158,16 +163,15 @@ "sha256:cd0b5e45b14b706426b833f06369b9a6d5ee03f826ec3238723ce8caaf6e5ffa" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==1.8.1" }, "anyio": { "hashes": [ - "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f", - "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a" + "sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f", + "sha256:5a0bec7085176715be77df87fc66d6c9d70626bd752fcc85f57cdbee5b3760da" ], "markers": "python_version >= '3.8'", - "version": "==4.0.0" + "version": "==4.1.0" }, "async-timeout": { "hashes": [ @@ -217,7 +221,6 @@ "sha256:fddcacf695581a8d856654bc4c8cfb73d5c9df26d5f55201722d3e6a699e9629" ], "index": "pypi", - "markers": "python_full_version >= '3.7.0'", "version": "==0.27.0" }, "asynctest": { @@ -226,7 +229,6 @@ "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac" ], "index": "pypi", - "markers": "python_version >= '3.5'", "version": "==0.13.0" }, "attrs": { @@ -239,11 +241,11 @@ }, "azure-core": { "hashes": [ - "sha256:500b3aa9bf2e90c5ccc88bb105d056114ca0ce7d0ce73afb8bc4d714b2fc7568", - "sha256:b03261bcba22c0b9290faf9999cedd23e849ed2577feee90515694cea6bc74bf" + "sha256:0fa04b7b1f7d44a4fb8468c4093deb2ea01fdf4faddbf802ed9205615f99d68c", + "sha256:52983c89d394c6f881a121e5101c5fa67278ca3b1f339c8fb2ef39230c70e9ac" ], "markers": "python_version >= '3.7'", - "version": "==1.29.4" + "version": "==1.29.5" }, "azure-storage-blob": { "hashes": [ @@ -251,7 +253,6 @@ "sha256:ffd864bf9abf33dfc72c6ef37899a19bd9d585a946a2c61e288b4420c035df3a" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==12.18.2" }, "bcrypt": { @@ -278,15 +279,16 @@ "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3" ], + "index": "pypi", "version": "==4.0.1" }, "blinker": { "hashes": [ - "sha256:4afd3de66ef3a9f8067559fb7a1cbe555c17dcbe15971b05d1b625c3e7abe213", - "sha256:c3d739772abb7bc2860abf5f2ec284223d9ad5c76da018234f6f50d6f31ab1f0" + "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9", + "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182" ], - "markers": "python_version >= '3.7'", - "version": "==1.6.2" + "markers": "python_version >= '3.8'", + "version": "==1.7.0" }, "boto3": { "hashes": [ @@ -294,7 +296,6 @@ "sha256:48e579088ec320f84266bb26434a14ab3e375456feb0f3bf043f78c485a3cee2" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==1.26.10" }, "botocore": { @@ -315,169 +316,173 @@ }, "cachetools": { "hashes": [ - "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590", - "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b" + "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2", + "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1" ], "markers": "python_version >= '3.7'", - "version": "==5.3.1" + "version": "==5.3.2" }, "certifi": { "hashes": [ - "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", - "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" + "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", + "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" ], "markers": "python_version >= '3.6'", - "version": "==2023.7.22" + "version": "==2023.11.17" }, "cffi": { "hashes": [ - "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", - "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", - "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", - "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", - "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", - "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", - "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", - "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", - "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", - "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", - "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", - "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", - "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", - "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", - "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", - "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", - "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", - "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", - "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", - "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", - "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", - "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", - "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", - "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", - "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", - "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", - "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", - "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", - "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", - "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", - "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", - "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", - "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", - "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", - "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", - "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", - "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", - "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", - "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", - "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", - "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", - "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", - "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", - "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", - "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", - "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", - "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", - "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", - "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", - "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", - "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", - "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", - "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", - "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", - "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", - "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", - "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", - "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", - "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", - "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", - "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", - "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", - "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", - "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" - ], - "version": "==1.15.1" + "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", + "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", + "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", + "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", + "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", + "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", + "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", + "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", + "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", + "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", + "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", + "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", + "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", + "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", + "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", + "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", + "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", + "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", + "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", + "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", + "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", + "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", + "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", + "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", + "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", + "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", + "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", + "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", + "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", + "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", + "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", + "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", + "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", + "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", + "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", + "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", + "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", + "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", + "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", + "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", + "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", + "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", + "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", + "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", + "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", + "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", + "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", + "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", + "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", + "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", + "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", + "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" + ], + "markers": "python_version >= '3.8'", + "version": "==1.16.0" }, "charset-normalizer": { "hashes": [ - "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96", - "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c", - "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710", - "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706", - "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020", - "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252", - "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad", - "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329", - "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a", - "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f", - "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6", - "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4", - "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a", - "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46", - "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2", - "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23", - "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace", - "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd", - "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982", - "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10", - "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2", - "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea", - "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09", - "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5", - "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149", - "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489", - "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9", - "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80", - "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592", - "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3", - "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6", - "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed", - "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c", - "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200", - "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a", - "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e", - "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d", - "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6", - "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623", - "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669", - "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3", - "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa", - "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9", - "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2", - "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f", - "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1", - "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4", - "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a", - "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8", - "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3", - "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029", - "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f", - "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959", - "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22", - "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7", - "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952", - "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346", - "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e", - "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d", - "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299", - "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd", - "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a", - "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3", - "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037", - "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94", - "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c", - "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858", - "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a", - "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449", - "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c", - "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918", - "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1", - "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c", - "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", - "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" + "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", + "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", + "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", + "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", + "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", + "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", + "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", + "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", + "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", + "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", + "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", + "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", + "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", + "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", + "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", + "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", + "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", + "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", + "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", + "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", + "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", + "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", + "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", + "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", + "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", + "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", + "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", + "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", + "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", + "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", + "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", + "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", + "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", + "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", + "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", + "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", + "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", + "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", + "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", + "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", + "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", + "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", + "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", + "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", + "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", + "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", + "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", + "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", + "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", + "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", + "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", + "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", + "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", + "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", + "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", + "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", + "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", + "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", + "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", + "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", + "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", + "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", + "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", + "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", + "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", + "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", + "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", + "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", + "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", + "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", + "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", + "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", + "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", + "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", + "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", + "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", + "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", + "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", + "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", + "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", + "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", + "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", + "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", + "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", + "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", + "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", + "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", + "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", + "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", + "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.2.0" + "version": "==3.3.2" }, "click": { "hashes": [ @@ -487,6 +492,13 @@ "markers": "python_version >= '3.7'", "version": "==8.1.7" }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "version": "==0.4.6" + }, "cryptography": { "hashes": [ "sha256:0e70da4bdff7601b0ef48e6348339e490ebfb0cbe638e083c9c41fb49f00c8bd", @@ -545,11 +557,11 @@ }, "exceptiongroup": { "hashes": [ - "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", - "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" + "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", + "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" ], "markers": "python_version < '3.11'", - "version": "==1.1.3" + "version": "==1.2.0" }, "fastapi": { "hashes": [ @@ -557,7 +569,6 @@ "sha256:254453a2e22f64e2a1b4e1d8baf67d239e55b6c8165c079d25746a5220c81bb4" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==0.87.0" }, "fastapi-mail": { @@ -566,7 +577,6 @@ "sha256:6dc41bd47c2276145a5e6d7d8271082a78b19fc4d506883b277f6f0428c7fa9f" ], "index": "pypi", - "markers": "python_version < '4.0' and python_full_version >= '3.8.1'", "version": "==1.2.5" }, "firebase-admin": { @@ -575,51 +585,50 @@ "sha256:e3c42351fb6194d7279a6fd9209a947005fb4ee7e9037d19762e6cb3da4a82e1" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==6.2.0" }, "frozendict": { "hashes": [ - "sha256:0bc4767e2f83db5b701c787e22380296977368b0c57e485ca71b2eedfa11c4a3", - "sha256:145afd033ebfade28416093335261b8ec1af5cccc593482309e7add062ec8668", - "sha256:23c4bb46e6b8246e1e7e49b5593c2bc09221db0d8f31f7c092be8dfb42b9e620", - "sha256:2b2fd8ce36277919b36e3c834d2389f3cd7ac068ae730c312671dd4439a5dd65", - "sha256:2b3435e5f1ca5ae68a5e95e64b09d6d5c645cadd6b87569a0b3019dd248c8d00", - "sha256:313ed8d9ba6bac35d7635cd9580ee5721a0fb016f4d2d20f0efa05dbecbdb1be", - "sha256:3957d52f1906b0c85f641a1911d214255873f6408ab4e5ad657cc27a247fb145", - "sha256:4742e76c4111bd09198d3ab66cef94be8506212311338f9182d6ef5f5cb60493", - "sha256:47fc26468407fdeb428cfc89495b7921419e670355c21b383765482fdf6c5c14", - "sha256:4c258aab9c8488338634f2ec670ef049dbf0ab0e7a2fa9bc2c7b5009cb614801", - "sha256:5526559eca8f1780a4ee5146896f59afc31435313560208dd394a3a5e537d3ff", - "sha256:5e82befa7c385a668d569cebbebbdf49cee6fea4083f08e869a1b08cfb640a9f", - "sha256:638cf363d3cbca31a341503cf2219eac52a5f5140449676fae3d9644cd3c5487", - "sha256:6ea638228692db2bf94bce40ea4b25f4077588497b516bd16576575560094bd9", - "sha256:72cfe08ab8ae524e54848fa90b22d02c1b1ecfb3064438696bcaa4b953f18772", - "sha256:750632cc890d8ee9484fe6d31b261159144b6efacc08e1317fe46accd1410373", - "sha256:7a75bf87e76c4386caecdbdd02a99e53ad43a6b5c38fb3d5a634a9fc9ce41462", - "sha256:7ee5fe2658a8ac9a57f748acaf563f6a47f80b8308cbf0a04fac0ba057d41f75", - "sha256:80abe81d36e889ceec665e06ec764a7638000fa3e7be09786ac4d3ddc64b76db", - "sha256:8ccc94ac781710db44e142e1a11ff9b31d02c032c01c6868d51fcbef73086225", - "sha256:8cf35ddd25513428ec152614def9696afb93ae5ec0eb54fa6aa6206eda77ac4c", - "sha256:9a506d807858fa961aaa5b48dab6154fdc6bd045bbe9310788bbff141bb42d13", - "sha256:9ea5520e85447ff8d4681e181941e482662817ccba921b7cb3f87922056d892a", - "sha256:ba41a7ed019bd03b62d63ed3f8dea35b8243d1936f7c9ed4b5298ca45a01928e", - "sha256:c31abc8acea309b132dde441856829f6003a3d242da8b54bce4c0f2a3c8c63f0", - "sha256:d086440328a465dea9bef2dbad7548d75d1a0a0d21f43a08c03e1ec79ac5240e", - "sha256:d188d062084fba0e4bf32719ff7380b26c050b932ff164043ce82ab90587c52b", - "sha256:d3c6ce943946c2a61501c8cf116fff0892d11dd579877eb36e2aea2c27fddfef", - "sha256:da98427de26b5a2865727947480cbb53860089c4d195baa29c539da811cea617", - "sha256:e27c5c1d29d0eda7979253ec88abc239da1313b38f39f4b16984db3b3e482300", - "sha256:e4c785de7f1a13f15963945f400656b18f057c2fc76c089dacf127a2bb188c03", - "sha256:e72dbc1bcc2203cef38d205f692396f5505921a5680f66aa9a7e8bb71fd38f28", - "sha256:ed5a6c5c7a0f57269577c2a338a6002949aea21a23b7b7d06da7e7dced8b605b", - "sha256:f0f573dc4861dd7ec9e055c8cceaf45355e894e749f621f199aab7b311ac4bdb", - "sha256:f2a4e818ac457f6354401dcb631527af25e5a20fcfc81e6b5054b45fc245caca", - "sha256:f83fed36497af9562ead5e9fb8443224ba2781786bd3b92b1087cb7d0ff20135", - "sha256:ffc684773de7c88724788fa9787d0016fd75830412d58acbd9ed1a04762c675b" + "sha256:07208e4718cb70aa259ac886c19b96a4aad1cf00e9199f211746f738951bbf7c", + "sha256:0856af4f5b4288b2270e0b74078fad5cbaf4f799326b82183865f6f367008b2c", + "sha256:08a5829d708657c9d5ad58f4a7e4baa73a3d57290f9613bdd909d481fc203a3a", + "sha256:0cdd496933ddb428f3854bea9ffdce0245bb27c27909f663ad396409fb4dffb5", + "sha256:12b40526219f9583b30690011288bca4d6cce8724cda96b3c3ab08b67c5a7f09", + "sha256:1c015852dacf144dbeadf203673d8c714f788fcc2b810a36504994b3c4f5a436", + "sha256:66cded65f144393b4226bda9fe9ac2f42451d2d603e8a486015744bb566a7008", + "sha256:6b552fffeba8e41b43ce10cc0fc467e048a7c9a71ae3241057510342132555b9", + "sha256:6d40d0644f19365fc6cc428db31c0f113fa550bd15920262f9d77ccf6556d87b", + "sha256:6f8681c0ffe92be9aba40c9b9960c48f0ae7f6ea585af2b93fc9542cc3865969", + "sha256:7901828700f36fe12486705afe7afc5583434390c8f69b5419de1b6c566fb00d", + "sha256:7b4d05e231dc1a2ec874f847fd7348cbee469555468efb875a89994ecde31a81", + "sha256:809bb9c6c657bded925710a309bb2a2350bdbfdc9371df427f1a93cb8ab7ec3e", + "sha256:89218738e2122b50bf8a0444083dbe2de280402e9c2ef0929c0db0f93ff11271", + "sha256:893205dc5a4e5c4b24e5822ceb21ef14fed8ca4afae7ac688e2fc24294c85225", + "sha256:8c4ca4cc42bc30b20476616411d4b49aae6084760b99251f1cbdfed879ae53ea", + "sha256:8e7abf4539b73c8e5680dd2fdbd19ca4fc3e2b2f3666f80f022217839bb859fd", + "sha256:901e774629fc63f84d24b5e46b59de1eed22392ee98b7f92e694a127d541edac", + "sha256:93634af5a6d71762aebc7d78bdce92890b7e612588faf887c9eaf752dc7ccdb1", + "sha256:99b2f47b292cc4d68f6679918e8e9e6dc5e816924d8369d07018be56b93fb20f", + "sha256:9df392b655fadaa0174c1923e6205b30ad1ccca248e8e146e63a8147a355ee01", + "sha256:a0065db2bc76628853dd620bd08c1ca44ad0b711e92e89b4156493153add6f9d", + "sha256:aa11add43a71fd47523fbd011be5cc011df79e25ec0b0339fc0d728623aaa7ec", + "sha256:aadc83510ce82751a0bb3575231f778bc37cbb373f5f05a52b888e26cbb92f79", + "sha256:ac41c671ff33cbefc0f06c4b2a630d18ab59f5256f45f57d5632252ae4a8c07a", + "sha256:af267bd6d98cbc10580105dc76f28f7156856fa48a5bbcadd40edb85f93657ae", + "sha256:b089c7e8c95d8b043e82e7da26e165f4220d7310efaad5e94445db7e3bc8321e", + "sha256:b10df7f5d8637b1af319434f99dc25ca6f5537e28b293e4c405ebfb4bf9581fa", + "sha256:bb9f15a5ed924be2b1cb3654b7ea3b7bae265ff39e2b5784d42bd4a6e1353e45", + "sha256:c112024df64b8926a315d7e36b860967fcad8aae0c592b9f117589391373e893", + "sha256:c865962216f7cfd6dac8693f4de431a9d98a7225185ff23613ecd10c42423adc", + "sha256:c9aa28ce48d848ee520409533fd0254de4caf025c5cf1b9f27c98c1dd8cf90aa", + "sha256:da22a3e873f365f97445c49afc1e6d5198ed6d172f3efaf0e9fde0edcca3cea1", + "sha256:df2d2afa5af41bfa09dc9d5a8e6d73ae39b677a8572200c65a5ea353387ffccd", + "sha256:e78c5ac5d71f3b73f07ff9d9e3cc32dfbf7954f2c57c2d0e1fe8f1600e980b40", + "sha256:e8bec6d11f7254e405290cb1b081caffa0c18b6aa779130da9a546349c56be83", + "sha256:ff7a9cca3a3a1e584349e859d028388bd96a5475f76721471b73797472c6db17" ], "markers": "python_version >= '3.6'", - "version": "==2.3.8" + "version": "==2.3.10" }, "frozenlist": { "hashes": [ @@ -688,32 +697,38 @@ "markers": "python_version >= '3.8'", "version": "==1.4.0" }, + "gitignore-parser": { + "hashes": [ + "sha256:270cb8cd09de410b8805c5f4183fd404c28f910dcbb94e1efc08226144fdff7d" + ], + "version": "==0.1.9" + }, "google-api-core": { "extras": [ "grpc" ], "hashes": [ - "sha256:c22e01b1e3c4dcd90998494879612c38d0a3411d1f7b679eb89e2abe3ce1f553", - "sha256:ec6054f7d64ad13b41e43d96f735acbd763b0f3b695dabaa2d579673f6a6e160" + "sha256:5368a4502b793d9bbf812a5912e13e4e69f9bd87f6efb508460c43f5bbd1ce41", + "sha256:de2fb50ed34d47ddbb2bd2dcf680ee8fead46279f4ed6b16de362aca23a18952" ], "markers": "platform_python_implementation != 'PyPy'", - "version": "==2.12.0" + "version": "==2.14.0" }, "google-api-python-client": { "hashes": [ - "sha256:71760dcf11d191b65520d1c13757a776f4f43cf87f302097a0d8e491c2ef87b0", - "sha256:e9620a809251174818e1fce16604006f10a9c2ac0d3d94a139cdddcd4dbea2d8" + "sha256:72e7d46cc70908d808e29f16d983b441783fe56b694cec132db9af9fb991daa2", + "sha256:d06390c25477c361d52639fe00ef912c3fab8dafc7fbf29580c1144e92523a79" ], "markers": "python_version >= '3.7'", - "version": "==2.101.0" + "version": "==2.109.0" }, "google-auth": { "hashes": [ - "sha256:2cec41407bd1e207f5b802638e32bb837df968bb5c05f413d0fa526fac4cf7a7", - "sha256:753a26312e6f1eaeec20bc6f2644a10926697da93446e1f8e24d6d32d45a922a" + "sha256:d5d66b8f4f6e3273740d7bb73ddefa6c2d1ff691704bd407d51c6b5800e7c97b", + "sha256:dfd7b44935d498e106c08883b2dac0ad36d8aa10402a6412e9a1c9d74b4773f1" ], "markers": "python_version >= '3.7'", - "version": "==2.23.0" + "version": "==2.25.1" }, "google-auth-httplib2": { "hashes": [ @@ -732,19 +747,19 @@ }, "google-cloud-firestore": { "hashes": [ - "sha256:3eedc9b2238d8fdb6c2645da455de7ba8df0a9a1d253815932ac3a98c5eec9cd", - "sha256:8dbdbe2fd96f2651076ec1a6eb9b68361ed7c560934a927f2ca55c8c5aadfb30" + "sha256:8b9ace47f8aeb561a6fd74620a7335e30c48a25e18dfedbd0d7923695768d156", + "sha256:bd14d2eb9ae358d21058ce091c13dea11917e26f1c43738a51e9cea8b49b4f38" ], "markers": "platform_python_implementation != 'PyPy'", - "version": "==2.12.0" + "version": "==2.13.1" }, "google-cloud-storage": { "hashes": [ - "sha256:6fbf62659b83c8f3a0a743af0d661d2046c97c3a5bfb587c4662c4bc68de3e31", - "sha256:88cbd7fb3d701c780c4272bc26952db99f25eb283fb4c2208423249f00b5fe53" + "sha256:ab0bf2e1780a1b74cf17fccb13788070b729f50c252f0c94ada2aae0ca95437d", + "sha256:f62dc4c7b6cd4360d072e3deb28035fbdad491ac3d9b0b1815a12daea10f37c7" ], "markers": "python_version >= '3.7'", - "version": "==2.11.0" + "version": "==2.13.0" }, "google-crc32c": { "hashes": [ @@ -830,138 +845,140 @@ }, "googleapis-common-protos": { "hashes": [ - "sha256:69f9bbcc6acde92cab2db95ce30a70bd2b81d20b12eff3f1aabaffcbe8a93918", - "sha256:e73ebb404098db405ba95d1e1ae0aa91c3e15a71da031a2eeb6b2e23e7bc3708" + "sha256:22f1915393bb3245343f6efe87f6fe868532efc12aa26b391b15132e1279f1c0", + "sha256:8a64866a97f6304a7179873a465d6eee97b7a24ec6cfd78e0f575e96b821240b" ], "markers": "python_version >= '3.7'", - "version": "==1.60.0" + "version": "==1.61.0" }, "greenlet": { "hashes": [ - "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a", - "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a", - "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1", - "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43", - "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33", - "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8", - "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088", - "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca", - "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343", - "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645", - "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db", - "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df", - "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3", - "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86", - "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2", - "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a", - "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf", - "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7", - "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394", - "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40", - "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3", - "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6", - "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74", - "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0", - "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3", - "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91", - "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5", - "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9", - "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417", - "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8", - "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b", - "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6", - "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb", - "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73", - "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b", - "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df", - "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9", - "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f", - "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0", - "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857", - "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a", - "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249", - "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30", - "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292", - "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b", - "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d", - "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b", - "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c", - "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca", - "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7", - "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75", - "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae", - "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47", - "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b", - "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470", - "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c", - "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564", - "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9", - "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099", - "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0", - "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5", - "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19", - "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1", - "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526" + "sha256:0a02d259510b3630f330c86557331a3b0e0c79dac3d166e449a39363beaae174", + "sha256:0b6f9f8ca7093fd4433472fd99b5650f8a26dcd8ba410e14094c1e44cd3ceddd", + "sha256:100f78a29707ca1525ea47388cec8a049405147719f47ebf3895e7509c6446aa", + "sha256:1757936efea16e3f03db20efd0cd50a1c86b06734f9f7338a90c4ba85ec2ad5a", + "sha256:19075157a10055759066854a973b3d1325d964d498a805bb68a1f9af4aaef8ec", + "sha256:19bbdf1cce0346ef7341705d71e2ecf6f41a35c311137f29b8a2dc2341374565", + "sha256:20107edf7c2c3644c67c12205dc60b1bb11d26b2610b276f97d666110d1b511d", + "sha256:22f79120a24aeeae2b4471c711dcf4f8c736a2bb2fabad2a67ac9a55ea72523c", + "sha256:2847e5d7beedb8d614186962c3d774d40d3374d580d2cbdab7f184580a39d234", + "sha256:28e89e232c7593d33cac35425b58950789962011cc274aa43ef8865f2e11f46d", + "sha256:329c5a2e5a0ee942f2992c5e3ff40be03e75f745f48847f118a3cfece7a28546", + "sha256:337322096d92808f76ad26061a8f5fccb22b0809bea39212cd6c406f6a7060d2", + "sha256:3fcc780ae8edbb1d050d920ab44790201f027d59fdbd21362340a85c79066a74", + "sha256:41bdeeb552d814bcd7fb52172b304898a35818107cc8778b5101423c9017b3de", + "sha256:4eddd98afc726f8aee1948858aed9e6feeb1758889dfd869072d4465973f6bfd", + "sha256:52e93b28db27ae7d208748f45d2db8a7b6a380e0d703f099c949d0f0d80b70e9", + "sha256:55d62807f1c5a1682075c62436702aaba941daa316e9161e4b6ccebbbf38bda3", + "sha256:5805e71e5b570d490938d55552f5a9e10f477c19400c38bf1d5190d760691846", + "sha256:599daf06ea59bfedbec564b1692b0166a0045f32b6f0933b0dd4df59a854caf2", + "sha256:60d5772e8195f4e9ebf74046a9121bbb90090f6550f81d8956a05387ba139353", + "sha256:696d8e7d82398e810f2b3622b24e87906763b6ebfd90e361e88eb85b0e554dc8", + "sha256:6e6061bf1e9565c29002e3c601cf68569c450be7fc3f7336671af7ddb4657166", + "sha256:80ac992f25d10aaebe1ee15df45ca0d7571d0f70b645c08ec68733fb7a020206", + "sha256:816bd9488a94cba78d93e1abb58000e8266fa9cc2aa9ccdd6eb0696acb24005b", + "sha256:85d2b77e7c9382f004b41d9c72c85537fac834fb141b0296942d52bf03fe4a3d", + "sha256:87c8ceb0cf8a5a51b8008b643844b7f4a8264a2c13fcbcd8a8316161725383fe", + "sha256:89ee2e967bd7ff85d84a2de09df10e021c9b38c7d91dead95b406ed6350c6997", + "sha256:8bef097455dea90ffe855286926ae02d8faa335ed8e4067326257cb571fc1445", + "sha256:8d11ebbd679e927593978aa44c10fc2092bc454b7d13fdc958d3e9d508aba7d0", + "sha256:91e6c7db42638dc45cf2e13c73be16bf83179f7859b07cfc139518941320be96", + "sha256:97e7ac860d64e2dcba5c5944cfc8fa9ea185cd84061c623536154d5a89237884", + "sha256:990066bff27c4fcf3b69382b86f4c99b3652bab2a7e685d968cd4d0cfc6f67c6", + "sha256:9fbc5b8f3dfe24784cee8ce0be3da2d8a79e46a276593db6868382d9c50d97b1", + "sha256:ac4a39d1abae48184d420aa8e5e63efd1b75c8444dd95daa3e03f6c6310e9619", + "sha256:b2c02d2ad98116e914d4f3155ffc905fd0c025d901ead3f6ed07385e19122c94", + "sha256:b2d3337dcfaa99698aa2377c81c9ca72fcd89c07e7eb62ece3f23a3fe89b2ce4", + "sha256:b489c36d1327868d207002391f662a1d163bdc8daf10ab2e5f6e41b9b96de3b1", + "sha256:b641161c302efbb860ae6b081f406839a8b7d5573f20a455539823802c655f63", + "sha256:b8ba29306c5de7717b5761b9ea74f9c72b9e2b834e24aa984da99cbfc70157fd", + "sha256:b9934adbd0f6e476f0ecff3c94626529f344f57b38c9a541f87098710b18af0a", + "sha256:ce85c43ae54845272f6f9cd8320d034d7a946e9773c693b27d620edec825e376", + "sha256:cf868e08690cb89360eebc73ba4be7fb461cfbc6168dd88e2fbbe6f31812cd57", + "sha256:d2905ce1df400360463c772b55d8e2518d0e488a87cdea13dd2c71dcb2a1fa16", + "sha256:d57e20ba591727da0c230ab2c3f200ac9d6d333860d85348816e1dca4cc4792e", + "sha256:d6a8c9d4f8692917a3dc7eb25a6fb337bff86909febe2f793ec1928cd97bedfc", + "sha256:d923ff276f1c1f9680d32832f8d6c040fe9306cbfb5d161b0911e9634be9ef0a", + "sha256:daa7197b43c707462f06d2c693ffdbb5991cbb8b80b5b984007de431493a319c", + "sha256:dbd4c177afb8a8d9ba348d925b0b67246147af806f0b104af4d24f144d461cd5", + "sha256:dc4d815b794fd8868c4d67602692c21bf5293a75e4b607bb92a11e821e2b859a", + "sha256:e9d21aaa84557d64209af04ff48e0ad5e28c5cca67ce43444e939579d085da72", + "sha256:ea6b8aa9e08eea388c5f7a276fabb1d4b6b9d6e4ceb12cc477c3d352001768a9", + "sha256:eabe7090db68c981fca689299c2d116400b553f4b713266b130cfc9e2aa9c5a9", + "sha256:f2f6d303f3dee132b322a14cd8765287b8f86cdc10d2cb6a6fae234ea488888e", + "sha256:f33f3258aae89da191c6ebaa3bc517c6c4cbc9b9f689e5d8452f7aedbb913fa8", + "sha256:f7bfb769f7efa0eefcd039dd19d843a4fbfbac52f1878b1da2ed5793ec9b1a65", + "sha256:f89e21afe925fcfa655965ca8ea10f24773a1791400989ff32f467badfe4a064", + "sha256:fa24255ae3c0ab67e613556375a4341af04a084bd58764731972bcbc8baeba36" ], "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": "==2.0.2" + "version": "==3.0.1" }, "grpcio": { "hashes": [ - "sha256:002f228d197fea12797a14e152447044e14fb4fdb2eb5d6cfa496f29ddbf79ef", - "sha256:039003a5e0ae7d41c86c768ef8b3ee2c558aa0a23cf04bf3c23567f37befa092", - "sha256:09206106848462763f7f273ca93d2d2d4d26cab475089e0de830bb76be04e9e8", - "sha256:128eb1f8e70676d05b1b0c8e6600320fc222b3f8c985a92224248b1367122188", - "sha256:1c1c5238c6072470c7f1614bf7c774ffde6b346a100521de9ce791d1e4453afe", - "sha256:1ed979b273a81de36fc9c6716d9fb09dd3443efa18dcc8652501df11da9583e9", - "sha256:201e550b7e2ede113b63e718e7ece93cef5b0fbf3c45e8fe4541a5a4305acd15", - "sha256:212f38c6a156862098f6bdc9a79bf850760a751d259d8f8f249fc6d645105855", - "sha256:24765a627eb4d9288ace32d5104161c3654128fe27f2808ecd6e9b0cfa7fc8b9", - "sha256:24edec346e69e672daf12b2c88e95c6f737f3792d08866101d8c5f34370c54fd", - "sha256:2ef8d4a76d2c7d8065aba829f8d0bc0055495c998dce1964ca5b302d02514fb3", - "sha256:2f85f87e2f087d9f632c085b37440a3169fda9cdde80cb84057c2fc292f8cbdf", - "sha256:3886b4d56bd4afeac518dbc05933926198aa967a7d1d237a318e6fbc47141577", - "sha256:3e6bebf1dfdbeb22afd95650e4f019219fef3ab86d3fca8ebade52e4bc39389a", - "sha256:458899d2ebd55d5ca2350fd3826dfd8fcb11fe0f79828ae75e2b1e6051d50a29", - "sha256:4891bbb4bba58acd1d620759b3be11245bfe715eb67a4864c8937b855b7ed7fa", - "sha256:4b12754af201bb993e6e2efd7812085ddaaef21d0a6f0ff128b97de1ef55aa4a", - "sha256:532410c51ccd851b706d1fbc00a87be0f5312bd6f8e5dbf89d4e99c7f79d7499", - "sha256:5b23d75e5173faa3d1296a7bedffb25afd2fddb607ef292dfc651490c7b53c3d", - "sha256:62831d5e251dd7561d9d9e83a0b8655084b2a1f8ea91e4bd6b3cedfefd32c9d2", - "sha256:652978551af02373a5a313e07bfef368f406b5929cf2d50fa7e4027f913dbdb4", - "sha256:6801ff6652ecd2aae08ef994a3e49ff53de29e69e9cd0fd604a79ae4e545a95c", - "sha256:6cba491c638c76d3dc6c191d9c75041ca5b8f5c6de4b8327ecdcab527f130bb4", - "sha256:7e473a7abad9af48e3ab5f3b5d237d18208024d28ead65a459bd720401bd2f8f", - "sha256:8774219e21b05f750eef8adc416e9431cf31b98f6ce9def288e4cea1548cbd22", - "sha256:8f061722cad3f9aabb3fbb27f3484ec9d4667b7328d1a7800c3c691a98f16bb0", - "sha256:92ae871a902cf19833328bd6498ec007b265aabf2fda845ab5bd10abcaf4c8c6", - "sha256:9f13a171281ebb4d7b1ba9f06574bce2455dcd3f2f6d1fbe0fd0d84615c74045", - "sha256:a2d67ff99e70e86b2be46c1017ae40b4840d09467d5455b2708de6d4c127e143", - "sha256:b5e8db0aff0a4819946215f156bd722b6f6c8320eb8419567ffc74850c9fd205", - "sha256:ba0af11938acf8cd4cf815c46156bcde36fa5850518120920d52620cc3ec1830", - "sha256:bc325fed4d074367bebd465a20763586e5e1ed5b943e9d8bc7c162b1f44fd602", - "sha256:bc7ffef430b80345729ff0a6825e9d96ac87efe39216e87ac58c6c4ef400de93", - "sha256:cde11577d5b6fd73a00e6bfa3cf5f428f3f33c2d2878982369b5372bbc4acc60", - "sha256:d4cef77ad2fed42b1ba9143465856d7e737279854e444925d5ba45fc1f3ba727", - "sha256:d79b660681eb9bc66cc7cbf78d1b1b9e335ee56f6ea1755d34a31108b80bd3c8", - "sha256:d81c2b2b24c32139dd2536972f1060678c6b9fbd106842a9fcdecf07b233eccd", - "sha256:dc72e04620d49d3007771c0e0348deb23ca341c0245d610605dddb4ac65a37cb", - "sha256:dcfba7befe3a55dab6fe1eb7fc9359dc0c7f7272b30a70ae0af5d5b063842f28", - "sha256:e9f995a8a421405958ff30599b4d0eec244f28edc760de82f0412c71c61763d2", - "sha256:eb6b92036ff312d5b4182fa72e8735d17aceca74d0d908a7f08e375456f03e07", - "sha256:f0241f7eb0d2303a545136c59bc565a35c4fc3b924ccbd69cb482f4828d6f31c", - "sha256:fad9295fe02455d4f158ad72c90ef8b4bcaadfdb5efb5795f7ab0786ad67dd58", - "sha256:fbcecb6aedd5c1891db1d70efbfbdc126c986645b5dd616a045c07d6bd2dfa86", - "sha256:fe643af248442221db027da43ed43e53b73e11f40c9043738de9a2b4b6ca7697" - ], - "version": "==1.58.0" + "sha256:00912ce19914d038851be5cd380d94a03f9d195643c28e3ad03d355cc02ce7e8", + "sha256:0511af8653fbda489ff11d542a08505d56023e63cafbda60e6e00d4e0bae86ea", + "sha256:0814942ba1bba269db4e760a34388640c601dece525c6a01f3b4ff030cc0db69", + "sha256:0d42048b8a3286ea4134faddf1f9a59cf98192b94aaa10d910a25613c5eb5bfb", + "sha256:0e735ed002f50d4f3cb9ecfe8ac82403f5d842d274c92d99db64cfc998515e07", + "sha256:16da0e40573962dab6cba16bec31f25a4f468e6d05b658e589090fe103b03e3d", + "sha256:1736496d74682e53dd0907fd515f2694d8e6a96c9a359b4080b2504bf2b2d91b", + "sha256:19ad26a7967f7999c8960d2b9fe382dae74c55b0c508c613a6c2ba21cddf2354", + "sha256:33b8fd65d4e97efa62baec6171ce51f9cf68f3a8ba9f866f4abc9d62b5c97b79", + "sha256:36636babfda14f9e9687f28d5b66d349cf88c1301154dc71c6513de2b6c88c59", + "sha256:3996aaa21231451161dc29df6a43fcaa8b332042b6150482c119a678d007dd86", + "sha256:45dddc5cb5227d30fa43652d8872dc87f086d81ab4b500be99413bad0ae198d7", + "sha256:4619fea15c64bcdd9d447cdbdde40e3d5f1da3a2e8ae84103d94a9c1df210d7e", + "sha256:52cc38a7241b5f7b4a91aaf9000fdd38e26bb00d5e8a71665ce40cfcee716281", + "sha256:575d61de1950b0b0699917b686b1ca108690702fcc2df127b8c9c9320f93e069", + "sha256:5f9b2e591da751ac7fdd316cc25afafb7a626dededa9b414f90faad7f3ccebdb", + "sha256:60cddafb70f9a2c81ba251b53b4007e07cca7389e704f86266e22c4bffd8bf1d", + "sha256:6a5c3a96405966c023e139c3bcccb2c7c776a6f256ac6d70f8558c9041bdccc3", + "sha256:6c75a1fa0e677c1d2b6d4196ad395a5c381dfb8385f07ed034ef667cdcdbcc25", + "sha256:72b71dad2a3d1650e69ad42a5c4edbc59ee017f08c32c95694172bc501def23c", + "sha256:73afbac602b8f1212a50088193601f869b5073efa9855b3e51aaaec97848fc8a", + "sha256:7800f99568a74a06ebdccd419dd1b6e639b477dcaf6da77ea702f8fb14ce5f80", + "sha256:8022ca303d6c694a0d7acfb2b472add920217618d3a99eb4b14edc7c6a7e8fcf", + "sha256:8239b853226e4824e769517e1b5232e7c4dda3815b200534500338960fcc6118", + "sha256:83113bcc393477b6f7342b9f48e8a054330c895205517edc66789ceea0796b53", + "sha256:8cd76057b5c9a4d68814610ef9226925f94c1231bbe533fdf96f6181f7d2ff9e", + "sha256:8d993399cc65e3a34f8fd48dd9ad7a376734564b822e0160dd18b3d00c1a33f9", + "sha256:95b5506e70284ac03b2005dd9ffcb6708c9ae660669376f0192a710687a22556", + "sha256:95d6fd804c81efe4879e38bfd84d2b26e339a0a9b797e7615e884ef4686eb47b", + "sha256:9e17660947660ccfce56c7869032910c179a5328a77b73b37305cd1ee9301c2e", + "sha256:a93a82876a4926bf451db82ceb725bd87f42292bacc94586045261f501a86994", + "sha256:aca028a6c7806e5b61e5f9f4232432c52856f7fcb98e330b20b6bc95d657bdcc", + "sha256:b1f00a3e6e0c3dccccffb5579fc76ebfe4eb40405ba308505b41ef92f747746a", + "sha256:b36683fad5664283755a7f4e2e804e243633634e93cd798a46247b8e54e3cb0d", + "sha256:b491e5bbcad3020a96842040421e508780cade35baba30f402df9d321d1c423e", + "sha256:c0bd141f4f41907eb90bda74d969c3cb21c1c62779419782a5b3f5e4b5835718", + "sha256:c0f0a11d82d0253656cc42e04b6a149521e02e755fe2e4edd21123de610fd1d4", + "sha256:c4b0076f0bf29ee62335b055a9599f52000b7941f577daa001c7ef961a1fbeab", + "sha256:c82ca1e4be24a98a253d6dbaa216542e4163f33f38163fc77964b0f0d255b552", + "sha256:cb4e9cbd9b7388fcb06412da9f188c7803742d06d6f626304eb838d1707ec7e3", + "sha256:cdbc6b32fadab9bebc6f49d3e7ec4c70983c71e965497adab7f87de218e84391", + "sha256:ce31fa0bfdd1f2bb15b657c16105c8652186eab304eb512e6ae3b99b2fdd7d13", + "sha256:d1d1a17372fd425addd5812049fa7374008ffe689585f27f802d0935522cf4b7", + "sha256:d787ecadea865bdf78f6679f6f5bf4b984f18f659257ba612979df97a298b3c3", + "sha256:ddbd1a16138e52e66229047624de364f88a948a4d92ba20e4e25ad7d22eef025", + "sha256:e1d8e01438d5964a11167eec1edb5f85ed8e475648f36c834ed5db4ffba24ac8", + "sha256:e58b3cadaa3c90f1efca26ba33e0d408b35b497307027d3d707e4bcd8de862a6", + "sha256:e78dc982bda74cef2ddfce1c91d29b96864c4c680c634e279ed204d51e227473", + "sha256:ea40ce4404e7cca0724c91a7404da410f0144148fdd58402a5942971e3469b94", + "sha256:eb8ba504c726befe40a356ecbe63c6c3c64c9a439b3164f5a718ec53c9874da0", + "sha256:ed26826ee423b11477297b187371cdf4fa1eca874eb1156422ef3c9a60590dd9", + "sha256:f2eb8f0c7c0c62f7a547ad7a91ba627a5aa32a5ae8d930783f7ee61680d7eb8d", + "sha256:fb111aa99d3180c361a35b5ae1e2c63750220c584a1344229abc139d5c891881", + "sha256:fcfa56f8d031ffda902c258c84c4b88707f3a4be4827b4e3ab8ec7c24676320d" + ], + "version": "==1.59.3" }, "grpcio-status": { "hashes": [ - "sha256:0b42e70c0405a66a82d9e9867fa255fe59e618964a6099b20568c31dd9099766", - "sha256:36d46072b71a00147709ebce49344ac59b4b8960942acf0f813a8a7d6c1c28e0" + "sha256:2fd2eb39ca4e9afb3c874c0878ff75b258db0b7dcc25570fc521f16ae0ab942a", + "sha256:65c394ba43380d6bdf8c04c61efc493104b5535552aed35817a1b4dc66598a1f" ], - "version": "==1.58.0" + "version": "==1.59.3" }, "h11": { "hashes": [ @@ -973,11 +990,11 @@ }, "httpcore": { "hashes": [ - "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9", - "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced" + "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7", + "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535" ], "markers": "python_version >= '3.8'", - "version": "==0.18.0" + "version": "==1.0.2" }, "httplib2": { "hashes": [ @@ -989,68 +1006,68 @@ }, "httptools": { "hashes": [ - "sha256:03bfd2ae8a2d532952ac54445a2fb2504c804135ed28b53fefaf03d3a93eb1fd", - "sha256:0781fedc610293a2716bc7fa142d4c85e6776bc59d617a807ff91246a95dea35", - "sha256:0d0b0571806a5168013b8c3d180d9f9d6997365a4212cb18ea20df18b938aa0b", - "sha256:0fb4a608c631f7dcbdf986f40af7a030521a10ba6bc3d36b28c1dc9e9035a3c0", - "sha256:22c01fcd53648162730a71c42842f73b50f989daae36534c818b3f5050b54589", - "sha256:23b09537086a5a611fad5696fc8963d67c7e7f98cb329d38ee114d588b0b74cd", - "sha256:259920bbae18740a40236807915def554132ad70af5067e562f4660b62c59b90", - "sha256:26326e0a8fe56829f3af483200d914a7cd16d8d398d14e36888b56de30bec81a", - "sha256:274bf20eeb41b0956e34f6a81f84d26ed57c84dd9253f13dcb7174b27ccd8aaf", - "sha256:33eb1d4e609c835966e969a31b1dedf5ba16b38cab356c2ce4f3e33ffa94cad3", - "sha256:35a541579bed0270d1ac10245a3e71e5beeb1903b5fbbc8d8b4d4e728d48ff1d", - "sha256:38f3cafedd6aa20ae05f81f2e616ea6f92116c8a0f8dcb79dc798df3356836e2", - "sha256:3f96d2a351b5625a9fd9133c95744e8ca06f7a4f8f0b8231e4bbaae2c485046a", - "sha256:463c3bc5ef64b9cf091be9ac0e0556199503f6e80456b790a917774a616aff6e", - "sha256:47043a6e0ea753f006a9d0dd076a8f8c99bc0ecae86a0888448eb3076c43d717", - "sha256:4e748fc0d5c4a629988ef50ac1aef99dfb5e8996583a73a717fc2cac4ab89932", - "sha256:5dcc14c090ab57b35908d4a4585ec5c0715439df07be2913405991dbb37e049d", - "sha256:65d802e7b2538a9756df5acc062300c160907b02e15ed15ba035b02bce43e89c", - "sha256:6bdc6675ec6cb79d27e0575750ac6e2b47032742e24eed011b8db73f2da9ed40", - "sha256:6e22896b42b95b3237eccc42278cd72c0df6f23247d886b7ded3163452481e38", - "sha256:721e503245d591527cddd0f6fd771d156c509e831caa7a57929b55ac91ee2b51", - "sha256:72205730bf1be875003692ca54a4a7c35fac77b4746008966061d9d41a61b0f5", - "sha256:72ec7c70bd9f95ef1083d14a755f321d181f046ca685b6358676737a5fecd26a", - "sha256:73e9d66a5a28b2d5d9fbd9e197a31edd02be310186db423b28e6052472dc8201", - "sha256:818325afee467d483bfab1647a72054246d29f9053fd17cc4b86cda09cc60339", - "sha256:82c723ed5982f8ead00f8e7605c53e55ffe47c47465d878305ebe0082b6a1755", - "sha256:82f228b88b0e8c6099a9c4757ce9fdbb8b45548074f8d0b1f0fc071e35655d1c", - "sha256:93f89975465133619aea8b1952bc6fa0e6bad22a447c6d982fc338fbb4c89649", - "sha256:9fc6e409ad38cbd68b177cd5158fc4042c796b82ca88d99ec78f07bed6c6b796", - "sha256:b0a816bb425c116a160fbc6f34cece097fd22ece15059d68932af686520966bd", - "sha256:b703d15dbe082cc23266bf5d9448e764c7cb3fcfe7cb358d79d3fd8248673ef9", - "sha256:cf8169e839a0d740f3d3c9c4fa630ac1a5aaf81641a34575ca6773ed7ce041a1", - "sha256:dea66d94e5a3f68c5e9d86e0894653b87d952e624845e0b0e3ad1c733c6cc75d", - "sha256:e41ccac9e77cd045f3e4ee0fc62cbf3d54d7d4b375431eb855561f26ee7a9ec4", - "sha256:f959e4770b3fc8ee4dbc3578fd910fab9003e093f20ac8c621452c4d62e517cb" - ], - "version": "==0.6.0" + "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:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100", - "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875" + "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8", + "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==0.25.0" + "version": "==0.25.2" }, "idna": { "hashes": [ - "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", - "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", + "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" ], "markers": "python_version >= '3.5'", - "version": "==3.4" + "version": "==3.6" }, "importlib-metadata": { "hashes": [ - "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb", - "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743" + "sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7", + "sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67" ], "markers": "python_version >= '3.8'", - "version": "==6.8.0" + "version": "==7.0.0" }, "isodate": { "hashes": [ @@ -1065,7 +1082,6 @@ "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==3.1.2" }, "jmespath": { @@ -1176,11 +1192,19 @@ }, "mako": { "hashes": [ - "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818", - "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34" + "sha256:57d4e997349f1a92035aa25c17ace371a4213f2ca42f99bee9a602500cfd54d9", + "sha256:e3a9d388fd00e87043edbe8792f45880ac0114e9c4adc69f6e9bfb2c55e3b11b" ], - "markers": "python_version >= '3.7'", - "version": "==1.2.4" + "markers": "python_version >= '3.8'", + "version": "==1.3.0" + }, + "markdown-it-py": { + "hashes": [ + "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", + "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.0" }, "markupsafe": { "hashes": [ @@ -1248,67 +1272,75 @@ "markers": "python_version >= '3.7'", "version": "==2.1.3" }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, "msgpack": { "hashes": [ - "sha256:00ce5f827d4f26fc094043e6f08b6069c1b148efa2631c47615ae14fb6cafc89", - "sha256:04450e4b5e1e662e7c86b6aafb7c230af9334fd0becf5e6b80459a507884241c", - "sha256:099c3d8a027367e1a6fc55d15336f04ff65c60c4f737b5739f7db4525c65fe9e", - "sha256:102cfb54eaefa73e8ca1e784b9352c623524185c98e057e519545131a56fb0af", - "sha256:14db7e1b7a7ed362b2f94897bf2486c899c8bb50f6e34b2db92fe534cdab306f", - "sha256:159cfec18a6e125dd4723e2b1de6f202b34b87c850fb9d509acfd054c01135e9", - "sha256:1dc67b40fe81217b308ab12651adba05e7300b3a2ccf84d6b35a878e308dd8d4", - "sha256:1f0e36a5fa7a182cde391a128a64f437657d2b9371dfa42eda3436245adccbf5", - "sha256:229ccb6713c8b941eaa5cf13dc7478eba117f21513b5893c35e44483e2f0c9c8", - "sha256:25d3746da40f3c8c59c3b1d001e49fd2aa17904438f980d9a391370366df001e", - "sha256:32c0aff31f33033f4961abc01f78497e5e07bac02a508632aef394b384d27428", - "sha256:33bbf47ea5a6ff20c23426106e81863cdbb5402de1825493026ce615039cc99d", - "sha256:35ad5aed9b52217d4cea739d0ea3a492a18dd86fecb4b132668a69f27fb0363b", - "sha256:3910211b0ab20be3a38e0bb944ed45bd4265d8d9f11a3d1674b95b298e08dd5c", - "sha256:3b5658b1f9e486a2eec4c0c688f213a90085b9cf2fec76ef08f98fdf6c62f4b9", - "sha256:40b801b768f5a765e33c68f30665d3c6ee1c8623a2d2bb78e6e59f2db4e4ceb7", - "sha256:47275ff73005a3e5e146e50baa2378e1730cba6e292f0222bc496a8e4c4adfc8", - "sha256:55bb4a1bf94e39447bc08238a2fb8a767460388a8192f67c103442eb36920887", - "sha256:5b08676a17e3f791daad34d5fcb18479e9c85e7200d5a17cbe8de798643a7e37", - "sha256:5b16344032a27b2ccfd341f89dadf3e4ef6407d91e4b93563c14644a8abb3ad7", - "sha256:5c5e05e4f5756758c58a8088aa10dc70d851c89f842b611fdccfc0581c1846bc", - "sha256:5cd67674db3c73026e0a2c729b909780e88bd9cbc8184256f9567640a5d299a8", - "sha256:5e7fae9ca93258a956551708cf60dc6c8145574e32ce8c8c4d894e63bcb04341", - "sha256:61213482b5a387ead9e250e9e3cb290292feca39dc83b41c3b1b7b8ffc8d8ecb", - "sha256:619a63753ba9e792fe3c6c0fc2b9ee2cfbd92153dd91bee029a89a71eb2942cd", - "sha256:652e4b7497825b0af6259e2c54700e6dc33d2fc4ed92b8839435090d4c9cc911", - "sha256:68569509dd015fcdd1e6b2b3ccc8c51fd27d9a97f461ccc909270e220ee09685", - "sha256:6a01a072b2219b65a6ff74df208f20b2cac9401c60adb676ee34e53b4c651077", - "sha256:70843788c85ca385846a2d2f836efebe7bb2687ca0734648bf5c9dc6c55602d2", - "sha256:76820f2ece3b0a7c948bbb6a599020e29574626d23a649476def023cbb026787", - "sha256:7a006c300e82402c0c8f1ded11352a3ba2a61b87e7abb3054c845af2ca8d553c", - "sha256:7baf16fd8908a025c4a8d7b699103e72d41f967e2aee5a2065432bcdbd9fd06e", - "sha256:7ecf431786019a7bfedc28281531d706627f603e3691d64eccdbce3ecd353823", - "sha256:885de1ed5ea01c1bfe0a34c901152a264c3c1f8f1d382042b92ea354bd14bb0e", - "sha256:88cdb1da7fdb121dbb3116910722f5acab4d6e8bfcacab8fafe27e2e7744dc6a", - "sha256:95ade0bd4cf69e04e8b8f8ec2d197d9c9c4a9b6902e048dc7456bf6d82e12a80", - "sha256:9b88dc97ba86c96b964c3745a445d9a65f76fe21955a953064fe04adb63e9367", - "sha256:9c780d992f5d734432726b92a0c87bf1857c3d85082a8dea29cbf56e44a132b3", - "sha256:9f85200ea102276afdd3749ca94747f057bbb868d1c52921ee2446730b508d0f", - "sha256:a1cf98afa7ad5e7012454ca3fde254499a13f9d92fd50cb46118118a249a1355", - "sha256:a635aecf1047255576dbb0927cbf9a7aa4a68e9d54110cc3c926652d18f144e0", - "sha256:ae97504958d0bc58c1152045c170815d5c4f8af906561ce044b6358b43d0c97e", - "sha256:b06a5095a79384760625b5de3f83f40b3053a385fb893be8a106fbbd84c14980", - "sha256:b5c8dd9a386a66e50bd7fa22b7a49fb8ead2b3574d6bd69eb1caced6caea0803", - "sha256:bae6c561f11b444b258b1b4be2bdd1e1cf93cd1d80766b7e869a79db4543a8a8", - "sha256:bbb4448a05d261fae423d5c0b0974ad899f60825bc77eabad5a0c518e78448c2", - "sha256:bd6af61388be65a8701f5787362cb54adae20007e0cc67ca9221a4b95115583b", - "sha256:bf652839d16de91fe1cfb253e0a88db9a548796939533894e07f45d4bdf90a5f", - "sha256:d6d25b8a5c70e2334ed61a8da4c11cd9b97c6fbd980c406033f06e4463fda006", - "sha256:da057d3652e698b00746e47f06dbb513314f847421e857e32e1dc61c46f6c052", - "sha256:e0ed35d6d6122d0baa9a1b59ebca4ee302139f4cfb57dab85e4c73ab793ae7ed", - "sha256:e36560d001d4ba469d469b02037f2dd404421fd72277d9474efe9f03f83fced5", - "sha256:f4321692e7f299277e55f322329b2c972d93bb612d85f3fda8741bec5c6285ce", - "sha256:f75114c05ec56566da6b55122791cf5bb53d5aada96a98c016d6231e03132f76", - "sha256:fb4571efe86545b772a4630fee578c213c91cbcfd20347806e47fd4e782a18fe", - "sha256:fc97aa4b4fb928ff4d3b74da7c30b360d0cb3ede49a5a6e1fd9705f49aea1deb" + "sha256:04ad6069c86e531682f9e1e71b71c1c3937d6014a7c3e9edd2aa81ad58842862", + "sha256:0bfdd914e55e0d2c9e1526de210f6fe8ffe9705f2b1dfcc4aecc92a4cb4b533d", + "sha256:1dc93e8e4653bdb5910aed79f11e165c85732067614f180f70534f056da97db3", + "sha256:1e2d69948e4132813b8d1131f29f9101bc2c915f26089a6d632001a5c1349672", + "sha256:235a31ec7db685f5c82233bddf9858748b89b8119bf4538d514536c485c15fe0", + "sha256:27dcd6f46a21c18fa5e5deed92a43d4554e3df8d8ca5a47bf0615d6a5f39dbc9", + "sha256:28efb066cde83c479dfe5a48141a53bc7e5f13f785b92ddde336c716663039ee", + "sha256:3476fae43db72bd11f29a5147ae2f3cb22e2f1a91d575ef130d2bf49afd21c46", + "sha256:36e17c4592231a7dbd2ed09027823ab295d2791b3b1efb2aee874b10548b7524", + "sha256:384d779f0d6f1b110eae74cb0659d9aa6ff35aaf547b3955abf2ab4c901c4819", + "sha256:38949d30b11ae5f95c3c91917ee7a6b239f5ec276f271f28638dec9156f82cfc", + "sha256:3967e4ad1aa9da62fd53e346ed17d7b2e922cba5ab93bdd46febcac39be636fc", + "sha256:3e7bf4442b310ff154b7bb9d81eb2c016b7d597e364f97d72b1acc3817a0fdc1", + "sha256:3f0c8c6dfa6605ab8ff0611995ee30d4f9fcff89966cf562733b4008a3d60d82", + "sha256:484ae3240666ad34cfa31eea7b8c6cd2f1fdaae21d73ce2974211df099a95d81", + "sha256:4a7b4f35de6a304b5533c238bee86b670b75b03d31b7797929caa7a624b5dda6", + "sha256:4cb14ce54d9b857be9591ac364cb08dc2d6a5c4318c1182cb1d02274029d590d", + "sha256:4e71bc4416de195d6e9b4ee93ad3f2f6b2ce11d042b4d7a7ee00bbe0358bd0c2", + "sha256:52700dc63a4676669b341ba33520f4d6e43d3ca58d422e22ba66d1736b0a6e4c", + "sha256:572efc93db7a4d27e404501975ca6d2d9775705c2d922390d878fcf768d92c87", + "sha256:576eb384292b139821c41995523654ad82d1916da6a60cff129c715a6223ea84", + "sha256:5b0bf0effb196ed76b7ad883848143427a73c355ae8e569fa538365064188b8e", + "sha256:5b6ccc0c85916998d788b295765ea0e9cb9aac7e4a8ed71d12e7d8ac31c23c95", + "sha256:5ed82f5a7af3697b1c4786053736f24a0efd0a1b8a130d4c7bfee4b9ded0f08f", + "sha256:6d4c80667de2e36970ebf74f42d1088cc9ee7ef5f4e8c35eee1b40eafd33ca5b", + "sha256:730076207cb816138cf1af7f7237b208340a2c5e749707457d70705715c93b93", + "sha256:7687e22a31e976a0e7fc99c2f4d11ca45eff652a81eb8c8085e9609298916dcf", + "sha256:822ea70dc4018c7e6223f13affd1c5c30c0f5c12ac1f96cd8e9949acddb48a61", + "sha256:84b0daf226913133f899ea9b30618722d45feffa67e4fe867b0b5ae83a34060c", + "sha256:85765fdf4b27eb5086f05ac0491090fc76f4f2b28e09d9350c31aac25a5aaff8", + "sha256:8dd178c4c80706546702c59529ffc005681bd6dc2ea234c450661b205445a34d", + "sha256:8f5b234f567cf76ee489502ceb7165c2a5cecec081db2b37e35332b537f8157c", + "sha256:98bbd754a422a0b123c66a4c341de0474cad4a5c10c164ceed6ea090f3563db4", + "sha256:993584fc821c58d5993521bfdcd31a4adf025c7d745bbd4d12ccfecf695af5ba", + "sha256:a40821a89dc373d6427e2b44b572efc36a2778d3f543299e2f24eb1a5de65415", + "sha256:b291f0ee7961a597cbbcc77709374087fa2a9afe7bdb6a40dbbd9b127e79afee", + "sha256:b573a43ef7c368ba4ea06050a957c2a7550f729c31f11dd616d2ac4aba99888d", + "sha256:b610ff0f24e9f11c9ae653c67ff8cc03c075131401b3e5ef4b82570d1728f8a9", + "sha256:bdf38ba2d393c7911ae989c3bbba510ebbcdf4ecbdbfec36272abe350c454075", + "sha256:bfef2bb6ef068827bbd021017a107194956918ab43ce4d6dc945ffa13efbc25f", + "sha256:cab3db8bab4b7e635c1c97270d7a4b2a90c070b33cbc00c99ef3f9be03d3e1f7", + "sha256:cb70766519500281815dfd7a87d3a178acf7ce95390544b8c90587d76b227681", + "sha256:cca1b62fe70d761a282496b96a5e51c44c213e410a964bdffe0928e611368329", + "sha256:ccf9a39706b604d884d2cb1e27fe973bc55f2890c52f38df742bc1d79ab9f5e1", + "sha256:dc43f1ec66eb8440567186ae2f8c447d91e0372d793dfe8c222aec857b81a8cf", + "sha256:dd632777ff3beaaf629f1ab4396caf7ba0bdd075d948a69460d13d44357aca4c", + "sha256:e45ae4927759289c30ccba8d9fdce62bb414977ba158286b5ddaf8df2cddb5c5", + "sha256:e50ebce52f41370707f1e21a59514e3375e3edd6e1832f5e5235237db933c98b", + "sha256:ebbbba226f0a108a7366bf4b59bf0f30a12fd5e75100c630267d94d7f0ad20e5", + "sha256:ec79ff6159dffcc30853b2ad612ed572af86c92b5168aa3fc01a67b0fa40665e", + "sha256:f0936e08e0003f66bfd97e74ee530427707297b0d0361247e9b4f59ab78ddc8b", + "sha256:f26a07a6e877c76a88e3cecac8531908d980d3d5067ff69213653649ec0f60ad", + "sha256:f64e376cd20d3f030190e8c32e1c64582eba56ac6dc7d5b0b49a9d44021b52fd", + "sha256:f6ffbc252eb0d229aeb2f9ad051200668fc3a9aaa8994e49f0cb2ffe2b7867e7", + "sha256:f9a7c509542db4eceed3dcf21ee5267ab565a83555c9b88a8109dcecc4709002", + "sha256:ff1d0899f104f3921d94579a5638847f783c9b04f2d5f229392ca77fba5b82fc" ], "markers": "python_version >= '3.8'", - "version": "==1.0.6" + "version": "==1.0.7" }, "multidict": { "hashes": [ @@ -1392,11 +1424,11 @@ }, "packaging": { "hashes": [ - "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", - "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" ], "markers": "python_version >= '3.7'", - "version": "==23.1" + "version": "==23.2" }, "pamqp": { "hashes": [ @@ -1414,6 +1446,7 @@ "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" ], + "index": "pypi", "version": "==1.7.4" }, "pillow": { @@ -1481,7 +1514,6 @@ "sha256:f1ff2ee69f10f13a9596480335f406dd1f70c3650349e2be67ca3139280cade0" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==9.3.0" }, "proto-plus": { @@ -1494,51 +1526,50 @@ }, "protobuf": { "hashes": [ - "sha256:067f750169bc644da2e1ef18c785e85071b7c296f14ac53e0900e605da588719", - "sha256:12e9ad2ec079b833176d2921be2cb24281fa591f0b119b208b788adc48c2561d", - "sha256:1b182c7181a2891e8f7f3a1b5242e4ec54d1f42582485a896e4de81aa17540c2", - "sha256:20651f11b6adc70c0f29efbe8f4a94a74caf61b6200472a9aea6e19898f9fcf4", - "sha256:2da777d34b4f4f7613cdf85c70eb9a90b1fbef9d36ae4a0ccfe014b0b07906f1", - "sha256:3d42e9e4796a811478c783ef63dc85b5a104b44aaaca85d4864d5b886e4b05e3", - "sha256:6e514e8af0045be2b56e56ae1bb14f43ce7ffa0f68b1c793670ccbe2c4fc7d2b", - "sha256:b0271a701e6782880d65a308ba42bc43874dabd1a0a0f41f72d2dac3b57f8e76", - "sha256:ba53c2f04798a326774f0e53b9c759eaef4f6a568ea7072ec6629851c8435959", - "sha256:e29d79c913f17a60cf17c626f1041e5288e9885c8579832580209de8b75f2a52", - "sha256:f631bb982c5478e0c1c70eab383af74a84be66945ebf5dd6b06fc90079668d0b", - "sha256:f6ccbcf027761a2978c1406070c3788f6de4a4b2cc20800cc03d52df716ad675", - "sha256:f6f8dc65625dadaad0c8545319c2e2f0424fede988368893ca3844261342c11a" + "sha256:0bf384e75b92c42830c0a679b0cd4d6e2b36ae0cf3dbb1e1dfdda48a244f4bcd", + "sha256:0f881b589ff449bf0b931a711926e9ddaad3b35089cc039ce1af50b21a4ae8cb", + "sha256:1484f9e692091450e7edf418c939e15bfc8fc68856e36ce399aed6889dae8bb0", + "sha256:193f50a6ab78a970c9b4f148e7c750cfde64f59815e86f686c22e26b4fe01ce7", + "sha256:3497c1af9f2526962f09329fd61a36566305e6c72da2590ae0d7d1322818843b", + "sha256:57d65074b4f5baa4ab5da1605c02be90ac20c8b40fb137d6a8df9f416b0d0ce2", + "sha256:8bdbeaddaac52d15c6dce38c71b03038ef7772b977847eb6d374fc86636fa510", + "sha256:a19731d5e83ae4737bb2a089605e636077ac001d18781b3cf489b9546c7c80d6", + "sha256:abc0525ae2689a8000837729eef7883b9391cd6aa7950249dcf5a4ede230d5dd", + "sha256:becc576b7e6b553d22cbdf418686ee4daa443d7217999125c045ad56322dda10", + "sha256:ca37bf6a6d0046272c152eea90d2e4ef34593aaa32e8873fc14c16440f22d4b7" ], - "markers": "python_version >= '3.7'", - "version": "==4.24.3" + "markers": "python_version >= '3.8'", + "version": "==4.25.1" }, "psutil": { "hashes": [ - "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d", - "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217", - "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4", - "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c", - "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f", - "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da", - "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4", - "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42", - "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5", - "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4", - "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9", - "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f", - "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30", - "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48" + "sha256:10e8c17b4f898d64b121149afb136c53ea8b68c7531155147867b7b1ac9e7e28", + "sha256:18cd22c5db486f33998f37e2bb054cc62fd06646995285e02a51b1e08da97017", + "sha256:3ebf2158c16cc69db777e3c7decb3c0f43a7af94a60d72e87b2823aebac3d602", + "sha256:51dc3d54607c73148f63732c727856f5febec1c7c336f8f41fcbd6315cce76ac", + "sha256:6e5fb8dc711a514da83098bc5234264e551ad980cec5f85dabf4d38ed6f15e9a", + "sha256:70cb3beb98bc3fd5ac9ac617a327af7e7f826373ee64c80efd4eb2856e5051e9", + "sha256:748c9dd2583ed86347ed65d0035f45fa8c851e8d90354c122ab72319b5f366f4", + "sha256:91ecd2d9c00db9817a4b4192107cf6954addb5d9d67a969a4f436dbc9200f88c", + "sha256:92e0cc43c524834af53e9d3369245e6cc3b130e78e26100d1f63cdb0abeb3d3c", + "sha256:a6f01f03bf1843280f4ad16f4bde26b817847b4c1a0db59bf6419807bc5ce05c", + "sha256:c69596f9fc2f8acd574a12d5f8b7b1ba3765a641ea5d60fb4736bf3c08a8214a", + "sha256:ca2780f5e038379e520281e4c032dddd086906ddff9ef0d1b9dcf00710e5071c", + "sha256:daecbcbd29b289aac14ece28eca6a3e60aa361754cf6da3dfb20d4d32b6c7f57", + "sha256:e4b92ddcd7dd4cdd3f900180ea1e104932c7bce234fb88976e2a3b296441225a", + "sha256:fb8a697f11b0f5994550555fcfe3e69799e5b060c8ecf9e2f75c69302cc35c0d", + "sha256:ff18b8d1a784b810df0b0fff3bcb50ab941c3b8e2c8de5726f9c71c601c611aa" ], "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==5.9.5" + "version": "==5.9.6" }, "pyasn1": { "hashes": [ - "sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57", - "sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde" + "sha256:4439847c58d40b1d0a573d07e3856e95333f1976294494c325775aeca506eb58", + "sha256:6d391a96e59b23130a5cfa74d6fd7f388dbbe26cc8f1edf39fdddf08d9d6676c" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==0.5.0" + "version": "==0.5.1" }, "pyasn1-modules": { "hashes": [ @@ -1603,9 +1634,17 @@ "sha256:f59ef915cac80275245824e9d771ee939133be38215555e9dc90c6cb148aaeb5", "sha256:f8e81fc5fb17dae698f52bdd1c4f18b6ca674d7068242b2aff075f588301bbb0" ], - "markers": "python_version >= '3.7'", + "index": "pypi", "version": "==1.10.13" }, + "pygments": { + "hashes": [ + "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", + "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" + ], + "markers": "python_version >= '3.7'", + "version": "==2.17.2" + }, "pyjwt": { "extras": [ "crypto" @@ -1629,7 +1668,7 @@ "sha256:7a83b7b272dd595222d672f5ce29aa030f1fb837630ef229f62e72e395ce8968", "sha256:b28437c9773bb6c6958628cf9c3bebe585de661dba6f63df17111966363dd15e" ], - "markers": "python_version >= '3.6'", + "index": "pypi", "version": "==22.1.0" }, "pyparsing": { @@ -1663,6 +1702,7 @@ "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a", "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a" ], + "index": "pypi", "version": "==3.3.0" }, "python-multipart": { @@ -1671,7 +1711,6 @@ "sha256:ee698bab5ef148b0a760751c261902cd096e57e10558e11aca17646b74ee1c18" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==0.0.6" }, "pytz": { @@ -1752,6 +1791,13 @@ "markers": "python_version >= '3.7'", "version": "==2.31.0" }, + "rich": { + "hashes": [ + "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa", + "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235" + ], + "version": "==13.7.0" + }, "rsa": { "hashes": [ "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", @@ -1770,11 +1816,18 @@ }, "sentry-sdk": { "hashes": [ - "sha256:64a7141005fb775b9db298a30de93e3b83e0ddd1232dc6f36eb38aebc1553291", - "sha256:6de2e88304873484207fed836388e422aeff000609b104c802749fd89d56ba5b" + "sha256:0017fa73b8ae2d4e57fd2522ee3df30453715b29d2692142793ec5d5f90b94a6", + "sha256:8feab81de6bbf64f53279b085bd3820e3e737403b0a0d9317f73a2c3374ae359" ], "index": "pypi", - "version": "==1.31.0" + "version": "==1.38.0" + }, + "shellingham": { + "hashes": [ + "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", + "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" + ], + "version": "==1.5.4" }, "six": { "hashes": [ @@ -1807,16 +1860,23 @@ "sha256:2e126cf98b7fd38f1e33c64484406b78e937b1a280e078ef558b95bf5b6895f6", "sha256:36e58f8c4fe43984384e3fbe6341ac99b6b4e083de2fe838f0fdb91cebe9e9cb", "sha256:37ce517c011560d68f1ffb28af65d7e06f873f191eb3a73af5671e9c3fada08a", + "sha256:393cd06c3b00b57f5421e2133e088df9cabcececcea180327e43b937b5a7caa5", "sha256:45806315aae81a0c202752558f0df52b42d11dd7ba0097bf71e253b4215f34f4", "sha256:4afbbf5ef41ac18e02c8dc1f86c04b22b7a2125f2a030e25bbb4aff31abb224b", "sha256:5debe7d49b8acf1f3035317e63d9ec8d5e4d904c6e75a2a9246a119f5f2fdf3d", "sha256:5fb1ebdfc8373b5a291485757bd6431de8d7ed42c27439f543c81f6c8febd729", "sha256:647e0b309cb4512b1f1b78471fdaf72921b6fa6e750b9f891e09c6e2f0e5326f", + "sha256:66da9627cfcc43bbdebd47bfe0145bb662041472393c03b7802253993b6b7c90", "sha256:706bfa02157b97c136547c406f263e4c6274a7b061b3eb9742915dd774bbc264", + "sha256:738d7321212941ab19ba2acf02a68b8ee64987b248ffa2101630e8fccb549e0d", "sha256:7653ed6817c710d0c95558232aba799307d14ae084cc9b1f4c389157ec50df5c", + "sha256:7cf8b90ad84ad3a45098b1c9f56f2b161601e4670827d6b892ea0e884569bd1d", "sha256:82b08e82da3756765c2e75f327b9bf6b0f043c9c3925fb95fb51e1567fa4ee87", + "sha256:8396e896e08e37032e87e7fbf4a15f431aa878c286dc7f79e616c2feacdb366c", "sha256:8923dfdf24d5aa8a3adb59723f54118dd4fe62cf59ed0d0d65d940579c1170a4", + "sha256:95ab792ca493891d7a45a077e35b418f68435efb3e1706cb8155e20e86a9013c", "sha256:95b9df9afd680b7a3b13b38adf6e3a38995da5e162cc7524ef08e3be4e5ed3e1", + "sha256:9a06e046ffeb8a484279e54bda0a5abfd9675f594a2e38ef3133d7e4d75b6214", "sha256:9c21b172dfb22e0db303ff6419451f0cac891d2e911bb9fbf8003d717f1bcf91", "sha256:a1878ce508edea4a879015ab5215546c444233881301e97ca16fe251e89f1c55", "sha256:a63e43bf3f668c11bb0444ce6e809c1227b8f067ca1068898f3008a273f52b09", @@ -1829,14 +1889,17 @@ "sha256:bbdf16372859b8ed3f4d05f925a984771cd2abd18bd187042f24be4886c2a15f", "sha256:c14b29d9e1529f99efd550cd04dbb6db6ba5d690abb96d52de2bff4ed518bc95", "sha256:c40f3470e084d31247aea228aa1c39bbc0904c2b9ccbf5d3cfa2ea2dac06f26d", + "sha256:ca46de16650d143a928d10842939dab208e8d8c3a9a8757600cae9b7c579c5cd", "sha256:ccf956da45290df6e809ea12c54c02ace7f8ff4d765d6d3dfb3655ee876ce58d", "sha256:d26f280b8f0a8f497bc10573849ad6dc62e671d2468826e5c748d04ed9e670d5", + "sha256:ebc22807a7e161c0d8f3da34018ab7c97ef6223578fcdd99b1d3e7ed1100a5db", "sha256:ec2268de67f73b43320383947e74700e95c6770d0c68c4e615e9897e46296294", "sha256:f167c8175ab908ce48bd6550679cc6ea20ae169379e73c7720a28f89e53aa532", + "sha256:f23755c384c2969ca2f7667a83f7c5648fcf8b62a3f2bbd883d805454964a800", "sha256:f835c050ebaa4e48b18403bed2c0fda986525896efd76c245bdd4db995e51a4c", "sha256:f8a65990c9c490f4651b5c02abccc9f113a7f56fa482031ac8cb88b70bc8ccaa" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "index": "pypi", "version": "==1.4.49" }, "sqlalchemy-utils": { @@ -1845,7 +1908,6 @@ "sha256:a2181bff01eeb84479e38571d2c0718eb52042f9afd8c194d0d02877e84b7d74" ], "index": "pypi", - "markers": "python_version >= '3.6'", "version": "==0.41.1" }, "starlette": { @@ -1857,11 +1919,14 @@ "version": "==0.21.0" }, "taskiq": { + "extras": [ + "reload" + ], "hashes": [ "sha256:a0ce2e28f76b87e2504693eca8dcd174013198a880fc209f831829dcf7fa6075", "sha256:a3c4fab8959ac4bf3e8a7e372a677169de41128a2c756546555ece9f9669d3c9" ], - "markers": "python_full_version >= '3.8.1' and python_full_version < '4.0.0'", + "index": "pypi", "version": "==0.9.1" }, "taskiq-aio-pika": { @@ -1870,7 +1935,6 @@ "sha256:9295e911ad2c808e10571adee262dcfe51344a2aebba0fbc89249e666bbe44a1" ], "index": "pypi", - "markers": "python_full_version >= '3.8.1' and python_full_version < '4.0.0'", "version": "==0.4.0" }, "taskiq-dependencies": { @@ -1887,7 +1951,6 @@ "sha256:93eae839c0df9f24d5dcaef9c617b21fbe2396ce0490dacbb39c6ca37be3a997" ], "index": "pypi", - "markers": "python_full_version >= '3.8.1' and python_full_version < '4.0.0'", "version": "==0.3.0" }, "taskiq-redis": { @@ -1896,9 +1959,19 @@ "sha256:bda563f085eae21345a1365cb71b7a72acca73616ff979045e9d73f9ff69eaa9" ], "index": "pypi", - "markers": "python_full_version >= '3.8.1' and python_full_version < '4.0.0'", "version": "==0.5.0" }, + "typer": { + "extras": [ + "all" + ], + "hashes": [ + "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2", + "sha256:5d96d986a21493606a358cae4461bd8cdf83cbf33a5aa950ae629ca3b51467ee" + ], + "index": "pypi", + "version": "==0.9.0" + }, "typing-extensions": { "hashes": [ "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", @@ -1917,11 +1990,11 @@ }, "urllib3": { "hashes": [ - "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f", - "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14" + "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07", + "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0" ], "markers": "python_version >= '3.6'", - "version": "==1.26.16" + "version": "==1.26.18" }, "uvicorn": { "extras": [ @@ -1931,225 +2004,330 @@ "sha256:cc277f7e73435748e69e075a721841f7c4a95dba06d12a72fe9874acced16f6f", "sha256:cf538f3018536edb1f4a826311137ab4944ed741d52aeb98846f52215de57f25" ], - "markers": "python_version >= '3.7'", - "version": "==0.19.0" + "index": "pypi", + "version": "==0.19" }, "uvloop": { "hashes": [ - "sha256:0949caf774b9fcefc7c5756bacbbbd3fc4c05a6b7eebc7c7ad6f825b23998d6d", - "sha256:0ddf6baf9cf11a1a22c71487f39f15b2cf78eb5bde7e5b45fbb99e8a9d91b9e1", - "sha256:1436c8673c1563422213ac6907789ecb2b070f5939b9cbff9ef7113f2b531595", - "sha256:23609ca361a7fc587031429fa25ad2ed7242941adec948f9d10c045bfecab06b", - "sha256:2a6149e1defac0faf505406259561bc14b034cdf1d4711a3ddcdfbaa8d825a05", - "sha256:2deae0b0fb00a6af41fe60a675cec079615b01d68beb4cc7b722424406b126a8", - "sha256:307958f9fc5c8bb01fad752d1345168c0abc5d62c1b72a4a8c6c06f042b45b20", - "sha256:30babd84706115626ea78ea5dbc7dd8d0d01a2e9f9b306d24ca4ed5796c66ded", - "sha256:3378eb62c63bf336ae2070599e49089005771cc651c8769aaad72d1bd9385a7c", - "sha256:3d97672dc709fa4447ab83276f344a165075fd9f366a97b712bdd3fee05efae8", - "sha256:3db8de10ed684995a7f34a001f15b374c230f7655ae840964d51496e2f8a8474", - "sha256:3ebeeec6a6641d0adb2ea71dcfb76017602ee2bfd8213e3fcc18d8f699c5104f", - "sha256:45cea33b208971e87a31c17622e4b440cac231766ec11e5d22c76fab3bf9df62", - "sha256:6708f30db9117f115eadc4f125c2a10c1a50d711461699a0cbfaa45b9a78e376", - "sha256:68532f4349fd3900b839f588972b3392ee56042e440dd5873dfbbcd2cc67617c", - "sha256:6aafa5a78b9e62493539456f8b646f85abc7093dd997f4976bb105537cf2635e", - "sha256:7d37dccc7ae63e61f7b96ee2e19c40f153ba6ce730d8ba4d3b4e9738c1dccc1b", - "sha256:864e1197139d651a76c81757db5eb199db8866e13acb0dfe96e6fc5d1cf45fc4", - "sha256:8887d675a64cfc59f4ecd34382e5b4f0ef4ae1da37ed665adba0c2badf0d6578", - "sha256:8efcadc5a0003d3a6e887ccc1fb44dec25594f117a94e3127954c05cf144d811", - "sha256:9b09e0f0ac29eee0451d71798878eae5a4e6a91aa275e114037b27f7db72702d", - "sha256:a4aee22ece20958888eedbad20e4dbb03c37533e010fb824161b4f05e641f738", - "sha256:a5abddb3558d3f0a78949c750644a67be31e47936042d4f6c888dd6f3c95f4aa", - "sha256:c092a2c1e736086d59ac8e41f9c98f26bbf9b9222a76f21af9dfe949b99b2eb9", - "sha256:c686a47d57ca910a2572fddfe9912819880b8765e2f01dc0dd12a9bf8573e539", - "sha256:cbbe908fda687e39afd6ea2a2f14c2c3e43f2ca88e3a11964b297822358d0e6c", - "sha256:ce9f61938d7155f79d3cb2ffa663147d4a76d16e08f65e2c66b77bd41b356718", - "sha256:dbbaf9da2ee98ee2531e0c780455f2841e4675ff580ecf93fe5c48fe733b5667", - "sha256:f1e507c9ee39c61bfddd79714e4f85900656db1aec4d40c6de55648e85c2799c", - "sha256:ff3d00b70ce95adce264462c930fbaecb29718ba6563db354608f37e49e09024" - ], - "version": "==0.17.0" + "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd", + "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec", + "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b", + "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc", + "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797", + "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5", + "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2", + "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d", + "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be", + "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd", + "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12", + "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17", + "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef", + "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24", + "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428", + "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1", + "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849", + "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593", + "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd", + "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67", + "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6", + "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3", + "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd", + "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8", + "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7", + "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533", + "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957", + "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650", + "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e", + "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7", + "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256" + ], + "version": "==0.19.0" + }, + "watchdog": { + "hashes": [ + "sha256:03f342a9432fe08107defbe8e405a2cb922c5d00c4c6c168c68b633c64ce6190", + "sha256:0d9878be36d2b9271e3abaa6f4f051b363ff54dbbe7e7df1af3c920e4311ee43", + "sha256:0e1dd6d449267cc7d6935d7fe27ee0426af6ee16578eed93bacb1be9ff824d2d", + "sha256:2caf77ae137935c1466f8cefd4a3aec7017b6969f425d086e6a528241cba7256", + "sha256:3d2dbcf1acd96e7a9c9aefed201c47c8e311075105d94ce5e899f118155709fd", + "sha256:4109cccf214b7e3462e8403ab1e5b17b302ecce6c103eb2fc3afa534a7f27b96", + "sha256:4cd61f98cb37143206818cb1786d2438626aa78d682a8f2ecee239055a9771d5", + "sha256:53f3e95081280898d9e4fc51c5c69017715929e4eea1ab45801d5e903dd518ad", + "sha256:564e7739abd4bd348aeafbf71cc006b6c0ccda3160c7053c4a53b67d14091d42", + "sha256:5b848c71ef2b15d0ef02f69da8cc120d335cec0ed82a3fa7779e27a5a8527225", + "sha256:5defe4f0918a2a1a4afbe4dbb967f743ac3a93d546ea4674567806375b024adb", + "sha256:6f5d0f7eac86807275eba40b577c671b306f6f335ba63a5c5a348da151aba0fc", + "sha256:7a1876f660e32027a1a46f8a0fa5747ad4fcf86cb451860eae61a26e102c8c79", + "sha256:7a596f9415a378d0339681efc08d2249e48975daae391d58f2e22a3673b977cf", + "sha256:85bf2263290591b7c5fa01140601b64c831be88084de41efbcba6ea289874f44", + "sha256:8a4d484e846dcd75e96b96d80d80445302621be40e293bfdf34a631cab3b33dc", + "sha256:8f2df370cd8e4e18499dd0bfdef476431bcc396108b97195d9448d90924e3131", + "sha256:91fd146d723392b3e6eb1ac21f122fcce149a194a2ba0a82c5e4d0ee29cd954c", + "sha256:95ad708a9454050a46f741ba5e2f3468655ea22da1114e4c40b8cbdaca572565", + "sha256:964fd236cd443933268ae49b59706569c8b741073dbfd7ca705492bae9d39aab", + "sha256:9da7acb9af7e4a272089bd2af0171d23e0d6271385c51d4d9bde91fe918c53ed", + "sha256:a073c91a6ef0dda488087669586768195c3080c66866144880f03445ca23ef16", + "sha256:a74155398434937ac2780fd257c045954de5b11b5c52fc844e2199ce3eecf4cf", + "sha256:aa8b028750b43e80eea9946d01925168eeadb488dfdef1d82be4b1e28067f375", + "sha256:d1f1200d4ec53b88bf04ab636f9133cb703eb19768a39351cee649de21a33697", + "sha256:d9f9ed26ed22a9d331820a8432c3680707ea8b54121ddcc9dc7d9f2ceeb36906", + "sha256:ea5d86d1bcf4a9d24610aa2f6f25492f441960cf04aed2bd9a97db439b643a7b", + "sha256:efe3252137392a471a2174d721e1037a0e6a5da7beb72a021e662b7000a9903f" + ], + "version": "==2.3.1" }, "watchfiles": { "hashes": [ - "sha256:007dcc4a401093010b389c044e81172c8a2520dba257c88f8828b3d460c6bb38", - "sha256:08dc702529bb06a2b23859110c214db245455532da5eaea602921687cfcd23db", - "sha256:0d82dbc1832da83e441d112069833eedd4cf583d983fb8dd666fbefbea9d99c0", - "sha256:13f995d5152a8ba4ed7c2bbbaeee4e11a5944defc7cacd0ccb4dcbdcfd78029a", - "sha256:3796312bd3587e14926013612b23066912cf45a14af71cf2b20db1c12dadf4e9", - "sha256:5392dd327a05f538c56edb1c6ebba6af91afc81b40822452342f6da54907bbdf", - "sha256:570848706440373b4cd8017f3e850ae17f76dbdf1e9045fc79023b11e1afe490", - "sha256:608cd94a8767f49521901aff9ae0c92cc8f5a24d528db7d6b0295290f9d41193", - "sha256:728575b6b94c90dd531514677201e8851708e6e4b5fe7028ac506a200b622019", - "sha256:7d4e66a857621584869cfbad87039e65dadd7119f0d9bb9dbc957e089e32c164", - "sha256:835df2da7a5df5464c4a23b2d963e1a9d35afa422c83bf4ff4380b3114603644", - "sha256:87d9e1f75c4f86c93d73b5bd1ebe667558357548f11b4f8af4e0e272f79413ce", - "sha256:89d1de8218874925bce7bb2ae9657efc504411528930d7a83f98b1749864f2ef", - "sha256:99f4c65fd2fce61a571b2a6fcf747d6868db0bef8a934e8ca235cc8533944d95", - "sha256:9a0351d20d03c6f7ad6b2e8a226a5efafb924c7755ee1e34f04c77c3682417fa", - "sha256:9b5c8d3be7b502f8c43a33c63166ada8828dbb0c6d49c8f9ce990a96de2f5a49", - "sha256:a03d1e6feb7966b417f43c3e3783188167fd69c2063e86bad31e62c4ea794cc5", - "sha256:b17d4176c49d207865630da5b59a91779468dd3e08692fe943064da260de2c7c", - "sha256:d0002d81c89a662b595645fb684a371b98ff90a9c7d8f8630c82f0fde8310458", - "sha256:d97db179f7566dcf145c5179ddb2ae2a4450e3a634eb864b09ea04e68c252e8e", - "sha256:e43af4464daa08723c04b43cf978ab86cc55c684c16172622bdac64b34e36af0", - "sha256:eccc8942bcdc7d638a01435d915b913255bbd66f018f1af051cd8afddb339ea3" - ], - "version": "==0.20.0" + "sha256:02b73130687bc3f6bb79d8a170959042eb56eb3a42df3671c79b428cd73f17cc", + "sha256:02d91cbac553a3ad141db016e3350b03184deaafeba09b9d6439826ee594b365", + "sha256:06247538e8253975bdb328e7683f8515ff5ff041f43be6c40bff62d989b7d0b0", + "sha256:08dca260e85ffae975448e344834d765983237ad6dc308231aa16e7933db763e", + "sha256:0d9ac347653ebd95839a7c607608703b20bc07e577e870d824fa4801bc1cb124", + "sha256:0dd5fad9b9c0dd89904bbdea978ce89a2b692a7ee8a0ce19b940e538c88a809c", + "sha256:11cd0c3100e2233e9c53106265da31d574355c288e15259c0d40a4405cbae317", + "sha256:18722b50783b5e30a18a8a5db3006bab146d2b705c92eb9a94f78c72beb94094", + "sha256:18d5b4da8cf3e41895b34e8c37d13c9ed294954907929aacd95153508d5d89d7", + "sha256:1ad7247d79f9f55bb25ab1778fd47f32d70cf36053941f07de0b7c4e96b5d235", + "sha256:1b8d1eae0f65441963d805f766c7e9cd092f91e0c600c820c764a4ff71a0764c", + "sha256:1bd467213195e76f838caf2c28cd65e58302d0254e636e7c0fca81efa4a2e62c", + "sha256:1c9198c989f47898b2c22201756f73249de3748e0fc9de44adaf54a8b259cc0c", + "sha256:1fd9a5205139f3c6bb60d11f6072e0552f0a20b712c85f43d42342d162be1235", + "sha256:214cee7f9e09150d4fb42e24919a1e74d8c9b8a9306ed1474ecaddcd5479c293", + "sha256:27b4035013f1ea49c6c0b42d983133b136637a527e48c132d368eb19bf1ac6aa", + "sha256:3a23092a992e61c3a6a70f350a56db7197242f3490da9c87b500f389b2d01eef", + "sha256:3ad692bc7792be8c32918c699638b660c0de078a6cbe464c46e1340dadb94c19", + "sha256:3ccceb50c611c433145502735e0370877cced72a6c70fd2410238bcbc7fe51d8", + "sha256:3d0f32ebfaa9c6011f8454994f86108c2eb9c79b8b7de00b36d558cadcedaa3d", + "sha256:3f92944efc564867bbf841c823c8b71bb0be75e06b8ce45c084b46411475a915", + "sha256:40bca549fdc929b470dd1dbfcb47b3295cb46a6d2c90e50588b0a1b3bd98f429", + "sha256:43babacef21c519bc6631c5fce2a61eccdfc011b4bcb9047255e9620732c8097", + "sha256:4566006aa44cb0d21b8ab53baf4b9c667a0ed23efe4aaad8c227bfba0bf15cbe", + "sha256:49f56e6ecc2503e7dbe233fa328b2be1a7797d31548e7a193237dcdf1ad0eee0", + "sha256:4c48a10d17571d1275701e14a601e36959ffada3add8cdbc9e5061a6e3579a5d", + "sha256:4ea10a29aa5de67de02256a28d1bf53d21322295cb00bd2d57fcd19b850ebd99", + "sha256:511f0b034120cd1989932bf1e9081aa9fb00f1f949fbd2d9cab6264916ae89b1", + "sha256:51ddac60b96a42c15d24fbdc7a4bfcd02b5a29c047b7f8bf63d3f6f5a860949a", + "sha256:57d430f5fb63fea141ab71ca9c064e80de3a20b427ca2febcbfcef70ff0ce895", + "sha256:59137c0c6826bd56c710d1d2bda81553b5e6b7c84d5a676747d80caf0409ad94", + "sha256:5a03651352fc20975ee2a707cd2d74a386cd303cc688f407296064ad1e6d1562", + "sha256:5eb86c6acb498208e7663ca22dbe68ca2cf42ab5bf1c776670a50919a56e64ab", + "sha256:642d66b75eda909fd1112d35c53816d59789a4b38c141a96d62f50a3ef9b3360", + "sha256:6674b00b9756b0af620aa2a3346b01f8e2a3dc729d25617e1b89cf6af4a54eb1", + "sha256:668c265d90de8ae914f860d3eeb164534ba2e836811f91fecc7050416ee70aa7", + "sha256:66fac0c238ab9a2e72d026b5fb91cb902c146202bbd29a9a1a44e8db7b710b6f", + "sha256:6c107ea3cf2bd07199d66f156e3ea756d1b84dfd43b542b2d870b77868c98c03", + "sha256:6c889025f59884423428c261f212e04d438de865beda0b1e1babab85ef4c0f01", + "sha256:6cb8fdc044909e2078c248986f2fc76f911f72b51ea4a4fbbf472e01d14faa58", + "sha256:6e9be3ef84e2bb9710f3f777accce25556f4a71e15d2b73223788d528fcc2052", + "sha256:7f762a1a85a12cc3484f77eee7be87b10f8c50b0b787bb02f4e357403cad0c0e", + "sha256:83a696da8922314ff2aec02987eefb03784f473281d740bf9170181829133765", + "sha256:853853cbf7bf9408b404754b92512ebe3e3a83587503d766d23e6bf83d092ee6", + "sha256:8ad3fe0a3567c2f0f629d800409cd528cb6251da12e81a1f765e5c5345fd0137", + "sha256:8c6ed10c2497e5fedadf61e465b3ca12a19f96004c15dcffe4bd442ebadc2d85", + "sha256:8d5f400326840934e3507701f9f7269247f7c026d1b6cfd49477d2be0933cfca", + "sha256:927c589500f9f41e370b0125c12ac9e7d3a2fd166b89e9ee2828b3dda20bfe6f", + "sha256:9a0aa47f94ea9a0b39dd30850b0adf2e1cd32a8b4f9c7aa443d852aacf9ca214", + "sha256:9b37a7ba223b2f26122c148bb8d09a9ff312afca998c48c725ff5a0a632145f7", + "sha256:9c873345680c1b87f1e09e0eaf8cf6c891b9851d8b4d3645e7efe2ec20a20cc7", + "sha256:9d09869f2c5a6f2d9df50ce3064b3391d3ecb6dced708ad64467b9e4f2c9bef3", + "sha256:9d353c4cfda586db2a176ce42c88f2fc31ec25e50212650c89fdd0f560ee507b", + "sha256:a1e3014a625bcf107fbf38eece0e47fa0190e52e45dc6eee5a8265ddc6dc5ea7", + "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6", + "sha256:ab03a90b305d2588e8352168e8c5a1520b721d2d367f31e9332c4235b30b8994", + "sha256:aff06b2cac3ef4616e26ba17a9c250c1fe9dd8a5d907d0193f84c499b1b6e6a9", + "sha256:b3cab0e06143768499384a8a5efb9c4dc53e19382952859e4802f294214f36ec", + "sha256:b4a21f71885aa2744719459951819e7bf5a906a6448a6b2bbce8e9cc9f2c8128", + "sha256:b6d45d9b699ecbac6c7bd8e0a2609767491540403610962968d258fd6405c17c", + "sha256:be6dd5d52b73018b21adc1c5d28ac0c68184a64769052dfeb0c5d9998e7f56a2", + "sha256:c550a56bf209a3d987d5a975cdf2063b3389a5d16caf29db4bdddeae49f22078", + "sha256:c76c635fabf542bb78524905718c39f736a98e5ab25b23ec6d4abede1a85a6a3", + "sha256:c81818595eff6e92535ff32825f31c116f867f64ff8cdf6562cd1d6b2e1e8f3e", + "sha256:cfb92d49dbb95ec7a07511bc9efb0faff8fe24ef3805662b8d6808ba8409a71a", + "sha256:d23bcd6c8eaa6324fe109d8cac01b41fe9a54b8c498af9ce464c1aeeb99903d6", + "sha256:d5b1dc0e708fad9f92c296ab2f948af403bf201db8fb2eb4c8179db143732e49", + "sha256:d78f30cbe8b2ce770160d3c08cff01b2ae9306fe66ce899b73f0409dc1846c1b", + "sha256:d8f57c4461cd24fda22493109c45b3980863c58a25b8bec885ca8bea6b8d4b28", + "sha256:d9792dff410f266051025ecfaa927078b94cc7478954b06796a9756ccc7e14a9", + "sha256:e7941bbcfdded9c26b0bf720cb7e6fd803d95a55d2c14b4bd1f6a2772230c586", + "sha256:ebe684d7d26239e23d102a2bad2a358dedf18e462e8808778703427d1f584400", + "sha256:ec8c8900dc5c83650a63dd48c4d1d245343f904c4b64b48798c67a3767d7e165", + "sha256:f564bf68404144ea6b87a78a3f910cc8de216c6b12a4cf0b27718bf4ec38d303", + "sha256:fd7ac678b92b29ba630d8c842d8ad6c555abda1b9ef044d6cc092dacbfc9719d" + ], + "version": "==0.21.0" }, "websockets": { "hashes": [ - "sha256:01f5567d9cf6f502d655151645d4e8b72b453413d3819d2b6f1185abc23e82dd", - "sha256:03aae4edc0b1c68498f41a6772d80ac7c1e33c06c6ffa2ac1c27a07653e79d6f", - "sha256:0ac56b661e60edd453585f4bd68eb6a29ae25b5184fd5ba51e97652580458998", - "sha256:0ee68fe502f9031f19d495dae2c268830df2760c0524cbac5d759921ba8c8e82", - "sha256:1553cb82942b2a74dd9b15a018dce645d4e68674de2ca31ff13ebc2d9f283788", - "sha256:1a073fc9ab1c8aff37c99f11f1641e16da517770e31a37265d2755282a5d28aa", - "sha256:1d2256283fa4b7f4c7d7d3e84dc2ece74d341bce57d5b9bf385df109c2a1a82f", - "sha256:1d5023a4b6a5b183dc838808087033ec5df77580485fc533e7dab2567851b0a4", - "sha256:1fdf26fa8a6a592f8f9235285b8affa72748dc12e964a5518c6c5e8f916716f7", - "sha256:2529338a6ff0eb0b50c7be33dc3d0e456381157a31eefc561771ee431134a97f", - "sha256:279e5de4671e79a9ac877427f4ac4ce93751b8823f276b681d04b2156713b9dd", - "sha256:2d903ad4419f5b472de90cd2d40384573b25da71e33519a67797de17ef849b69", - "sha256:332d126167ddddec94597c2365537baf9ff62dfcc9db4266f263d455f2f031cb", - "sha256:34fd59a4ac42dff6d4681d8843217137f6bc85ed29722f2f7222bd619d15e95b", - "sha256:3580dd9c1ad0701169e4d6fc41e878ffe05e6bdcaf3c412f9d559389d0c9e016", - "sha256:3ccc8a0c387629aec40f2fc9fdcb4b9d5431954f934da3eaf16cdc94f67dbfac", - "sha256:41f696ba95cd92dc047e46b41b26dd24518384749ed0d99bea0a941ca87404c4", - "sha256:42cc5452a54a8e46a032521d7365da775823e21bfba2895fb7b77633cce031bb", - "sha256:4841ed00f1026dfbced6fca7d963c4e7043aa832648671b5138008dc5a8f6d99", - "sha256:4b253869ea05a5a073ebfdcb5cb3b0266a57c3764cf6fe114e4cd90f4bfa5f5e", - "sha256:54c6e5b3d3a8936a4ab6870d46bdd6ec500ad62bde9e44462c32d18f1e9a8e54", - "sha256:619d9f06372b3a42bc29d0cd0354c9bb9fb39c2cbc1a9c5025b4538738dbffaf", - "sha256:6505c1b31274723ccaf5f515c1824a4ad2f0d191cec942666b3d0f3aa4cb4007", - "sha256:660e2d9068d2bedc0912af508f30bbeb505bbbf9774d98def45f68278cea20d3", - "sha256:6681ba9e7f8f3b19440921e99efbb40fc89f26cd71bf539e45d8c8a25c976dc6", - "sha256:68b977f21ce443d6d378dbd5ca38621755f2063d6fdb3335bda981d552cfff86", - "sha256:69269f3a0b472e91125b503d3c0b3566bda26da0a3261c49f0027eb6075086d1", - "sha256:6f1a3f10f836fab6ca6efa97bb952300b20ae56b409414ca85bff2ad241d2a61", - "sha256:7622a89d696fc87af8e8d280d9b421db5133ef5b29d3f7a1ce9f1a7bf7fcfa11", - "sha256:777354ee16f02f643a4c7f2b3eff8027a33c9861edc691a2003531f5da4f6bc8", - "sha256:84d27a4832cc1a0ee07cdcf2b0629a8a72db73f4cf6de6f0904f6661227f256f", - "sha256:8531fdcad636d82c517b26a448dcfe62f720e1922b33c81ce695d0edb91eb931", - "sha256:86d2a77fd490ae3ff6fae1c6ceaecad063d3cc2320b44377efdde79880e11526", - "sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016", - "sha256:8a34e13a62a59c871064dfd8ffb150867e54291e46d4a7cf11d02c94a5275bae", - "sha256:8c82f11964f010053e13daafdc7154ce7385ecc538989a354ccc7067fd7028fd", - "sha256:92b2065d642bf8c0a82d59e59053dd2fdde64d4ed44efe4870fa816c1232647b", - "sha256:97b52894d948d2f6ea480171a27122d77af14ced35f62e5c892ca2fae9344311", - "sha256:9d9acd80072abcc98bd2c86c3c9cd4ac2347b5a5a0cae7ed5c0ee5675f86d9af", - "sha256:9f59a3c656fef341a99e3d63189852be7084c0e54b75734cde571182c087b152", - "sha256:aa5003845cdd21ac0dc6c9bf661c5beddd01116f6eb9eb3c8e272353d45b3288", - "sha256:b16fff62b45eccb9c7abb18e60e7e446998093cdcb50fed33134b9b6878836de", - "sha256:b30c6590146e53149f04e85a6e4fcae068df4289e31e4aee1fdf56a0dead8f97", - "sha256:b58cbf0697721120866820b89f93659abc31c1e876bf20d0b3d03cef14faf84d", - "sha256:b67c6f5e5a401fc56394f191f00f9b3811fe843ee93f4a70df3c389d1adf857d", - "sha256:bceab846bac555aff6427d060f2fcfff71042dba6f5fca7dc4f75cac815e57ca", - "sha256:bee9fcb41db2a23bed96c6b6ead6489702c12334ea20a297aa095ce6d31370d0", - "sha256:c114e8da9b475739dde229fd3bc6b05a6537a88a578358bc8eb29b4030fac9c9", - "sha256:c1f0524f203e3bd35149f12157438f406eff2e4fb30f71221c8a5eceb3617b6b", - "sha256:c792ea4eabc0159535608fc5658a74d1a81020eb35195dd63214dcf07556f67e", - "sha256:c7f3cb904cce8e1be667c7e6fef4516b98d1a6a0635a58a57528d577ac18a128", - "sha256:d67ac60a307f760c6e65dad586f556dde58e683fab03323221a4e530ead6f74d", - "sha256:dcacf2c7a6c3a84e720d1bb2b543c675bf6c40e460300b628bab1b1efc7c034c", - "sha256:de36fe9c02995c7e6ae6efe2e205816f5f00c22fd1fbf343d4d18c3d5ceac2f5", - "sha256:def07915168ac8f7853812cc593c71185a16216e9e4fa886358a17ed0fd9fcf6", - "sha256:df41b9bc27c2c25b486bae7cf42fccdc52ff181c8c387bfd026624a491c2671b", - "sha256:e052b8467dd07d4943936009f46ae5ce7b908ddcac3fda581656b1b19c083d9b", - "sha256:e063b1865974611313a3849d43f2c3f5368093691349cf3c7c8f8f75ad7cb280", - "sha256:e1459677e5d12be8bbc7584c35b992eea142911a6236a3278b9b5ce3326f282c", - "sha256:e1a99a7a71631f0efe727c10edfba09ea6bee4166a6f9c19aafb6c0b5917d09c", - "sha256:e590228200fcfc7e9109509e4d9125eace2042fd52b595dd22bbc34bb282307f", - "sha256:e6316827e3e79b7b8e7d8e3b08f4e331af91a48e794d5d8b099928b6f0b85f20", - "sha256:e7837cb169eca3b3ae94cc5787c4fed99eef74c0ab9506756eea335e0d6f3ed8", - "sha256:e848f46a58b9fcf3d06061d17be388caf70ea5b8cc3466251963c8345e13f7eb", - "sha256:ed058398f55163a79bb9f06a90ef9ccc063b204bb346c4de78efc5d15abfe602", - "sha256:f2e58f2c36cc52d41f2659e4c0cbf7353e28c8c9e63e30d8c6d3494dc9fdedcf", - "sha256:f467ba0050b7de85016b43f5a22b46383ef004c4f672148a8abf32bc999a87f0", - "sha256:f61bdb1df43dc9c131791fbc2355535f9024b9a04398d3bd0684fc16ab07df74", - "sha256:fb06eea71a00a7af0ae6aefbb932fb8a7df3cb390cc217d51a9ad7343de1b8d0", - "sha256:ffd7dcaf744f25f82190856bc26ed81721508fc5cbf2a330751e135ff1283564" - ], - "version": "==11.0.3" + "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b", + "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6", + "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df", + "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b", + "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205", + "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892", + "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53", + "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2", + "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed", + "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c", + "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd", + "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b", + "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931", + "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30", + "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370", + "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be", + "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec", + "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf", + "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62", + "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b", + "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402", + "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f", + "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123", + "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9", + "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603", + "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45", + "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558", + "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4", + "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438", + "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137", + "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480", + "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447", + "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8", + "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04", + "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c", + "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb", + "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967", + "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b", + "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d", + "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def", + "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c", + "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92", + "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2", + "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113", + "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b", + "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28", + "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7", + "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d", + "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f", + "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468", + "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8", + "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae", + "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611", + "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d", + "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9", + "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca", + "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f", + "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2", + "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077", + "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2", + "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6", + "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374", + "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc", + "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e", + "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53", + "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399", + "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547", + "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3", + "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870", + "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5", + "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8", + "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7" + ], + "version": "==12.0" }, "yarl": { "hashes": [ - "sha256:04ab9d4b9f587c06d801c2abfe9317b77cdf996c65a90d5e84ecc45010823571", - "sha256:066c163aec9d3d073dc9ffe5dd3ad05069bcb03fcaab8d221290ba99f9f69ee3", - "sha256:13414591ff516e04fcdee8dc051c13fd3db13b673c7a4cb1350e6b2ad9639ad3", - "sha256:149ddea5abf329752ea5051b61bd6c1d979e13fbf122d3a1f9f0c8be6cb6f63c", - "sha256:159d81f22d7a43e6eabc36d7194cb53f2f15f498dbbfa8edc8a3239350f59fe7", - "sha256:1b1bba902cba32cdec51fca038fd53f8beee88b77efc373968d1ed021024cc04", - "sha256:22a94666751778629f1ec4280b08eb11815783c63f52092a5953faf73be24191", - "sha256:2a96c19c52ff442a808c105901d0bdfd2e28575b3d5f82e2f5fd67e20dc5f4ea", - "sha256:2b0738fb871812722a0ac2154be1f049c6223b9f6f22eec352996b69775b36d4", - "sha256:2c315df3293cd521033533d242d15eab26583360b58f7ee5d9565f15fee1bef4", - "sha256:32f1d071b3f362c80f1a7d322bfd7b2d11e33d2adf395cc1dd4df36c9c243095", - "sha256:3458a24e4ea3fd8930e934c129b676c27452e4ebda80fbe47b56d8c6c7a63a9e", - "sha256:38a3928ae37558bc1b559f67410df446d1fbfa87318b124bf5032c31e3447b74", - "sha256:3da8a678ca8b96c8606bbb8bfacd99a12ad5dd288bc6f7979baddd62f71c63ef", - "sha256:494053246b119b041960ddcd20fd76224149cfea8ed8777b687358727911dd33", - "sha256:50f33040f3836e912ed16d212f6cc1efb3231a8a60526a407aeb66c1c1956dde", - "sha256:52a25809fcbecfc63ac9ba0c0fb586f90837f5425edfd1ec9f3372b119585e45", - "sha256:53338749febd28935d55b41bf0bcc79d634881195a39f6b2f767870b72514caf", - "sha256:5415d5a4b080dc9612b1b63cba008db84e908b95848369aa1da3686ae27b6d2b", - "sha256:5610f80cf43b6202e2c33ba3ec2ee0a2884f8f423c8f4f62906731d876ef4fac", - "sha256:566185e8ebc0898b11f8026447eacd02e46226716229cea8db37496c8cdd26e0", - "sha256:56ff08ab5df8429901ebdc5d15941b59f6253393cb5da07b4170beefcf1b2528", - "sha256:59723a029760079b7d991a401386390c4be5bfec1e7dd83e25a6a0881859e716", - "sha256:5fcd436ea16fee7d4207c045b1e340020e58a2597301cfbcfdbe5abd2356c2fb", - "sha256:61016e7d582bc46a5378ffdd02cd0314fb8ba52f40f9cf4d9a5e7dbef88dee18", - "sha256:63c48f6cef34e6319a74c727376e95626f84ea091f92c0250a98e53e62c77c72", - "sha256:646d663eb2232d7909e6601f1a9107e66f9791f290a1b3dc7057818fe44fc2b6", - "sha256:662e6016409828ee910f5d9602a2729a8a57d74b163c89a837de3fea050c7582", - "sha256:674ca19cbee4a82c9f54e0d1eee28116e63bc6fd1e96c43031d11cbab8b2afd5", - "sha256:6a5883464143ab3ae9ba68daae8e7c5c95b969462bbe42e2464d60e7e2698368", - "sha256:6e7221580dc1db478464cfeef9b03b95c5852cc22894e418562997df0d074ccc", - "sha256:75df5ef94c3fdc393c6b19d80e6ef1ecc9ae2f4263c09cacb178d871c02a5ba9", - "sha256:783185c75c12a017cc345015ea359cc801c3b29a2966c2655cd12b233bf5a2be", - "sha256:822b30a0f22e588b32d3120f6d41e4ed021806418b4c9f0bc3048b8c8cb3f92a", - "sha256:8288d7cd28f8119b07dd49b7230d6b4562f9b61ee9a4ab02221060d21136be80", - "sha256:82aa6264b36c50acfb2424ad5ca537a2060ab6de158a5bd2a72a032cc75b9eb8", - "sha256:832b7e711027c114d79dffb92576acd1bd2decc467dec60e1cac96912602d0e6", - "sha256:838162460b3a08987546e881a2bfa573960bb559dfa739e7800ceeec92e64417", - "sha256:83fcc480d7549ccebe9415d96d9263e2d4226798c37ebd18c930fce43dfb9574", - "sha256:84e0b1599334b1e1478db01b756e55937d4614f8654311eb26012091be109d59", - "sha256:891c0e3ec5ec881541f6c5113d8df0315ce5440e244a716b95f2525b7b9f3608", - "sha256:8c2ad583743d16ddbdf6bb14b5cd76bf43b0d0006e918809d5d4ddf7bde8dd82", - "sha256:8c56986609b057b4839968ba901944af91b8e92f1725d1a2d77cbac6972b9ed1", - "sha256:8ea48e0a2f931064469bdabca50c2f578b565fc446f302a79ba6cc0ee7f384d3", - "sha256:8ec53a0ea2a80c5cd1ab397925f94bff59222aa3cf9c6da938ce05c9ec20428d", - "sha256:95d2ecefbcf4e744ea952d073c6922e72ee650ffc79028eb1e320e732898d7e8", - "sha256:9b3152f2f5677b997ae6c804b73da05a39daa6a9e85a512e0e6823d81cdad7cc", - "sha256:9bf345c3a4f5ba7f766430f97f9cc1320786f19584acc7086491f45524a551ac", - "sha256:a60347f234c2212a9f0361955007fcf4033a75bf600a33c88a0a8e91af77c0e8", - "sha256:a74dcbfe780e62f4b5a062714576f16c2f3493a0394e555ab141bf0d746bb955", - "sha256:a83503934c6273806aed765035716216cc9ab4e0364f7f066227e1aaea90b8d0", - "sha256:ac9bb4c5ce3975aeac288cfcb5061ce60e0d14d92209e780c93954076c7c4367", - "sha256:aff634b15beff8902d1f918012fc2a42e0dbae6f469fce134c8a0dc51ca423bb", - "sha256:b03917871bf859a81ccb180c9a2e6c1e04d2f6a51d953e6a5cdd70c93d4e5a2a", - "sha256:b124e2a6d223b65ba8768d5706d103280914d61f5cae3afbc50fc3dfcc016623", - "sha256:b25322201585c69abc7b0e89e72790469f7dad90d26754717f3310bfe30331c2", - "sha256:b7232f8dfbd225d57340e441d8caf8652a6acd06b389ea2d3222b8bc89cbfca6", - "sha256:b8cc1863402472f16c600e3e93d542b7e7542a540f95c30afd472e8e549fc3f7", - "sha256:b9a4e67ad7b646cd6f0938c7ebfd60e481b7410f574c560e455e938d2da8e0f4", - "sha256:be6b3fdec5c62f2a67cb3f8c6dbf56bbf3f61c0f046f84645cd1ca73532ea051", - "sha256:bf74d08542c3a9ea97bb8f343d4fcbd4d8f91bba5ec9d5d7f792dbe727f88938", - "sha256:c027a6e96ef77d401d8d5a5c8d6bc478e8042f1e448272e8d9752cb0aff8b5c8", - "sha256:c0c77533b5ed4bcc38e943178ccae29b9bcf48ffd1063f5821192f23a1bd27b9", - "sha256:c1012fa63eb6c032f3ce5d2171c267992ae0c00b9e164efe4d73db818465fac3", - "sha256:c3a53ba34a636a256d767c086ceb111358876e1fb6b50dfc4d3f4951d40133d5", - "sha256:d4e2c6d555e77b37288eaf45b8f60f0737c9efa3452c6c44626a5455aeb250b9", - "sha256:de119f56f3c5f0e2fb4dee508531a32b069a5f2c6e827b272d1e0ff5ac040333", - "sha256:e65610c5792870d45d7b68c677681376fcf9cc1c289f23e8e8b39c1485384185", - "sha256:e9fdc7ac0d42bc3ea78818557fab03af6181e076a2944f43c38684b4b6bed8e3", - "sha256:ee4afac41415d52d53a9833ebae7e32b344be72835bbb589018c9e938045a560", - "sha256:f364d3480bffd3aa566e886587eaca7c8c04d74f6e8933f3f2c996b7f09bee1b", - "sha256:f3b078dbe227f79be488ffcfc7a9edb3409d018e0952cf13f15fd6512847f3f7", - "sha256:f4e2d08f07a3d7d3e12549052eb5ad3eab1c349c53ac51c209a0e5991bbada78", - "sha256:f7a3d8146575e08c29ed1cd287068e6d02f1c7bdff8970db96683b9591b86ee7" + "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51", + "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce", + "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559", + "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0", + "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81", + "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc", + "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4", + "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c", + "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130", + "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136", + "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e", + "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec", + "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7", + "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1", + "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455", + "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099", + "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129", + "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10", + "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142", + "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98", + "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa", + "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7", + "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525", + "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c", + "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9", + "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c", + "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8", + "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b", + "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf", + "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23", + "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd", + "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27", + "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f", + "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece", + "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434", + "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec", + "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff", + "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78", + "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d", + "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863", + "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53", + "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31", + "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15", + "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5", + "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b", + "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57", + "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3", + "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1", + "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f", + "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad", + "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c", + "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7", + "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2", + "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b", + "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2", + "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b", + "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9", + "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be", + "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e", + "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984", + "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4", + "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074", + "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2", + "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392", + "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91", + "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541", + "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf", + "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572", + "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66", + "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575", + "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14", + "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5", + "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1", + "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e", + "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551", + "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17", + "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead", + "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0", + "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe", + "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234", + "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0", + "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7", + "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34", + "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42", + "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385", + "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78", + "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be", + "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958", + "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749", + "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec" ], "markers": "python_version >= '3.7'", - "version": "==1.9.2" + "version": "==1.9.4" }, "zipp": { "hashes": [ @@ -2163,18 +2341,18 @@ "develop": { "anyio": { "hashes": [ - "sha256:cfdb2b588b9fc25ede96d8db56ed50848b0b649dca3dd1df0b11f683bb9e0b5f", - "sha256:f7ed51751b2c2add651e5747c891b47e26d2a21be5d32d9311dfe9692f3e5d7a" + "sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f", + "sha256:5a0bec7085176715be77df87fc66d6c9d70626bd752fcc85f57cdbee5b3760da" ], "markers": "python_version >= '3.8'", - "version": "==4.0.0" + "version": "==4.1.0" }, "asttokens": { "hashes": [ - "sha256:2e0171b991b2c959acc6c49318049236844a5da1d65ba2672c4880c1c894834e", - "sha256:cf8fc9e61a86461aa9fb161a14a0841a03c405fa829ac6b202670b3495d2ce69" + "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24", + "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0" ], - "version": "==2.4.0" + "version": "==2.4.1" }, "attrs": { "hashes": [ @@ -2184,13 +2362,6 @@ "markers": "python_version >= '3.7'", "version": "==23.1.0" }, - "backcall": { - "hashes": [ - "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", - "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255" - ], - "version": "==0.2.0" - }, "backports-datetime-fromisoformat": { "hashes": [ "sha256:9577a2a9486cd7383a5f58b23bb8e81cf0821dbbc0eb7c87d3fa198c1df40f5c" @@ -2204,7 +2375,6 @@ "sha256:f0b0e4eba956de51238e17573b7087e852dfe9854afd2e9c873f73fc0ca0a6dd" ], "index": "pypi", - "markers": "python_version >= '2.6'", "version": "==1.5" }, "black": { @@ -2223,40 +2393,38 @@ "sha256:d30b212bffeb1e252b31dd269dfae69dd17e06d92b87ad26e23890f3efea366f" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==22.12.0" }, "cachetools": { "hashes": [ - "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590", - "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b" + "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2", + "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1" ], "markers": "python_version >= '3.7'", - "version": "==5.3.1" + "version": "==5.3.2" }, "cattrs": { "hashes": [ - "sha256:b2bb14311ac17bed0d58785e5a60f022e5431aca3932e3fc5cc8ed8639de50a4", - "sha256:db1c821b8c537382b2c7c66678c3790091ca0275ac486c76f3c8f3920e83c657" + "sha256:0341994d94971052e9ee70662542699a3162ea1e0c62f7ce1b4a57f563685108", + "sha256:a934090d95abaa9e911dac357e3a8699e0b4b14f8529bcc7d2b1ad9d51672b9f" ], - "markers": "python_version >= '3.7'", - "version": "==23.1.2" + "markers": "python_version >= '3.8'", + "version": "==23.2.3" }, "cerberus": { "hashes": [ "sha256:302e6694f206dd85cb63f13fd5025b31ab6d38c99c50c6d769f8fa0b0f299589" ], "index": "pypi", - "markers": "python_version >= '2.7'", "version": "==1.3.2" }, "certifi": { "hashes": [ - "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", - "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" + "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", + "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" ], "markers": "python_version >= '3.6'", - "version": "==2023.7.22" + "version": "==2023.11.17" }, "cfgv": { "hashes": [ @@ -2268,84 +2436,99 @@ }, "charset-normalizer": { "hashes": [ - "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96", - "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c", - "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710", - "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706", - "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020", - "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252", - "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad", - "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329", - "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a", - "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f", - "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6", - "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4", - "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a", - "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46", - "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2", - "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23", - "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace", - "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd", - "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982", - "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10", - "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2", - "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea", - "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09", - "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5", - "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149", - "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489", - "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9", - "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80", - "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592", - "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3", - "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6", - "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed", - "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c", - "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200", - "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a", - "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e", - "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d", - "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6", - "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623", - "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669", - "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3", - "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa", - "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9", - "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2", - "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f", - "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1", - "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4", - "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a", - "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8", - "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3", - "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029", - "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f", - "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959", - "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22", - "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7", - "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952", - "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346", - "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e", - "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d", - "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299", - "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd", - "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a", - "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3", - "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037", - "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94", - "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c", - "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858", - "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a", - "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449", - "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c", - "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918", - "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1", - "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c", - "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", - "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" + "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", + "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", + "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", + "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", + "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", + "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", + "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", + "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", + "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", + "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", + "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", + "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", + "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", + "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", + "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", + "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", + "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", + "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", + "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", + "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", + "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", + "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", + "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", + "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", + "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", + "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", + "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", + "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", + "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", + "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", + "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", + "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", + "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", + "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", + "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", + "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", + "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", + "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", + "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", + "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", + "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", + "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", + "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", + "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", + "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", + "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", + "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", + "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", + "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", + "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", + "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", + "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", + "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", + "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", + "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", + "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", + "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", + "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", + "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", + "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", + "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", + "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", + "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", + "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", + "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", + "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", + "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", + "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", + "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", + "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", + "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", + "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", + "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", + "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", + "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", + "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", + "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", + "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", + "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", + "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", + "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", + "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", + "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", + "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", + "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", + "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", + "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", + "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", + "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", + "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.2.0" + "version": "==3.3.2" }, "cheroot": { "hashes": [ @@ -2361,7 +2544,6 @@ "sha256:ccb974e3b103c47324a277836150b567f22f511276d49164fa8a05d5117e3dac" ], "index": "pypi", - "markers": "python_version >= '2.7' and python_version != '3.0'", "version": "==11.0.0" }, "ci-info": { @@ -2385,61 +2567,61 @@ "toml" ], "hashes": [ - "sha256:025ded371f1ca280c035d91b43252adbb04d2aea4c7105252d3cbc227f03b375", - "sha256:04312b036580ec505f2b77cbbdfb15137d5efdfade09156961f5277149f5e344", - "sha256:0575c37e207bb9b98b6cf72fdaaa18ac909fb3d153083400c2d48e2e6d28bd8e", - "sha256:07d156269718670d00a3b06db2288b48527fc5f36859425ff7cec07c6b367745", - "sha256:1f111a7d85658ea52ffad7084088277135ec5f368457275fc57f11cebb15607f", - "sha256:220eb51f5fb38dfdb7e5d54284ca4d0cd70ddac047d750111a68ab1798945194", - "sha256:229c0dd2ccf956bf5aeede7e3131ca48b65beacde2029f0361b54bf93d36f45a", - "sha256:245c5a99254e83875c7fed8b8b2536f040997a9b76ac4c1da5bff398c06e860f", - "sha256:2829c65c8faaf55b868ed7af3c7477b76b1c6ebeee99a28f59a2cb5907a45760", - "sha256:4aba512a15a3e1e4fdbfed2f5392ec221434a614cc68100ca99dcad7af29f3f8", - "sha256:4c96dd7798d83b960afc6c1feb9e5af537fc4908852ef025600374ff1a017392", - "sha256:50dd1e2dd13dbbd856ffef69196781edff26c800a74f070d3b3e3389cab2600d", - "sha256:5289490dd1c3bb86de4730a92261ae66ea8d44b79ed3cc26464f4c2cde581fbc", - "sha256:53669b79f3d599da95a0afbef039ac0fadbb236532feb042c534fbb81b1a4e40", - "sha256:553d7094cb27db58ea91332e8b5681bac107e7242c23f7629ab1316ee73c4981", - "sha256:586649ada7cf139445da386ab6f8ef00e6172f11a939fc3b2b7e7c9082052fa0", - "sha256:5ae4c6da8b3d123500f9525b50bf0168023313963e0e2e814badf9000dd6ef92", - "sha256:5b4ee7080878077af0afa7238df1b967f00dc10763f6e1b66f5cced4abebb0a3", - "sha256:5d991e13ad2ed3aced177f524e4d670f304c8233edad3210e02c465351f785a0", - "sha256:614f1f98b84eb256e4f35e726bfe5ca82349f8dfa576faabf8a49ca09e630086", - "sha256:636a8ac0b044cfeccae76a36f3b18264edcc810a76a49884b96dd744613ec0b7", - "sha256:6407424621f40205bbe6325686417e5e552f6b2dba3535dd1f90afc88a61d465", - "sha256:6bc6f3f4692d806831c136c5acad5ccedd0262aa44c087c46b7101c77e139140", - "sha256:6cb7fe1581deb67b782c153136541e20901aa312ceedaf1467dcb35255787952", - "sha256:74bb470399dc1989b535cb41f5ca7ab2af561e40def22d7e188e0a445e7639e3", - "sha256:75c8f0df9dfd8ff745bccff75867d63ef336e57cc22b2908ee725cc552689ec8", - "sha256:770f143980cc16eb601ccfd571846e89a5fe4c03b4193f2e485268f224ab602f", - "sha256:7eb0b188f30e41ddd659a529e385470aa6782f3b412f860ce22b2491c89b8593", - "sha256:7eb3cd48d54b9bd0e73026dedce44773214064be93611deab0b6a43158c3d5a0", - "sha256:87d38444efffd5b056fcc026c1e8d862191881143c3aa80bb11fcf9dca9ae204", - "sha256:8a07b692129b8a14ad7a37941a3029c291254feb7a4237f245cfae2de78de037", - "sha256:966f10df9b2b2115da87f50f6a248e313c72a668248be1b9060ce935c871f276", - "sha256:a6191b3a6ad3e09b6cfd75b45c6aeeffe7e3b0ad46b268345d159b8df8d835f9", - "sha256:aab8e9464c00da5cb9c536150b7fbcd8850d376d1151741dd0d16dfe1ba4fd26", - "sha256:ac3c5b7e75acac31e490b7851595212ed951889918d398b7afa12736c85e13ce", - "sha256:ac9ad38204887349853d7c313f53a7b1c210ce138c73859e925bc4e5d8fc18e7", - "sha256:b9c0c19f70d30219113b18fe07e372b244fb2a773d4afde29d5a2f7930765136", - "sha256:c397c70cd20f6df7d2a52283857af622d5f23300c4ca8e5bd8c7a543825baa5a", - "sha256:c6601a60318f9c3945be6ea0f2a80571f4299b6801716f8a6e4846892737ebe4", - "sha256:c6f55d38818ca9596dc9019eae19a47410d5322408140d9a0076001a3dcb938c", - "sha256:ca70466ca3a17460e8fc9cea7123c8cbef5ada4be3140a1ef8f7b63f2f37108f", - "sha256:ca833941ec701fda15414be400c3259479bfde7ae6d806b69e63b3dc423b1832", - "sha256:cd0f7429ecfd1ff597389907045ff209c8fdb5b013d38cfa7c60728cb484b6e3", - "sha256:cd694e19c031733e446c8024dedd12a00cda87e1c10bd7b8539a87963685e969", - "sha256:cdd088c00c39a27cfa5329349cc763a48761fdc785879220d54eb785c8a38520", - "sha256:de30c1aa80f30af0f6b2058a91505ea6e36d6535d437520067f525f7df123887", - "sha256:defbbb51121189722420a208957e26e49809feafca6afeef325df66c39c4fdb3", - "sha256:f09195dda68d94a53123883de75bb97b0e35f5f6f9f3aa5bf6e496da718f0cb6", - "sha256:f12d8b11a54f32688b165fd1a788c408f927b0960984b899be7e4c190ae758f1", - "sha256:f1a317fdf5c122ad642db8a97964733ab7c3cf6009e1a8ae8821089993f175ff", - "sha256:f2781fd3cabc28278dc982a352f50c81c09a1a500cc2086dc4249853ea96b981", - "sha256:f4f456590eefb6e1b3c9ea6328c1e9fa0f1006e7481179d749b3376fc793478e" + "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1", + "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63", + "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9", + "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312", + "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3", + "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb", + "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25", + "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92", + "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda", + "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148", + "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6", + "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216", + "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a", + "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640", + "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836", + "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c", + "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f", + "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2", + "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901", + "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed", + "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a", + "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074", + "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc", + "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84", + "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083", + "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f", + "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c", + "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c", + "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637", + "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2", + "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82", + "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f", + "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce", + "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef", + "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f", + "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611", + "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c", + "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76", + "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9", + "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce", + "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9", + "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf", + "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf", + "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9", + "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6", + "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2", + "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a", + "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a", + "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf", + "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738", + "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a", + "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4" ], "markers": "python_version >= '3.8'", - "version": "==7.3.1" + "version": "==7.3.2" }, "decorator": { "hashes": [ @@ -2465,25 +2647,26 @@ }, "etelemetry": { "hashes": [ - "sha256:78febd59a22eb53d052d731f10f24139eb2854fd237348fba683dd8616fb4a67" + "sha256:a64f09bcd55cbfa5684e4d9fb6d1d6a018ab99d2ea28e638435c4c26e6814a6b" ], "markers": "python_version >= '3.7'", - "version": "==0.3.0" + "version": "==0.3.1" }, "exceptiongroup": { "hashes": [ - "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", - "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" + "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", + "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" ], "markers": "python_version < '3.11'", - "version": "==1.1.3" + "version": "==1.2.0" }, "executing": { "hashes": [ - "sha256:0314a69e37426e3608aada02473b4161d4caf5a4b244d1d0c48072b8fee7bacc", - "sha256:19da64c18d2d851112f09c287f8d3dbbdf725ab0e569077efb6cdcbd3497c107" + "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147", + "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc" ], - "version": "==1.2.0" + "markers": "python_version >= '3.5'", + "version": "==2.0.1" }, "faker": { "hashes": [ @@ -2495,11 +2678,11 @@ }, "filelock": { "hashes": [ - "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4", - "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd" + "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e", + "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c" ], "markers": "python_version >= '3.8'", - "version": "==3.12.4" + "version": "==3.13.1" }, "flake8": { "hashes": [ @@ -2507,127 +2690,159 @@ "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d" ], "index": "pypi", - "markers": "python_version >= '3.6'", "version": "==4.0.1" }, "frozendict": { "hashes": [ - "sha256:0bc4767e2f83db5b701c787e22380296977368b0c57e485ca71b2eedfa11c4a3", - "sha256:145afd033ebfade28416093335261b8ec1af5cccc593482309e7add062ec8668", - "sha256:23c4bb46e6b8246e1e7e49b5593c2bc09221db0d8f31f7c092be8dfb42b9e620", - "sha256:2b2fd8ce36277919b36e3c834d2389f3cd7ac068ae730c312671dd4439a5dd65", - "sha256:2b3435e5f1ca5ae68a5e95e64b09d6d5c645cadd6b87569a0b3019dd248c8d00", - "sha256:313ed8d9ba6bac35d7635cd9580ee5721a0fb016f4d2d20f0efa05dbecbdb1be", - "sha256:3957d52f1906b0c85f641a1911d214255873f6408ab4e5ad657cc27a247fb145", - "sha256:4742e76c4111bd09198d3ab66cef94be8506212311338f9182d6ef5f5cb60493", - "sha256:47fc26468407fdeb428cfc89495b7921419e670355c21b383765482fdf6c5c14", - "sha256:4c258aab9c8488338634f2ec670ef049dbf0ab0e7a2fa9bc2c7b5009cb614801", - "sha256:5526559eca8f1780a4ee5146896f59afc31435313560208dd394a3a5e537d3ff", - "sha256:5e82befa7c385a668d569cebbebbdf49cee6fea4083f08e869a1b08cfb640a9f", - "sha256:638cf363d3cbca31a341503cf2219eac52a5f5140449676fae3d9644cd3c5487", - "sha256:6ea638228692db2bf94bce40ea4b25f4077588497b516bd16576575560094bd9", - "sha256:72cfe08ab8ae524e54848fa90b22d02c1b1ecfb3064438696bcaa4b953f18772", - "sha256:750632cc890d8ee9484fe6d31b261159144b6efacc08e1317fe46accd1410373", - "sha256:7a75bf87e76c4386caecdbdd02a99e53ad43a6b5c38fb3d5a634a9fc9ce41462", - "sha256:7ee5fe2658a8ac9a57f748acaf563f6a47f80b8308cbf0a04fac0ba057d41f75", - "sha256:80abe81d36e889ceec665e06ec764a7638000fa3e7be09786ac4d3ddc64b76db", - "sha256:8ccc94ac781710db44e142e1a11ff9b31d02c032c01c6868d51fcbef73086225", - "sha256:8cf35ddd25513428ec152614def9696afb93ae5ec0eb54fa6aa6206eda77ac4c", - "sha256:9a506d807858fa961aaa5b48dab6154fdc6bd045bbe9310788bbff141bb42d13", - "sha256:9ea5520e85447ff8d4681e181941e482662817ccba921b7cb3f87922056d892a", - "sha256:ba41a7ed019bd03b62d63ed3f8dea35b8243d1936f7c9ed4b5298ca45a01928e", - "sha256:c31abc8acea309b132dde441856829f6003a3d242da8b54bce4c0f2a3c8c63f0", - "sha256:d086440328a465dea9bef2dbad7548d75d1a0a0d21f43a08c03e1ec79ac5240e", - "sha256:d188d062084fba0e4bf32719ff7380b26c050b932ff164043ce82ab90587c52b", - "sha256:d3c6ce943946c2a61501c8cf116fff0892d11dd579877eb36e2aea2c27fddfef", - "sha256:da98427de26b5a2865727947480cbb53860089c4d195baa29c539da811cea617", - "sha256:e27c5c1d29d0eda7979253ec88abc239da1313b38f39f4b16984db3b3e482300", - "sha256:e4c785de7f1a13f15963945f400656b18f057c2fc76c089dacf127a2bb188c03", - "sha256:e72dbc1bcc2203cef38d205f692396f5505921a5680f66aa9a7e8bb71fd38f28", - "sha256:ed5a6c5c7a0f57269577c2a338a6002949aea21a23b7b7d06da7e7dced8b605b", - "sha256:f0f573dc4861dd7ec9e055c8cceaf45355e894e749f621f199aab7b311ac4bdb", - "sha256:f2a4e818ac457f6354401dcb631527af25e5a20fcfc81e6b5054b45fc245caca", - "sha256:f83fed36497af9562ead5e9fb8443224ba2781786bd3b92b1087cb7d0ff20135", - "sha256:ffc684773de7c88724788fa9787d0016fd75830412d58acbd9ed1a04762c675b" + "sha256:07208e4718cb70aa259ac886c19b96a4aad1cf00e9199f211746f738951bbf7c", + "sha256:0856af4f5b4288b2270e0b74078fad5cbaf4f799326b82183865f6f367008b2c", + "sha256:08a5829d708657c9d5ad58f4a7e4baa73a3d57290f9613bdd909d481fc203a3a", + "sha256:0cdd496933ddb428f3854bea9ffdce0245bb27c27909f663ad396409fb4dffb5", + "sha256:12b40526219f9583b30690011288bca4d6cce8724cda96b3c3ab08b67c5a7f09", + "sha256:1c015852dacf144dbeadf203673d8c714f788fcc2b810a36504994b3c4f5a436", + "sha256:66cded65f144393b4226bda9fe9ac2f42451d2d603e8a486015744bb566a7008", + "sha256:6b552fffeba8e41b43ce10cc0fc467e048a7c9a71ae3241057510342132555b9", + "sha256:6d40d0644f19365fc6cc428db31c0f113fa550bd15920262f9d77ccf6556d87b", + "sha256:6f8681c0ffe92be9aba40c9b9960c48f0ae7f6ea585af2b93fc9542cc3865969", + "sha256:7901828700f36fe12486705afe7afc5583434390c8f69b5419de1b6c566fb00d", + "sha256:7b4d05e231dc1a2ec874f847fd7348cbee469555468efb875a89994ecde31a81", + "sha256:809bb9c6c657bded925710a309bb2a2350bdbfdc9371df427f1a93cb8ab7ec3e", + "sha256:89218738e2122b50bf8a0444083dbe2de280402e9c2ef0929c0db0f93ff11271", + "sha256:893205dc5a4e5c4b24e5822ceb21ef14fed8ca4afae7ac688e2fc24294c85225", + "sha256:8c4ca4cc42bc30b20476616411d4b49aae6084760b99251f1cbdfed879ae53ea", + "sha256:8e7abf4539b73c8e5680dd2fdbd19ca4fc3e2b2f3666f80f022217839bb859fd", + "sha256:901e774629fc63f84d24b5e46b59de1eed22392ee98b7f92e694a127d541edac", + "sha256:93634af5a6d71762aebc7d78bdce92890b7e612588faf887c9eaf752dc7ccdb1", + "sha256:99b2f47b292cc4d68f6679918e8e9e6dc5e816924d8369d07018be56b93fb20f", + "sha256:9df392b655fadaa0174c1923e6205b30ad1ccca248e8e146e63a8147a355ee01", + "sha256:a0065db2bc76628853dd620bd08c1ca44ad0b711e92e89b4156493153add6f9d", + "sha256:aa11add43a71fd47523fbd011be5cc011df79e25ec0b0339fc0d728623aaa7ec", + "sha256:aadc83510ce82751a0bb3575231f778bc37cbb373f5f05a52b888e26cbb92f79", + "sha256:ac41c671ff33cbefc0f06c4b2a630d18ab59f5256f45f57d5632252ae4a8c07a", + "sha256:af267bd6d98cbc10580105dc76f28f7156856fa48a5bbcadd40edb85f93657ae", + "sha256:b089c7e8c95d8b043e82e7da26e165f4220d7310efaad5e94445db7e3bc8321e", + "sha256:b10df7f5d8637b1af319434f99dc25ca6f5537e28b293e4c405ebfb4bf9581fa", + "sha256:bb9f15a5ed924be2b1cb3654b7ea3b7bae265ff39e2b5784d42bd4a6e1353e45", + "sha256:c112024df64b8926a315d7e36b860967fcad8aae0c592b9f117589391373e893", + "sha256:c865962216f7cfd6dac8693f4de431a9d98a7225185ff23613ecd10c42423adc", + "sha256:c9aa28ce48d848ee520409533fd0254de4caf025c5cf1b9f27c98c1dd8cf90aa", + "sha256:da22a3e873f365f97445c49afc1e6d5198ed6d172f3efaf0e9fde0edcca3cea1", + "sha256:df2d2afa5af41bfa09dc9d5a8e6d73ae39b677a8572200c65a5ea353387ffccd", + "sha256:e78c5ac5d71f3b73f07ff9d9e3cc32dfbf7954f2c57c2d0e1fe8f1600e980b40", + "sha256:e8bec6d11f7254e405290cb1b081caffa0c18b6aa779130da9a546349c56be83", + "sha256:ff7a9cca3a3a1e584349e859d028388bd96a5475f76721471b73797472c6db17" ], "markers": "python_version >= '3.6'", - "version": "==2.3.8" - }, - "gitignore-parser": { - "hashes": [ - "sha256:8962420f7abb02cc9bb17461b37504eaa2342775408de6e5375f9c8a6c662fa7" + "version": "==2.3.10" + }, + "gevent": { + "hashes": [ + "sha256:272cffdf535978d59c38ed837916dfd2b5d193be1e9e5dcc60a5f4d5025dd98a", + "sha256:2c7b5c9912378e5f5ccf180d1fdb1e83f42b71823483066eddbe10ef1a2fcaa2", + "sha256:36a549d632c14684bcbbd3014a6ce2666c5f2a500f34d58d32df6c9ea38b6535", + "sha256:4368f341a5f51611411ec3fc62426f52ac3d6d42eaee9ed0f9eebe715c80184e", + "sha256:43daf68496c03a35287b8b617f9f91e0e7c0d042aebcc060cadc3f049aadd653", + "sha256:455e5ee8103f722b503fa45dedb04f3ffdec978c1524647f8ba72b4f08490af1", + "sha256:45792c45d60f6ce3d19651d7fde0bc13e01b56bb4db60d3f32ab7d9ec467374c", + "sha256:4e24c2af9638d6c989caffc691a039d7c7022a31c0363da367c0d32ceb4a0648", + "sha256:52b4abf28e837f1865a9bdeef58ff6afd07d1d888b70b6804557e7908032e599", + "sha256:52e9f12cd1cda96603ce6b113d934f1aafb873e2c13182cf8e86d2c5c41982ea", + "sha256:5f3c781c84794926d853d6fb58554dc0dcc800ba25c41d42f6959c344b4db5a6", + "sha256:62d121344f7465e3739989ad6b91f53a6ca9110518231553fe5846dbe1b4518f", + "sha256:65883ac026731ac112184680d1f0f1e39fa6f4389fd1fc0bf46cc1388e2599f9", + "sha256:707904027d7130ff3e59ea387dddceedb133cc742b00b3ffe696d567147a9c9e", + "sha256:72c002235390d46f94938a96920d8856d4ffd9ddf62a303a0d7c118894097e34", + "sha256:7532c17bc6c1cbac265e751b95000961715adef35a25d2b0b1813aa7263fb397", + "sha256:78eebaf5e73ff91d34df48f4e35581ab4c84e22dd5338ef32714264063c57507", + "sha256:7c1abc6f25f475adc33e5fc2dbcc26a732608ac5375d0d306228738a9ae14d3b", + "sha256:7c28e38dcde327c217fdafb9d5d17d3e772f636f35df15ffae2d933a5587addd", + "sha256:7ccf0fd378257cb77d91c116e15c99e533374a8153632c48a3ecae7f7f4f09fe", + "sha256:921dda1c0b84e3d3b1778efa362d61ed29e2b215b90f81d498eb4d8eafcd0b7a", + "sha256:a2898b7048771917d85a1d548fd378e8a7b2ca963db8e17c6d90c76b495e0e2b", + "sha256:a3c5e9b1f766a7a64833334a18539a362fb563f6c4682f9634dea72cbe24f771", + "sha256:ada07076b380918829250201df1d016bdafb3acf352f35e5693b59dceee8dd2e", + "sha256:b101086f109168b23fa3586fccd1133494bdb97f86920a24dc0b23984dc30b69", + "sha256:bf456bd6b992eb0e1e869e2fd0caf817f0253e55ca7977fd0e72d0336a8c1c6a", + "sha256:bf7af500da05363e66f122896012acb6e101a552682f2352b618e541c941a011", + "sha256:c3e5d2fa532e4d3450595244de8ccf51f5721a05088813c1abd93ad274fe15e7", + "sha256:c84d34256c243b0a53d4335ef0bc76c735873986d478c53073861a92566a8d71", + "sha256:d163d59f1be5a4c4efcdd13c2177baaf24aadf721fdf2e1af9ee54a998d160f5", + "sha256:d57737860bfc332b9b5aa438963986afe90f49645f6e053140cfa0fa1bdae1ae", + "sha256:dbb22a9bbd6a13e925815ce70b940d1578dbe5d4013f20d23e8a11eddf8d14a7", + "sha256:dcb8612787a7f4626aa881ff15ff25439561a429f5b303048f0fca8a1c781c39", + "sha256:dd6c32ab977ecf7c7b8c2611ed95fa4aaebd69b74bf08f4b4960ad516861517d", + "sha256:de350fde10efa87ea60d742901e1053eb2127ebd8b59a7d3b90597eb4e586599", + "sha256:e1ead6863e596a8cc2a03e26a7a0981f84b6b3e956101135ff6d02df4d9a6b07", + "sha256:ed7a048d3e526a5c1d55c44cb3bc06cfdc1947d06d45006cc4cf60dedc628904", + "sha256:f632487c87866094546a74eefbca2c74c1d03638b715b6feb12e80120960185a", + "sha256:fae8d5b5b8fa2a8f63b39f5447168b02db10c888a3e387ed7af2bd1b8612e543", + "sha256:fde6402c5432b835fbb7698f1c7f2809c8d6b2bd9d047ac1f5a7c1d5aa569303" ], - "version": "==0.1.6" + "index": "pypi", + "version": "==23.9.1" }, "greenlet": { "hashes": [ - "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a", - "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a", - "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1", - "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43", - "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33", - "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8", - "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088", - "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca", - "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343", - "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645", - "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db", - "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df", - "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3", - "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86", - "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2", - "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a", - "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf", - "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7", - "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394", - "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40", - "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3", - "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6", - "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74", - "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0", - "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3", - "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91", - "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5", - "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9", - "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417", - "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8", - "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b", - "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6", - "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb", - "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73", - "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b", - "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df", - "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9", - "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f", - "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0", - "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857", - "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a", - "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249", - "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30", - "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292", - "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b", - "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d", - "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b", - "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c", - "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca", - "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7", - "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75", - "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae", - "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47", - "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b", - "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470", - "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c", - "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564", - "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9", - "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099", - "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0", - "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5", - "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19", - "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1", - "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526" + "sha256:0a02d259510b3630f330c86557331a3b0e0c79dac3d166e449a39363beaae174", + "sha256:0b6f9f8ca7093fd4433472fd99b5650f8a26dcd8ba410e14094c1e44cd3ceddd", + "sha256:100f78a29707ca1525ea47388cec8a049405147719f47ebf3895e7509c6446aa", + "sha256:1757936efea16e3f03db20efd0cd50a1c86b06734f9f7338a90c4ba85ec2ad5a", + "sha256:19075157a10055759066854a973b3d1325d964d498a805bb68a1f9af4aaef8ec", + "sha256:19bbdf1cce0346ef7341705d71e2ecf6f41a35c311137f29b8a2dc2341374565", + "sha256:20107edf7c2c3644c67c12205dc60b1bb11d26b2610b276f97d666110d1b511d", + "sha256:22f79120a24aeeae2b4471c711dcf4f8c736a2bb2fabad2a67ac9a55ea72523c", + "sha256:2847e5d7beedb8d614186962c3d774d40d3374d580d2cbdab7f184580a39d234", + "sha256:28e89e232c7593d33cac35425b58950789962011cc274aa43ef8865f2e11f46d", + "sha256:329c5a2e5a0ee942f2992c5e3ff40be03e75f745f48847f118a3cfece7a28546", + "sha256:337322096d92808f76ad26061a8f5fccb22b0809bea39212cd6c406f6a7060d2", + "sha256:3fcc780ae8edbb1d050d920ab44790201f027d59fdbd21362340a85c79066a74", + "sha256:41bdeeb552d814bcd7fb52172b304898a35818107cc8778b5101423c9017b3de", + "sha256:4eddd98afc726f8aee1948858aed9e6feeb1758889dfd869072d4465973f6bfd", + "sha256:52e93b28db27ae7d208748f45d2db8a7b6a380e0d703f099c949d0f0d80b70e9", + "sha256:55d62807f1c5a1682075c62436702aaba941daa316e9161e4b6ccebbbf38bda3", + "sha256:5805e71e5b570d490938d55552f5a9e10f477c19400c38bf1d5190d760691846", + "sha256:599daf06ea59bfedbec564b1692b0166a0045f32b6f0933b0dd4df59a854caf2", + "sha256:60d5772e8195f4e9ebf74046a9121bbb90090f6550f81d8956a05387ba139353", + "sha256:696d8e7d82398e810f2b3622b24e87906763b6ebfd90e361e88eb85b0e554dc8", + "sha256:6e6061bf1e9565c29002e3c601cf68569c450be7fc3f7336671af7ddb4657166", + "sha256:80ac992f25d10aaebe1ee15df45ca0d7571d0f70b645c08ec68733fb7a020206", + "sha256:816bd9488a94cba78d93e1abb58000e8266fa9cc2aa9ccdd6eb0696acb24005b", + "sha256:85d2b77e7c9382f004b41d9c72c85537fac834fb141b0296942d52bf03fe4a3d", + "sha256:87c8ceb0cf8a5a51b8008b643844b7f4a8264a2c13fcbcd8a8316161725383fe", + "sha256:89ee2e967bd7ff85d84a2de09df10e021c9b38c7d91dead95b406ed6350c6997", + "sha256:8bef097455dea90ffe855286926ae02d8faa335ed8e4067326257cb571fc1445", + "sha256:8d11ebbd679e927593978aa44c10fc2092bc454b7d13fdc958d3e9d508aba7d0", + "sha256:91e6c7db42638dc45cf2e13c73be16bf83179f7859b07cfc139518941320be96", + "sha256:97e7ac860d64e2dcba5c5944cfc8fa9ea185cd84061c623536154d5a89237884", + "sha256:990066bff27c4fcf3b69382b86f4c99b3652bab2a7e685d968cd4d0cfc6f67c6", + "sha256:9fbc5b8f3dfe24784cee8ce0be3da2d8a79e46a276593db6868382d9c50d97b1", + "sha256:ac4a39d1abae48184d420aa8e5e63efd1b75c8444dd95daa3e03f6c6310e9619", + "sha256:b2c02d2ad98116e914d4f3155ffc905fd0c025d901ead3f6ed07385e19122c94", + "sha256:b2d3337dcfaa99698aa2377c81c9ca72fcd89c07e7eb62ece3f23a3fe89b2ce4", + "sha256:b489c36d1327868d207002391f662a1d163bdc8daf10ab2e5f6e41b9b96de3b1", + "sha256:b641161c302efbb860ae6b081f406839a8b7d5573f20a455539823802c655f63", + "sha256:b8ba29306c5de7717b5761b9ea74f9c72b9e2b834e24aa984da99cbfc70157fd", + "sha256:b9934adbd0f6e476f0ecff3c94626529f344f57b38c9a541f87098710b18af0a", + "sha256:ce85c43ae54845272f6f9cd8320d034d7a946e9773c693b27d620edec825e376", + "sha256:cf868e08690cb89360eebc73ba4be7fb461cfbc6168dd88e2fbbe6f31812cd57", + "sha256:d2905ce1df400360463c772b55d8e2518d0e488a87cdea13dd2c71dcb2a1fa16", + "sha256:d57e20ba591727da0c230ab2c3f200ac9d6d333860d85348816e1dca4cc4792e", + "sha256:d6a8c9d4f8692917a3dc7eb25a6fb337bff86909febe2f793ec1928cd97bedfc", + "sha256:d923ff276f1c1f9680d32832f8d6c040fe9306cbfb5d161b0911e9634be9ef0a", + "sha256:daa7197b43c707462f06d2c693ffdbb5991cbb8b80b5b984007de431493a319c", + "sha256:dbd4c177afb8a8d9ba348d925b0b67246147af806f0b104af4d24f144d461cd5", + "sha256:dc4d815b794fd8868c4d67602692c21bf5293a75e4b607bb92a11e821e2b859a", + "sha256:e9d21aaa84557d64209af04ff48e0ad5e28c5cca67ce43444e939579d085da72", + "sha256:ea6b8aa9e08eea388c5f7a276fabb1d4b6b9d6e4ceb12cc477c3d352001768a9", + "sha256:eabe7090db68c981fca689299c2d116400b553f4b713266b130cfc9e2aa9c5a9", + "sha256:f2f6d303f3dee132b322a14cd8765287b8f86cdc10d2cb6a6fae234ea488888e", + "sha256:f33f3258aae89da191c6ebaa3bc517c6c4cbc9b9f689e5d8452f7aedbb913fa8", + "sha256:f7bfb769f7efa0eefcd039dd19d843a4fbfbac52f1878b1da2ed5793ec9b1a65", + "sha256:f89e21afe925fcfa655965ca8ea10f24773a1791400989ff32f467badfe4a064", + "sha256:fa24255ae3c0ab67e613556375a4341af04a084bd58764731972bcbc8baeba36" ], "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": "==2.0.2" + "version": "==3.0.1" }, "h11": { "hashes": [ @@ -2647,45 +2862,47 @@ }, "httpcore": { "hashes": [ - "sha256:13b5e5cd1dca1a6636a6aaea212b19f4f85cd88c366a2b82304181b769aab3c9", - "sha256:adc5398ee0a476567bf87467063ee63584a8bce86078bf748e48754f60202ced" + "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7", + "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535" ], "markers": "python_version >= '3.8'", - "version": "==0.18.0" + "version": "==1.0.2" }, "httpx": { "hashes": [ - "sha256:181ea7f8ba3a82578be86ef4171554dd45fec26a02556a744db029a0a27b7100", - "sha256:47ecda285389cb32bb2691cc6e069e3ab0205956f681c5b2ad2325719751d875" + "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8", + "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==0.25.0" + "version": "==0.25.2" }, "identify": { "hashes": [ - "sha256:24437fbf6f4d3fe6efd0eb9d67e24dd9106db99af5ceb27996a5f7895f24bf1b", - "sha256:d43d52b86b15918c137e3a74fff5224f60385cd0e9c38e99d07c257f02f151a5" + "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d", + "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34" ], "markers": "python_version >= '3.8'", - "version": "==2.5.29" + "version": "==2.5.33" }, "idna": { "hashes": [ - "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", - "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", + "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" ], "markers": "python_version >= '3.5'", - "version": "==3.4" + "version": "==3.6" }, "ijson": { "hashes": [ + "sha256:055b71bbc37af5c3c5861afe789e15211d2d3d06ac51ee5a647adf4def19c0ea", + "sha256:0567e8c833825b119e74e10a7c29761dc65fcd155f5d4cb10f9d3b8916ef9912", "sha256:06f9707da06a19b01013f8c65bf67db523662a9b4a4ff027e946e66c261f17f0", "sha256:0974444c1f416e19de1e9f567a4560890095e71e81623c509feff642114c1e53", "sha256:0a4ae076bf97b0430e4e16c9cb635a6b773904aec45ed8dcbc9b17211b8569ba", "sha256:0b9d1141cfd1e6d6643aa0b4876730d0d28371815ce846d2e4e84a2d4f471cf3", "sha256:0e0243d166d11a2a47c17c7e885debf3b19ed136be2af1f5d1c34212850236ac", "sha256:10294e9bf89cb713da05bc4790bdff616610432db561964827074898e174f917", + "sha256:105c314fd624e81ed20f925271ec506523b8dd236589ab6c0208b8707d652a0e", "sha256:1844c5b57da21466f255a0aeddf89049e730d7f3dfc4d750f0e65c36e6a61a7c", "sha256:211124cff9d9d139dd0dfced356f1472860352c055d2481459038b8205d7d742", "sha256:2a80c0bb1053055d1599e44dc1396f713e8b3407000e6390add72d49633ff3bb", @@ -2699,6 +2916,7 @@ "sha256:3dcc33ee56f92a77f48776014ddb47af67c33dda361e84371153c4f1ed4434e1", "sha256:4252e48c95cd8ceefc2caade310559ab61c37d82dfa045928ed05328eb5b5f65", "sha256:455d7d3b7a6aacfb8ab1ebcaf697eedf5be66e044eac32508fccdc633d995f0e", + "sha256:457f8a5fc559478ac6b06b6d37ebacb4811f8c5156e997f0d87d708b0d8ab2ae", "sha256:46bafb1b9959872a1f946f8dd9c6f1a30a970fc05b7bfae8579da3f1f988e598", "sha256:4a3a6a2fbbe7550ffe52d151cf76065e6b89cfb3e9d0463e49a7e322a25d0426", "sha256:4b2ec8c2a3f1742cbd5f36b65e192028e541b5fd8c7fd97c1fc0ca6c427c704a", @@ -2725,19 +2943,25 @@ "sha256:92dc4d48e9f6a271292d6079e9fcdce33c83d1acf11e6e12696fb05c5889fe74", "sha256:96190d59f015b5a2af388a98446e411f58ecc6a93934e036daa75f75d02386a0", "sha256:9680e37a10fedb3eab24a4a7e749d8a73f26f1a4c901430e7aa81b5da15f7307", + "sha256:9788f0c915351f41f0e69ec2618b81ebfcf9f13d9d67c6d404c7f5afda3e4afb", "sha256:98c6799925a5d1988da4cd68879b8eeab52c6e029acc45e03abb7921a4715c4b", "sha256:9c2a12dcdb6fa28f333bf10b3a0f80ec70bc45280d8435be7e19696fab2bc706", "sha256:9e0a27db6454edd6013d40a956d008361aac5bff375a9c04ab11fc8c214250b5", + "sha256:a2973ce57afb142d96f35a14e9cfec08308ef178a2c76b8b5e1e98f3960438bf", "sha256:a4d7fe3629de3ecb088bff6dfe25f77be3e8261ed53d5e244717e266f8544305", "sha256:a729b0c8fb935481afe3cf7e0dadd0da3a69cc7f145dbab8502e2f1e01d85a7c", "sha256:ab4db9fee0138b60e31b3c02fff8a4c28d7b152040553b6a91b60354aebd4b02", + "sha256:ac44781de5e901ce8339352bb5594fcb3b94ced315a34dbe840b4cff3450e23b", "sha256:b49fd5fe1cd9c1c8caf6c59f82b08117dd6bea2ec45b641594e25948f48f4169", "sha256:b4eb2304573c9fdf448d3fa4a4fdcb727b93002b5c5c56c14a5ffbbc39f64ae4", "sha256:ba33c764afa9ecef62801ba7ac0319268a7526f50f7601370d9f8f04e77fc02b", "sha256:bcc51c84bb220ac330122468fe526a7777faa6464e3b04c15b476761beea424f", + "sha256:bdd0dc5da4f9dc6d12ab6e8e0c57d8b41d3c8f9ceed31a99dae7b2baf9ea769a", "sha256:be8495f7c13fa1f622a2c6b64e79ac63965b89caf664cc4e701c335c652d15f2", + "sha256:c075a547de32f265a5dd139ab2035900fef6653951628862e5cdce0d101af557", "sha256:c1a4b8eb69b6d7b4e94170aa991efad75ba156b05f0de2a6cd84f991def12ff9", "sha256:c63f3d57dbbac56cead05b12b81e8e1e259f14ce7f233a8cbe7fa0996733b628", + "sha256:c6beb80df19713e39e68dc5c337b5c76d36ccf69c30b79034634e5e4c14d6904", "sha256:ccd6be56335cbb845f3d3021b1766299c056c70c4c9165fb2fbe2d62258bae3f", "sha256:cfced0a6ec85916eb8c8e22415b7267ae118eaff2a860c42d2cc1261711d0d31", "sha256:d052417fd7ce2221114f8d3b58f05a83c1a2b6b99cafe0b86ac9ed5e2fc889df", @@ -2755,6 +2979,7 @@ "sha256:f05ed49f434ce396ddcf99e9fd98245328e99f991283850c309f5e3182211a79", "sha256:f4bc87e69d1997c6a55fff5ee2af878720801ff6ab1fb3b7f94adda050651e37", "sha256:f8d54b624629f9903005c58d9321a036c72f5c212701bbb93d1a520ecd15e370", + "sha256:fa234ab7a6a33ed51494d9d2197fb96296f9217ecae57f5551a55589091e7853", "sha256:fa8b98be298efbb2588f883f9953113d8a0023ab39abe77fe734b71b46b1220a", "sha256:fbac4e9609a1086bbad075beb2ceec486a3b138604e12d2059a33ce2cba93051", "sha256:fd12e42b9cb9c0166559a3ffa276b4f9fc9d5b4c304e5a13668642d34b48b634" @@ -2764,11 +2989,11 @@ }, "importlib-metadata": { "hashes": [ - "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb", - "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743" + "sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7", + "sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67" ], "markers": "python_version >= '3.8'", - "version": "==6.8.0" + "version": "==7.0.0" }, "iniconfig": { "hashes": [ @@ -2784,16 +3009,15 @@ "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726" ], "index": "pypi", - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.13.13" }, "ipython": { "hashes": [ - "sha256:2baeb5be6949eeebf532150f81746f8333e2ccce02de1c7eedde3f23ed5e9f1e", - "sha256:45a2c3a529296870a97b7de34eda4a31bee16bc7bf954e07d39abe49caf8f887" + "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27", + "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397" ], "markers": "python_version < '3.11' and python_version >= '3.7'", - "version": "==8.15.0" + "version": "==8.18.1" }, "isodate": { "hashes": [ @@ -2808,24 +3032,23 @@ "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" ], "index": "pypi", - "markers": "python_full_version >= '3.8.0'", "version": "==5.12.0" }, "jaraco.functools": { "hashes": [ - "sha256:8b137b0feacc17fef4bacee04c011c9e86f2341099c870a1d12d3be37b32a638", - "sha256:df2e2b0aadd2dfcee2d7e0d7d083d5a5b68f4c8621e6915ae9819a90de65dd44" + "sha256:c279cb24c93d694ef7270f970d499cab4d3813f4e08273f95398651a634f0925", + "sha256:daf276ddf234bea897ef14f43c4e1bf9eefeac7b7a82a4dd69228ac20acff68d" ], "markers": "python_version >= '3.8'", - "version": "==3.9.0" + "version": "==4.0.0" }, "jedi": { "hashes": [ - "sha256:bcf9894f1753969cbac8022a8c2eaee06bfa3724e4192470aaffe7eb6272b0c4", - "sha256:cb8ce23fbccff0025e9386b5cf85e892f94c9b822378f8da49970471335ac64e" + "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd", + "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0" ], "markers": "python_version >= '3.6'", - "version": "==0.19.0" + "version": "==0.19.1" }, "json5": { "hashes": [ @@ -2954,7 +3177,6 @@ "sha256:9b3f1a261b56d8f2394f39955f83adbc7ff3ab4bb1065ebfec19a10d3e8501e0" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==7.2.2" }, "more-itertools": { @@ -2999,7 +3221,6 @@ "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==0.991" }, "mypy-extensions": { @@ -3020,41 +3241,45 @@ }, "numpy": { "hashes": [ - "sha256:020cdbee66ed46b671429c7265cf00d8ac91c046901c55684954c3958525dab2", - "sha256:0621f7daf973d34d18b4e4bafb210bbaf1ef5e0100b5fa750bd9cde84c7ac292", - "sha256:0792824ce2f7ea0c82ed2e4fecc29bb86bee0567a080dacaf2e0a01fe7654369", - "sha256:09aaee96c2cbdea95de76ecb8a586cb687d281c881f5f17bfc0fb7f5890f6b91", - "sha256:166b36197e9debc4e384e9c652ba60c0bacc216d0fc89e78f973a9760b503388", - "sha256:186ba67fad3c60dbe8a3abff3b67a91351100f2661c8e2a80364ae6279720299", - "sha256:306545e234503a24fe9ae95ebf84d25cba1fdc27db971aa2d9f1ab6bba19a9dd", - "sha256:436c8e9a4bdeeee84e3e59614d38c3dbd3235838a877af8c211cfcac8a80b8d3", - "sha256:4a873a8180479bc829313e8d9798d5234dfacfc2e8a7ac188418189bb8eafbd2", - "sha256:4acc65dd65da28060e206c8f27a573455ed724e6179941edb19f97e58161bb69", - "sha256:51be5f8c349fdd1a5568e72713a21f518e7d6707bcf8503b528b88d33b57dc68", - "sha256:546b7dd7e22f3c6861463bebb000646fa730e55df5ee4a0224408b5694cc6148", - "sha256:5671338034b820c8d58c81ad1dafc0ed5a00771a82fccc71d6438df00302094b", - "sha256:637c58b468a69869258b8ae26f4a4c6ff8abffd4a8334c830ffb63e0feefe99a", - "sha256:767254ad364991ccfc4d81b8152912e53e103ec192d1bb4ea6b1f5a7117040be", - "sha256:7d484292eaeb3e84a51432a94f53578689ffdea3f90e10c8b203a99be5af57d8", - "sha256:7f6bad22a791226d0a5c7c27a80a20e11cfe09ad5ef9084d4d3fc4a299cca505", - "sha256:86f737708b366c36b76e953c46ba5827d8c27b7a8c9d0f471810728e5a2fe57c", - "sha256:8c6adc33561bd1d46f81131d5352348350fc23df4d742bb246cdfca606ea1208", - "sha256:914b28d3215e0c721dc75db3ad6d62f51f630cb0c277e6b3bcb39519bed10bd8", - "sha256:b44e6a09afc12952a7d2a58ca0a2429ee0d49a4f89d83a0a11052da696440e49", - "sha256:bb0d9a1aaf5f1cb7967320e80690a1d7ff69f1d47ebc5a9bea013e3a21faec95", - "sha256:c0b45c8b65b79337dee5134d038346d30e109e9e2e9d43464a2970e5c0e93229", - "sha256:c2e698cb0c6dda9372ea98a0344245ee65bdc1c9dd939cceed6bb91256837896", - "sha256:c78a22e95182fb2e7874712433eaa610478a3caf86f28c621708d35fa4fd6e7f", - "sha256:e062aa24638bb5018b7841977c360d2f5917268d125c833a686b7cbabbec496c", - "sha256:e5e18e5b14a7560d8acf1c596688f4dfd19b4f2945b245a71e5af4ddb7422feb", - "sha256:eae430ecf5794cb7ae7fa3808740b015aa80747e5266153128ef055975a72b99", - "sha256:ee84ca3c58fe48b8ddafdeb1db87388dce2c3c3f701bf447b05e4cfcc3679112", - "sha256:f042f66d0b4ae6d48e70e28d487376204d3cbf43b84c03bac57e28dac6151581", - "sha256:f8db2f125746e44dce707dd44d4f4efeea8d7e2b43aace3f8d1f235cfa2733dd", - "sha256:f93fc78fe8bf15afe2b8d6b6499f1c73953169fad1e9a8dd086cdff3190e7fdf" + "sha256:06fa1ed84aa60ea6ef9f91ba57b5ed963c3729534e6e54055fc151fad0423f0a", + "sha256:174a8880739c16c925799c018f3f55b8130c1f7c8e75ab0a6fa9d41cab092fd6", + "sha256:1a13860fdcd95de7cf58bd6f8bc5a5ef81c0b0625eb2c9a783948847abbef2c2", + "sha256:1cc3d5029a30fb5f06704ad6b23b35e11309491c999838c31f124fee32107c79", + "sha256:22f8fc02fdbc829e7a8c578dd8d2e15a9074b630d4da29cda483337e300e3ee9", + "sha256:26c9d33f8e8b846d5a65dd068c14e04018d05533b348d9eaeef6c1bd787f9919", + "sha256:2b3fca8a5b00184828d12b073af4d0fc5fdd94b1632c2477526f6bd7842d700d", + "sha256:2beef57fb031dcc0dc8fa4fe297a742027b954949cabb52a2a376c144e5e6060", + "sha256:36340109af8da8805d8851ef1d74761b3b88e81a9bd80b290bbfed61bd2b4f75", + "sha256:3703fc9258a4a122d17043e57b35e5ef1c5a5837c3db8be396c82e04c1cf9b0f", + "sha256:3ced40d4e9e18242f70dd02d739e44698df3dcb010d31f495ff00a31ef6014fe", + "sha256:4a06263321dfd3598cacb252f51e521a8cb4b6df471bb12a7ee5cbab20ea9167", + "sha256:4eb8df4bf8d3d90d091e0146f6c28492b0be84da3e409ebef54349f71ed271ef", + "sha256:5d5244aabd6ed7f312268b9247be47343a654ebea52a60f002dc70c769048e75", + "sha256:64308ebc366a8ed63fd0bf426b6a9468060962f1a4339ab1074c228fa6ade8e3", + "sha256:6a3cdb4d9c70e6b8c0814239ead47da00934666f668426fc6e94cce869e13fd7", + "sha256:854ab91a2906ef29dc3925a064fcd365c7b4da743f84b123002f6139bcb3f8a7", + "sha256:94cc3c222bb9fb5a12e334d0479b97bb2df446fbe622b470928f5284ffca3f8d", + "sha256:96ca5482c3dbdd051bcd1fce8034603d6ebfc125a7bd59f55b40d8f5d246832b", + "sha256:a2bbc29fcb1771cd7b7425f98b05307776a6baf43035d3b80c4b0f29e9545186", + "sha256:a4cd6ed4a339c21f1d1b0fdf13426cb3b284555c27ac2f156dfdaaa7e16bfab0", + "sha256:aa18428111fb9a591d7a9cc1b48150097ba6a7e8299fb56bdf574df650e7d1f1", + "sha256:aa317b2325f7aa0a9471663e6093c210cb2ae9c0ad824732b307d2c51983d5b6", + "sha256:b04f5dc6b3efdaab541f7857351aac359e6ae3c126e2edb376929bd3b7f92d7e", + "sha256:b272d4cecc32c9e19911891446b72e986157e6a1809b7b56518b4f3755267523", + "sha256:b361d369fc7e5e1714cf827b731ca32bff8d411212fccd29ad98ad622449cc36", + "sha256:b96e7b9c624ef3ae2ae0e04fa9b460f6b9f17ad8b4bec6d7756510f1f6c0c841", + "sha256:baf8aab04a2c0e859da118f0b38617e5ee65d75b83795055fb66c0d5e9e9b818", + "sha256:bcc008217145b3d77abd3e4d5ef586e3bdfba8fe17940769f8aa09b99e856c00", + "sha256:bd3f0091e845164a20bd5a326860c840fe2af79fa12e0469a12768a3ec578d80", + "sha256:cc392fdcbd21d4be6ae1bb4475a03ce3b025cd49a9be5345d76d7585aea69440", + "sha256:d73a3abcac238250091b11caef9ad12413dab01669511779bc9b29261dd50210", + "sha256:f43740ab089277d403aa07567be138fc2a89d4d9892d113b76153e0e412409f8", + "sha256:f65738447676ab5777f11e6bbbdb8ce11b785e105f690bc45966574816b6d3ea", + "sha256:f79b231bf5c16b1f39c7f4875e1ded36abee1591e98742b05d8a0fb55d8a3eec", + "sha256:fe6b44fb8fcdf7eda4ef4461b97b3f63c466b27ab151bec2366db8b197387841" ], "markers": "python_version < '3.11'", - "version": "==1.26.0" + "version": "==1.26.2" }, "owlrl": { "hashes": [ @@ -3065,43 +3290,42 @@ }, "packaging": { "hashes": [ - "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", - "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" ], "markers": "python_version >= '3.7'", - "version": "==23.1" + "version": "==23.2" }, "pandas": { "hashes": [ - "sha256:02304e11582c5d090e5a52aec726f31fe3f42895d6bfc1f28738f9b64b6f0614", - "sha256:0489b0e6aa3d907e909aef92975edae89b1ee1654db5eafb9be633b0124abe97", - "sha256:05674536bd477af36aa2effd4ec8f71b92234ce0cc174de34fd21e2ee99adbc2", - "sha256:25e8474a8eb258e391e30c288eecec565bfed3e026f312b0cbd709a63906b6f8", - "sha256:29deb61de5a8a93bdd033df328441a79fcf8dd3c12d5ed0b41a395eef9cd76f0", - "sha256:366da7b0e540d1b908886d4feb3d951f2f1e572e655c1160f5fde28ad4abb750", - "sha256:3bcad1e6fb34b727b016775bea407311f7721db87e5b409e6542f4546a4951ea", - "sha256:4c3f32fd7c4dccd035f71734df39231ac1a6ff95e8bdab8d891167197b7018d2", - "sha256:4cdb0fab0400c2cb46dafcf1a0fe084c8bb2480a1fa8d81e19d15e12e6d4ded2", - "sha256:4f99bebf19b7e03cf80a4e770a3e65eee9dd4e2679039f542d7c1ace7b7b1daa", - "sha256:58d997dbee0d4b64f3cb881a24f918b5f25dd64ddf31f467bb9b67ae4c63a1e4", - "sha256:75ce97667d06d69396d72be074f0556698c7f662029322027c226fd7a26965cb", - "sha256:84e7e910096416adec68075dc87b986ff202920fb8704e6d9c8c9897fe7332d6", - "sha256:9e2959720b70e106bb1d8b6eadd8ecd7c8e99ccdbe03ee03260877184bb2877d", - "sha256:9e50e72b667415a816ac27dfcfe686dc5a0b02202e06196b943d54c4f9c7693e", - "sha256:a0dbfea0dd3901ad4ce2306575c54348d98499c95be01b8d885a2737fe4d7a98", - "sha256:b407381258a667df49d58a1b637be33e514b07f9285feb27769cedb3ab3d0b3a", - "sha256:b8bd1685556f3374520466998929bade3076aeae77c3e67ada5ed2b90b4de7f0", - "sha256:c1f84c144dee086fe4f04a472b5cd51e680f061adf75c1ae4fc3a9275560f8f4", - "sha256:c747793c4e9dcece7bb20156179529898abf505fe32cb40c4052107a3c620b49", - "sha256:cc1ab6a25da197f03ebe6d8fa17273126120874386b4ac11c1d687df288542dd", - "sha256:dc3657869c7902810f32bd072f0740487f9e030c1a3ab03e0af093db35a9d14e", - "sha256:f5ec7740f9ccb90aec64edd71434711f58ee0ea7f5ed4ac48be11cfa9abf7317", - "sha256:fecb198dc389429be557cde50a2d46da8434a17fe37d7d41ff102e3987fd947b", - "sha256:ffa8f0966de2c22de408d0e322db2faed6f6e74265aa0856f3824813cf124363" - ], - "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==2.1.1" + "sha256:0296a66200dee556850d99b24c54c7dfa53a3264b1ca6f440e42bad424caea03", + "sha256:04d4c58e1f112a74689da707be31cf689db086949c71828ef5da86727cfe3f82", + "sha256:08637041279b8981a062899da0ef47828df52a1838204d2b3761fbd3e9fcb549", + "sha256:11a771450f36cebf2a4c9dbd3a19dfa8c46c4b905a3ea09dc8e556626060fe71", + "sha256:1329dbe93a880a3d7893149979caa82d6ba64a25e471682637f846d9dbc10dd2", + "sha256:1f539e113739a3e0cc15176bf1231a553db0239bfa47a2c870283fd93ba4f683", + "sha256:22929f84bca106921917eb73c1521317ddd0a4c71b395bcf767a106e3494209f", + "sha256:321ecdb117bf0f16c339cc6d5c9a06063854f12d4d9bc422a84bb2ed3207380a", + "sha256:35172bff95f598cc5866c047f43c7f4df2c893acd8e10e6653a4b792ed7f19bb", + "sha256:3cc4469ff0cf9aa3a005870cb49ab8969942b7156e0a46cc3f5abd6b11051dfb", + "sha256:4441ac94a2a2613e3982e502ccec3bdedefe871e8cea54b8775992485c5660ef", + "sha256:465571472267a2d6e00657900afadbe6097c8e1dc43746917db4dfc862e8863e", + "sha256:59dfe0e65a2f3988e940224e2a70932edc964df79f3356e5f2997c7d63e758b4", + "sha256:72c84ec1b1d8e5efcbff5312abe92bfb9d5b558f11e0cf077f5496c4f4a3c99e", + "sha256:7cf4cf26042476e39394f1f86868d25b265ff787c9b2f0d367280f11afbdee6d", + "sha256:7fa2ad4ff196768ae63a33f8062e6838efed3a319cf938fdf8b95e956c813042", + "sha256:a5d53c725832e5f1645e7674989f4c106e4b7249c1d57549023ed5462d73b140", + "sha256:acf08a73b5022b479c1be155d4988b72f3020f308f7a87c527702c5f8966d34f", + "sha256:b99c4e51ef2ed98f69099c72c75ec904dd610eb41a32847c4fcbc1a975f2d2b8", + "sha256:d5ded6ff28abbf0ea7689f251754d3789e1edb0c4d0d91028f0b980598418a58", + "sha256:de21e12bf1511190fc1e9ebc067f14ca09fccfb189a813b38d63211d54832f5f", + "sha256:f7ea8ae8004de0381a2376662c0505bb0a4f679f4c61fbfd122aa3d1b0e5f09d", + "sha256:fc77309da3b55732059e484a1efc0897f6149183c522390772d3561f9bf96c00", + "sha256:fca5680368a5139d4920ae3dc993eb5106d49f814ff24018b64d8850a52c6ed2", + "sha256:fcd76d67ca2d48f56e2db45833cf9d58f548f97f61eecd3fdc74268417632b8a" + ], + "index": "pypi", + "version": "==2.1.3" }, "parso": { "hashes": [ @@ -3121,26 +3345,19 @@ }, "pexpect": { "hashes": [ - "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", - "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" + "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", + "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f" ], "markers": "sys_platform != 'win32'", - "version": "==4.8.0" - }, - "pickleshare": { - "hashes": [ - "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", - "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56" - ], - "version": "==0.7.5" + "version": "==4.9.0" }, "platformdirs": { "hashes": [ - "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d", - "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d" + "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380", + "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420" ], - "markers": "python_version >= '3.7'", - "version": "==3.10.0" + "markers": "python_version >= '3.8'", + "version": "==4.1.0" }, "pluggy": { "hashes": [ @@ -3164,24 +3381,23 @@ "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70" ], "index": "pypi", - "markers": "python_full_version >= '3.6.1'", "version": "==2.7.1" }, "prettytable": { "hashes": [ - "sha256:1411c65d21dca9eaa505ba1d041bed75a6d629ae22f5109a923f4e719cfecba4", - "sha256:f7da57ba63d55116d65e5acb147bfdfa60dceccabf0d607d6817ee2888a05f2c" + "sha256:a71292ab7769a5de274b146b276ce938786f56c31cf7cea88b6f3775d82fe8c8", + "sha256:f4ed94803c23073a90620b201965e5dc0bccf1760b7a7eaf3158cab8aaffdf34" ], - "markers": "python_version >= '3.6'", - "version": "==2.5.0" + "markers": "python_version < '3.12' and python_version >= '3.8'", + "version": "==3.9.0" }, "prompt-toolkit": { "hashes": [ - "sha256:04505ade687dc26dc4284b1ad19a83be2f2afe83e7a828ace0c72f3a1df72aac", - "sha256:9dffbe1d8acf91e3de75f3b544e4842382fc06c6babe903ac9acb74dc6e08d88" + "sha256:941367d97fc815548822aa26c2a269fdc4eb21e9ec05fc5d447cf09bad5d75f0", + "sha256:f36fe301fafb7470e86aaf90f036eef600a3210be4decf461a5b1ca8403d3cb2" ], "markers": "python_full_version >= '3.7.0'", - "version": "==3.0.39" + "version": "==3.0.41" }, "psycopg2-binary": { "hashes": [ @@ -3249,7 +3465,6 @@ "sha256:ffe9dc0a884a8848075e576c1de0290d85a533a9f6e9c4e564f19adf8f6e54a7" ], "index": "pypi", - "markers": "python_version >= '3.6'", "version": "==2.9.6" }, "ptyprocess": { @@ -3264,7 +3479,6 @@ "sha256:58e83ada9e19ffe92c1fdc78ae5458ef91aeb892a5b8f0e7379e6fa61e0e664a" ], "index": "pypi", - "markers": "python_version ~= '3.6'", "version": "==2022.1.3" }, "pure-eval": { @@ -3282,12 +3496,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.8.0" }, - "pycron": { - "hashes": [ - "sha256:b916044e3e8253d5409c68df3ac64a3472c4e608dab92f40e8f595e5d3acb3de" - ], - "version": "==3.0.0" - }, "pycryptodomex": { "hashes": [ "sha256:1537d2d15b604b303aef56e7f440895a1c81adbee786b91f1f06eddc34da5314", @@ -3322,7 +3530,6 @@ "sha256:fb350e31e55211fec8ddc89fc0256f3b9bc3b44b68a8bde1cf44b3b4e80c0e42" ], "index": "pypi", - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.9.7" }, "pydantic": { @@ -3367,7 +3574,7 @@ "sha256:f59ef915cac80275245824e9d771ee939133be38215555e9dc90c6cb148aaeb5", "sha256:f8e81fc5fb17dae698f52bdd1c4f18b6ca674d7068242b2aff075f588301bbb0" ], - "markers": "python_version >= '3.7'", + "index": "pypi", "version": "==1.10.13" }, "pydantic-factories": { @@ -3376,7 +3583,6 @@ "sha256:de36e0db7108af5f4328308da9a4049311c4d5e0814553d2f39078b08b05e48d" ], "index": "pypi", - "markers": "python_version >= '3.8' and python_version < '4.0'", "version": "==1.17.3" }, "pyflakes": { @@ -3389,11 +3595,11 @@ }, "pygments": { "hashes": [ - "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692", - "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29" + "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", + "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" ], "markers": "python_version >= '3.7'", - "version": "==2.16.1" + "version": "==2.17.2" }, "pyld": { "hashes": [ @@ -3474,29 +3680,27 @@ }, "pyshacl": { "hashes": [ - "sha256:43a80bbf403176f8f37a22fbbe8d95ba5395cf755e378af1db3126e709819d2c", - "sha256:5d77ab194d4333d6c2a3c409d096ac31e68f1ae0e22c3668b2a081e32256c738" + "sha256:716b65397486b1a306efefd018d772d3c112a3828ea4e1be27aae16aee524243", + "sha256:91e87ed04ccb29aa47abfcf8a3e172d35a8831fce23a011cfbf35534ce4c940b" ], - "markers": "python_full_version >= '3.7.0' and python_full_version < '4.0.0'", - "version": "==0.23.0" + "markers": "python_full_version >= '3.8.1' and python_full_version < '4.0.0'", + "version": "==0.25.0" }, "pytest": { "hashes": [ - "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002", - "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069" + "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac", + "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==7.4.2" + "version": "==7.4.3" }, "pytest-asyncio": { "hashes": [ - "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d", - "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b" + "sha256:c16052382554c7b22d48782ab3438d5b10f8cf7a4bdcae7f0f67f097d95beecc", + "sha256:ea9021364e32d58f0be43b91c6233fb8d2224ccef2398d6837559e587682808f" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==0.21.1" + "version": "==0.23.2" }, "pytest-cov": { "hashes": [ @@ -3504,7 +3708,6 @@ "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470" ], "index": "pypi", - "markers": "python_version >= '3.6'", "version": "==3.0.0" }, "pytest-env": { @@ -3513,7 +3716,6 @@ "sha256:baed9b3b6bae77bd75b9238e0ed1ee6903a42806ae9d6aeffb8754cd5584d4ff" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==0.8.2" }, "pytest-lazy-fixture": { @@ -3526,12 +3728,11 @@ }, "pytest-mock": { "hashes": [ - "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39", - "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f" + "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f", + "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==3.11.1" + "version": "==3.12.0" }, "python-dateutil": { "hashes": [ @@ -3605,11 +3806,11 @@ }, "rdflib": { "hashes": [ - "sha256:36b4e74a32aa1e4fa7b8719876fb192f19ecd45ff932ea5ebbd2e417a0247e63", - "sha256:72af591ff704f4caacea7ecc0c5a9056b8553e0489dd4f35a9bc52dbd41522e0" + "sha256:0438920912a642c866a513de6fe8a0001bd86ef975057d6962c79ce4771687cd", + "sha256:9995eb8569428059b8c1affd26b25eac510d64f5043d9ce8c84e0d0036e995ae" ], - "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==6.3.2" + "markers": "python_full_version >= '3.8.1'", + "version": "==7.0.0" }, "redis": { "hashes": [ @@ -3624,7 +3825,6 @@ "sha256:fff9f207283eb9769b3731f7f8d2eee2f7f1f0d205d859da8c51e6ea9826628e" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==0.6.2" }, "requests": { @@ -3637,11 +3837,11 @@ }, "requests-cache": { "hashes": [ - "sha256:178282bce704b912c59e7f88f367c42bddd6cde6bf511b2a3e3cfb7e5332a92a", - "sha256:41b79166aa8e300cc4de982f7ab7c52af914a785160be1eda25c6e9265969a67" + "sha256:764f93d3fa860be72125a568c2cc8eafb151cf29b4dc2515433a56ee657e1c60", + "sha256:c8420cf096f3aafde13c374979c21844752e2694ffd8710e6764685bb577ac90" ], "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==1.1.0" + "version": "==1.1.1" }, "respx": { "hashes": [ @@ -3649,16 +3849,15 @@ "sha256:ab8e1cf6da28a5b2dd883ea617f8130f77f676736e6e9e4a25817ad116a172c9" ], "index": "pypi", - "markers": "python_version >= '3.7'", "version": "==0.20.2" }, "setuptools": { "hashes": [ - "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87", - "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a" + "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2", + "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6" ], "markers": "python_version >= '3.8'", - "version": "==68.2.2" + "version": "==69.0.2" }, "six": { "hashes": [ @@ -3678,26 +3877,10 @@ }, "stack-data": { "hashes": [ - "sha256:32d2dd0376772d01b6cb9fc996f3c8b57a357089dec328ed4b6553d037eaf815", - "sha256:cbb2a53eb64e5785878201a97ed7c7b94883f48b87bfb0bbe8b623c74679e4a8" - ], - "version": "==0.6.2" - }, - "taskiq": { - "hashes": [ - "sha256:a0ce2e28f76b87e2504693eca8dcd174013198a880fc209f831829dcf7fa6075", - "sha256:a3c4fab8959ac4bf3e8a7e372a677169de41128a2c756546555ece9f9669d3c9" + "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", + "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695" ], - "markers": "python_full_version >= '3.8.1' and python_full_version < '4.0.0'", - "version": "==0.9.1" - }, - "taskiq-dependencies": { - "hashes": [ - "sha256:4a4195eac74aa50fe3ab4f8e0c840eca7750c40f2d518c4db9c338c15effd790", - "sha256:743b3550d5afa59fd8c3a6ee0677d4866dded8f7da1a4d3238d6ba31cda2faae" - ], - "markers": "python_full_version >= '3.8.1' and python_full_version < '4.0.0'", - "version": "==1.4.2" + "version": "==0.6.3" }, "tempora": { "hashes": [ @@ -3720,16 +3903,32 @@ "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" ], - "markers": "python_version < '3.11' and python_version >= '3.7'", + "markers": "python_full_version < '3.11.0a7'", "version": "==2.0.1" }, "traitlets": { "hashes": [ - "sha256:07ab9c5bf8a0499fd7b088ba51be899c90ffc936ffc797d7b6907fc516bcd116", - "sha256:db9c4aa58139c3ba850101913915c042bdba86f7c8a0dda1c6f7f92c5da8e542" + "sha256:f14949d23829023013c47df20b4a76ccd1a85effb786dc060f34de7948361b33", + "sha256:fcdaa8ac49c04dfa0ed3ee3384ef6dfdb5d6f3741502be247279407679296772" ], "markers": "python_version >= '3.8'", - "version": "==5.10.1" + "version": "==5.14.0" + }, + "types-aiofiles": { + "hashes": [ + "sha256:5d6719e8148cb2a9c4ea46dad86d50d3b675c46a940adca698533a8d2216d53d", + "sha256:b6a7127bd232e0802532837b84140b1cd5df19ee60bea3a5699720d2b583361b" + ], + "index": "pypi", + "version": "==23.2.0.0" + }, + "types-cachetools": { + "hashes": [ + "sha256:27c982cdb9cf3fead8b0089ee6b895715ecc99dac90ec29e2cab56eb1aaf4199", + "sha256:98c069dc7fc087b1b061703369c80751b0a0fc561f6fb072b554e5eee23773a0" + ], + "index": "pypi", + "version": "==5.3.0.7" }, "types-passlib": { "hashes": [ @@ -3765,12 +3964,11 @@ }, "types-requests": { "hashes": [ - "sha256:a2db9cb228a81da8348b49ad6db3f5519452dd20a9c1e1a868c83c5fe88fd1a9", - "sha256:cd74ce3b53c461f1228a9b783929ac73a666658f223e28ed29753771477b3bd0" + "sha256:b32b9a86beffa876c0c3ac99a4cd3b8b51e973fb8e3bd4e0a6bb32c7efad80fc", + "sha256:dc5852a76f1eaf60eafa81a2e50aefa3d1f015c34cf0cba130930866b1b22a92" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==2.31.0.6" + "version": "==2.31.0.10" }, "types-six": { "hashes": [ @@ -3780,13 +3978,6 @@ "index": "pypi", "version": "==1.16.21.4" }, - "types-urllib3": { - "hashes": [ - "sha256:229b7f577c951b8c1b92c1bc2b2fdb0b49847bd2af6d1cc2a2e3dd340f3bda8f", - "sha256:9683bbb7fb72e32bfe9d2be6e04875fbe1b3eeec3cbb4ea231435aa7fd6b4f0e" - ], - "version": "==1.26.25.14" - }, "typing-extensions": { "hashes": [ "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", @@ -3805,12 +3996,11 @@ }, "tzlocal": { "hashes": [ - "sha256:46eb99ad4bdb71f3f72b7d24f4267753e240944ecfc16f25d2719ba89827a803", - "sha256:f3596e180296aaf2dbd97d124fe76ae3a0e3d32b258447de7b939b3fd4be992f" + "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8", + "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==5.0.1" + "version": "==5.2" }, "url-normalize": { "hashes": [ @@ -3822,52 +4012,52 @@ }, "urllib3": { "hashes": [ - "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f", - "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14" + "sha256:34b97092d7e0a3a8cf7cd10e386f401b3737364026c45e622aa02903dffe0f07", + "sha256:f8ecc1bba5667413457c529ab955bf8c67b45db799d159066261719e328580a0" ], "markers": "python_version >= '3.6'", - "version": "==1.26.16" + "version": "==1.26.18" }, "urwid": { "hashes": [ - "sha256:0e142752c40186fc3e7435cb4a215679099ed718a041e5a0b19b0f71f7382073", - "sha256:13d27499364037417a485b8a78e90a655d09bbfe7a09fd625e258f5d402ad35c", - "sha256:15bda7bb0edc524429ae286a70407e30e5c474c0153e9c64c4acd82171634af5", - "sha256:1e20ab476761c18b48f89ff5e6cb17fb69046a87de7f7830634f96180434b798", - "sha256:219650cb7f921e2a92af779eab9a9d1b12a099f5f769705403f031b41a954c5d", - "sha256:2aa84103b292eadd4583227edea1c7c83e77d40c1981e356d2e4f08da106aaa9", - "sha256:2b9b57a0a6f3ad109ba085a1a0a0153d098e950abe5f09a6bcd2f52346cb2d6e", - "sha256:32b4fd0031cdb16a1b3868b55fac9d47a6c266f1fad63ceaf31396acad330046", - "sha256:3d3859a4c7eedc9d4adc4e4fa1e9f8b82047b1441a2b5957a4ecb8b2077dab1c", - "sha256:436a50b750583b0be3a20e90c141a5241967c38119b50f7fa1d22221b2ff8e1d", - "sha256:447794999d5d0b5001ac711dab77548d26e78c6f74046369ec7b4147a39deee2", - "sha256:4a889f91969a9888dfcf8c12d8a1cbc0cb6b1944920097ef06c76c464032a8c8", - "sha256:533f9d2ee0cd4ecb7b7ea54607bb201ac6075c42c0da57b67120140fa1b1a548", - "sha256:53fef75a592538845c3ac46084ab0346d026cb0662506f20b932f2e29565df68", - "sha256:5f83b241c1cbf3ec6c4b8c6b908127e0c9ad7481c5d3145639524157fc4e1744", - "sha256:65c705f515c1336dd2d75accd90bc6d446c985fc70819628c55a16afe923d7f9", - "sha256:702eb91a4989d4d9600618e00ac50f9f68b0ab32313f0acec9a559a1044c1fa6", - "sha256:708b330ef90a239ddcb3633e3b16f3f8339de02d4d9f414dabfdf2f82ae8e445", - "sha256:83bed43a2741101f5875b2cb6470ebbb491d1c2488f481a86921d167d9ff591b", - "sha256:8ea07e35ee311f19da9424c652e831add974694929a25fd265189782502f1c5c", - "sha256:9590a8fd47152569ae3968fee437b10035d30b2e3ce6f916bf2f46a0db8589eb", - "sha256:9ade5dfd25ca26dff6bba53bd4b0137f793ab9f2db545eb18298591356f601ea", - "sha256:a6b71f1bd7c7e356ca401bf193c760ac9bc482630dd4dedbde0795263b54eba4", - "sha256:aca2dc1b79214adcddce7c839c8b47ba405cc2040d210955d40cd8751a6c2b62", - "sha256:ae2ec1032040c3a92cddede7b7262955595e7d0e4e8ae5a21dc4674eb3f6724b", - "sha256:b6c3c39ac47377105c699d685226deb2b02e982ac411c9e184e14a42dd2465de", - "sha256:b8cde689f8f4b8d3c62168f53d2fe6701bcc5fb831b37488c4f93bd57bfebe3d", - "sha256:c21f797f87b8fcfb72ef1660b3845658878da2e621606b229f9d5f14cc98a4e2", - "sha256:ca3c2258357c046eb7877067e22f70bf0921341233b245b7e69cbb38ec55f6e5", - "sha256:caca9d3d5b6a3a723dc7147fbaf03c1444ce7d381e3ee43169b55ba0b680dd23", - "sha256:d8ecac6d1a20932e2ff25a3de2b87c672dab41bbc36db87c5c2197f026d12ca8", - "sha256:de67e51605645a0bb17564b2c5f6406dcb41ddbb502d5ec682b5a4846d2844fd", - "sha256:e7d6f62411e72876e67de22085fe4e8426519eb39bd9473724c7c421518f2e2e", - "sha256:e98e68f2c44a9e19fa3ef68fed381d7b1f20aee572a63f37f3062c0c6096bab6", - "sha256:f1016dafdac1cb5a45f8339e5b684a2f07df15aca8b469bf0941c1724c1fb950" + "sha256:0080ad86d37792faeda2ddaf7eaab711861c34f19996876dde649e28139a741d", + "sha256:085b4b1ff4c0df96e2e82331a2353a56abbd5b7456e838baa995be4f12644347", + "sha256:0a263d8a90450166e0a195cf751b46d5081a48a3c00afa45a4ae582a34a6785e", + "sha256:0be1a86fa279850bd167a031dd71aa401b26f7e9fdcc99360785f1a292938c10", + "sha256:0c7e3f21b4427ecfffd6588fe5119eab7e12abd03576e8ba111fdfe2a78e3fdc", + "sha256:0d518d3cb428c9e0c03076dc6b83996bbfe0595d4612c3c2f572d8edbc260e9c", + "sha256:18b9f84cc80a4fcda55ea29f0ea260d31f8c1c721ff6a0396a396020e6667738", + "sha256:205c7aa020c92797f65465e1a226fbf2122159b1565b936a5fd6ed6bd34b4440", + "sha256:232b64678248c489e0dddadccfc2483a54567b74fcf74d140f3f4b4e9d15ddba", + "sha256:278ffe0c8366c03da533a983eb85ac80e325eec09f78988fd37fc830dc563eb2", + "sha256:2c4a213be475ae81b250e401e670d74d5d4ffb3b034ff6fc52e721759788eb05", + "sha256:32d91a4f2abaa022d6bcd8f4d7b179a1bb03afcb83be4707e5599131f322dbce", + "sha256:3c141f5e6a32e03e78ef28083691588d37f60fb5ffb2d96aecba3cda7fa38bb1", + "sha256:3ca3e5fbdddc3b4394cc93835a79358c56a54b05538edc7e8b66d2ff13c4689f", + "sha256:448d248bd3cbe34f0422108db0cecb7d24336703677bce06b7ed67dc4892d925", + "sha256:45e61a5be847ab36a5188aacd96554e0f354367dcae2f6cbc284de7fbbb3f075", + "sha256:4dbeb404751341f354f7d7bf5857cacb7ba415335427c78ee00991cdcc1b5bb0", + "sha256:4e4a3500b7166f27ca830df8bcfb9969d97a98f340cd4b0f0299557dee8c39b3", + "sha256:5a6fc3651d5aff40d53b52cae1951d17c62126ffcc11c7f0d53583d28198b0a4", + "sha256:5fbb765783120d5dc835424f6870190b6d73c020b44ab650b682fd9ffbc41a85", + "sha256:67bdc9c3f8a834b848e4400d91b5ab82620e1f963e4736600a304930433ea8fe", + "sha256:6cc8db27989f6166602a45f6382357827cba966e3bd607a9e409cf867f7b0ec6", + "sha256:7d38afb5bbcbca365fbd86746990c22167fbeb7f85d756bad33892f364028975", + "sha256:81a17afedc1f0ec7ee6af9ebae0c21f5caddf050831ffc5dfbd7e61a4966388a", + "sha256:8b0802494b1baaf691313cd24aaf5e4ec00bf8a6f4ba664bdf088119517db0d4", + "sha256:90440ef37b50ad5e947a095bfec7a048dfae938daa0d2355274accd886474932", + "sha256:963e89099b0438416b550161750a1e2fb52a328008732197d29e3675baacb150", + "sha256:9dae4dbccae166ee9ff03bda58c36369a566d03274c1fbf559cff11117539bc7", + "sha256:bae83944dc78bd178b0ad8cab9ff7d72b03ac01dcd0e1e01725142e241196f04", + "sha256:bbbb8c21920be76630d1ae03eb67dced3d362a56eabf05d619a0ce3f0488cb0d", + "sha256:c692bb9a314216c0cfe1fdd8787858e2e916e25f97c95e8411ba5933c0fe3c39", + "sha256:c79f55558dc50f8c19d1e1468e63942fc7577cbdb2ff948768931ef28548a5af", + "sha256:ce538f0e5c8ee2341f3e38239a1c65a5a042ee993577093067a4419c10615030", + "sha256:d1a0e6bbd4e70805519e843fa982ba322d819939eb7049e6ece5ed6aa132c96b", + "sha256:de28459d08020a07fe4d918a9ca5ff069102fa5455356cc5a1695b55ebb5bafe" ], "markers": "python_full_version >= '3.7.0'", - "version": "==2.2.2" + "version": "==2.3.4" }, "urwid-readline": { "hashes": [ @@ -3877,51 +4067,18 @@ }, "virtualenv": { "hashes": [ - "sha256:b80039f280f4919c77b30f1c23294ae357c4c8701042086e3fc005963e4e537b", - "sha256:e8361967f6da6fbdf1426483bfe9fca8287c242ac0bc30429905721cefbff752" + "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3", + "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b" ], "markers": "python_version >= '3.7'", - "version": "==20.24.5" - }, - "watchdog": { - "hashes": [ - "sha256:03f342a9432fe08107defbe8e405a2cb922c5d00c4c6c168c68b633c64ce6190", - "sha256:0d9878be36d2b9271e3abaa6f4f051b363ff54dbbe7e7df1af3c920e4311ee43", - "sha256:0e1dd6d449267cc7d6935d7fe27ee0426af6ee16578eed93bacb1be9ff824d2d", - "sha256:2caf77ae137935c1466f8cefd4a3aec7017b6969f425d086e6a528241cba7256", - "sha256:3d2dbcf1acd96e7a9c9aefed201c47c8e311075105d94ce5e899f118155709fd", - "sha256:4109cccf214b7e3462e8403ab1e5b17b302ecce6c103eb2fc3afa534a7f27b96", - "sha256:4cd61f98cb37143206818cb1786d2438626aa78d682a8f2ecee239055a9771d5", - "sha256:53f3e95081280898d9e4fc51c5c69017715929e4eea1ab45801d5e903dd518ad", - "sha256:564e7739abd4bd348aeafbf71cc006b6c0ccda3160c7053c4a53b67d14091d42", - "sha256:5b848c71ef2b15d0ef02f69da8cc120d335cec0ed82a3fa7779e27a5a8527225", - "sha256:5defe4f0918a2a1a4afbe4dbb967f743ac3a93d546ea4674567806375b024adb", - "sha256:6f5d0f7eac86807275eba40b577c671b306f6f335ba63a5c5a348da151aba0fc", - "sha256:7a1876f660e32027a1a46f8a0fa5747ad4fcf86cb451860eae61a26e102c8c79", - "sha256:7a596f9415a378d0339681efc08d2249e48975daae391d58f2e22a3673b977cf", - "sha256:85bf2263290591b7c5fa01140601b64c831be88084de41efbcba6ea289874f44", - "sha256:8a4d484e846dcd75e96b96d80d80445302621be40e293bfdf34a631cab3b33dc", - "sha256:8f2df370cd8e4e18499dd0bfdef476431bcc396108b97195d9448d90924e3131", - "sha256:91fd146d723392b3e6eb1ac21f122fcce149a194a2ba0a82c5e4d0ee29cd954c", - "sha256:95ad708a9454050a46f741ba5e2f3468655ea22da1114e4c40b8cbdaca572565", - "sha256:964fd236cd443933268ae49b59706569c8b741073dbfd7ca705492bae9d39aab", - "sha256:9da7acb9af7e4a272089bd2af0171d23e0d6271385c51d4d9bde91fe918c53ed", - "sha256:a073c91a6ef0dda488087669586768195c3080c66866144880f03445ca23ef16", - "sha256:a74155398434937ac2780fd257c045954de5b11b5c52fc844e2199ce3eecf4cf", - "sha256:aa8b028750b43e80eea9946d01925168eeadb488dfdef1d82be4b1e28067f375", - "sha256:d1f1200d4ec53b88bf04ab636f9133cb703eb19768a39351cee649de21a33697", - "sha256:d9f9ed26ed22a9d331820a8432c3680707ea8b54121ddcc9dc7d9f2ceeb36906", - "sha256:ea5d86d1bcf4a9d24610aa2f6f25492f441960cf04aed2bd9a97db439b643a7b", - "sha256:efe3252137392a471a2174d721e1037a0e6a5da7beb72a021e662b7000a9903f" - ], - "version": "==2.3.1" + "version": "==20.25.0" }, "wcwidth": { "hashes": [ - "sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e", - "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0" + "sha256:f01c104efdf57971bcb756f054dd58ddec5204dd15fa31d6503ea57947d97c02", + "sha256:f26ec43d96c8cbfed76a5075dac87680124fa84e0855195a6184da9c187f133c" ], - "version": "==0.2.6" + "version": "==0.2.12" }, "webencodings": { "hashes": [ @@ -3937,6 +4094,56 @@ ], "markers": "python_version >= '3.8'", "version": "==3.17.0" + }, + "zope.event": { + "hashes": [ + "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", + "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd" + ], + "markers": "python_version >= '3.7'", + "version": "==5.0" + }, + "zope.interface": { + "hashes": [ + "sha256:0c8cf55261e15590065039696607f6c9c1aeda700ceee40c70478552d323b3ff", + "sha256:13b7d0f2a67eb83c385880489dbb80145e9d344427b4262c49fbf2581677c11c", + "sha256:1f294a15f7723fc0d3b40701ca9b446133ec713eafc1cc6afa7b3d98666ee1ac", + "sha256:239a4a08525c080ff833560171d23b249f7f4d17fcbf9316ef4159f44997616f", + "sha256:2f8d89721834524a813f37fa174bac074ec3d179858e4ad1b7efd4401f8ac45d", + "sha256:2fdc7ccbd6eb6b7df5353012fbed6c3c5d04ceaca0038f75e601060e95345309", + "sha256:34c15ca9248f2e095ef2e93af2d633358c5f048c49fbfddf5fdfc47d5e263736", + "sha256:387545206c56b0315fbadb0431d5129c797f92dc59e276b3ce82db07ac1c6179", + "sha256:43b576c34ef0c1f5a4981163b551a8781896f2a37f71b8655fd20b5af0386abb", + "sha256:57d0a8ce40ce440f96a2c77824ee94bf0d0925e6089df7366c2272ccefcb7941", + "sha256:5a804abc126b33824a44a7aa94f06cd211a18bbf31898ba04bd0924fbe9d282d", + "sha256:67be3ca75012c6e9b109860820a8b6c9a84bfb036fbd1076246b98e56951ca92", + "sha256:6af47f10cfc54c2ba2d825220f180cc1e2d4914d783d6fc0cd93d43d7bc1c78b", + "sha256:6dc998f6de015723196a904045e5a2217f3590b62ea31990672e31fbc5370b41", + "sha256:70d2cef1bf529bff41559be2de9d44d47b002f65e17f43c73ddefc92f32bf00f", + "sha256:7ebc4d34e7620c4f0da7bf162c81978fce0ea820e4fa1e8fc40ee763839805f3", + "sha256:964a7af27379ff4357dad1256d9f215047e70e93009e532d36dcb8909036033d", + "sha256:97806e9ca3651588c1baaebb8d0c5ee3db95430b612db354c199b57378312ee8", + "sha256:9b9bc671626281f6045ad61d93a60f52fd5e8209b1610972cf0ef1bbe6d808e3", + "sha256:9ffdaa5290422ac0f1688cb8adb1b94ca56cee3ad11f29f2ae301df8aecba7d1", + "sha256:a0da79117952a9a41253696ed3e8b560a425197d4e41634a23b1507efe3273f1", + "sha256:a41f87bb93b8048fe866fa9e3d0c51e27fe55149035dcf5f43da4b56732c0a40", + "sha256:aa6fd016e9644406d0a61313e50348c706e911dca29736a3266fc9e28ec4ca6d", + "sha256:ad54ed57bdfa3254d23ae04a4b1ce405954969c1b0550cc2d1d2990e8b439de1", + "sha256:b012d023b4fb59183909b45d7f97fb493ef7a46d2838a5e716e3155081894605", + "sha256:b51b64432eed4c0744241e9ce5c70dcfecac866dff720e746d0a9c82f371dfa7", + "sha256:bbe81def9cf3e46f16ce01d9bfd8bea595e06505e51b7baf45115c77352675fd", + "sha256:c9559138690e1bd4ea6cd0954d22d1e9251e8025ce9ede5d0af0ceae4a401e43", + "sha256:e30506bcb03de8983f78884807e4fd95d8db6e65b69257eea05d13d519b83ac0", + "sha256:e33e86fd65f369f10608b08729c8f1c92ec7e0e485964670b4d2633a4812d36b", + "sha256:e441e8b7d587af0414d25e8d05e27040d78581388eed4c54c30c0c91aad3a379", + "sha256:e8bb9c990ca9027b4214fa543fd4025818dc95f8b7abce79d61dc8a2112b561a", + "sha256:ef43ee91c193f827e49599e824385ec7c7f3cd152d74cb1dfe02cb135f264d83", + "sha256:ef467d86d3cfde8b39ea1b35090208b0447caaabd38405420830f7fd85fbdd56", + "sha256:f89b28772fc2562ed9ad871c865f5320ef761a7fcc188a935e21fe8b31a38ca9", + "sha256:fddbab55a2473f1d3b8833ec6b7ac31e8211b0aa608df5ab09ce07f3727326de" + ], + "markers": "python_version >= '3.7'", + "version": "==6.1" } } } diff --git a/README.md b/README.md index d6d933945ef..53b0995b36d 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,13 @@ - ✅ [Redis](https://redis.io) - ✅ [Docker](https://docs.docker.com/get-docker/) - ✅ [Pydantic](https://pydantic-docs.helpmanual.io) -- ✅ [FastAPI](https://fastapi.tiangolo.com/) - ✅ [SQLAlchemy](https://www.sqlalchemy.org/) And - ✅ [The 12-Factor App](https://12factor.net) -- ✅ [Domain driven design](https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software-ebook/dp/B00794TAUG) -
+
🔌 **Code quality tools:** - ✅ [flake8](https://github.com/pycqa/flake8) @@ -29,7 +27,7 @@ And - ✅ [mypy](https://github.com/python/mypy) - ✅ [pytest](https://github.com/pytest-dev/pytest) -
+
## ✋ Mandatory steps @@ -49,23 +47,22 @@ git clone git@github.com:ChildMindInstitute/mindlogger-backend-refactor.git #### 2.1 Description 📜 -| Key | Default value | Description | -| --- |------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| PYTHONPATH | src/ | This variable makes all folders inside `src/` reachable in a runtime.
***NOTE:*** You don't need to do this if you use Docker as far as it is hardcoded in `Dockerfile` | -| DATABASE__HOST | postgres | Database Host | -| DATABASE__USER | postgres | User name for Postgresql Database user | -| DATABASE__PASSWORD | postgres | Password for Postgresql Database user | -| DATABASE__DB | mindlogger_backend | Database name | -| CORS__ALLOW_ORIGINS | `*` | Represents the list of allowed origins. Set the `Access-Control-Allow-Origin` header. Example: `https://dev.com,http://localohst:8000` | -| CORS__ALLOW_CREDENTIALS | true | Set the `Access-Control-Allow-Credentials` header | -| CORS__ALLOW_METHODS | `*` | Set the `Access-Control-Allow-Methods` header | -| CORS__ALLOW_HEADERS | `*` | Set the `Access-Control-Allow-Headers` header | -| AUTHENTICATION__SECRET_KEY | e51bcf5f4cb8550ff3f6a8bb4dfe112a | Access token's salt | -| AUTHENTICATION__REFRESH_SECRET_KEY | 5da342d54ed5659f123cdd1cefe439c5aaf7e317a0aba1405375c07d32e097cc | Refresh token salt | -| AUTHENTICATION__ALGORITHM | HS256 | The JWT's algorithm | -| AUTHENTICATION__ACCESS_TOKEN_EXPIRATION_TIME | 30 | Time in minutes after which the access token will stop working | -| AUTHENTICATION__REFRESH_TOKEN_EXPIRATION_TIME | 30 | Time in minutes after which the refresh token will stop working | -| ADMIN_DOMAIN | - | Admin panel domain | +| Key | Default value | Description | +| --- |--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| DATABASE__HOST | postgres | Database Host | +| DATABASE__USER | postgres | User name for Postgresql Database user | +| DATABASE__PASSWORD | postgres | Password for Postgresql Database user | +| DATABASE__DB | mindlogger_backend | Database name | +| CORS__ALLOW_ORIGINS | `*` | Represents the list of allowed origins. Set the `Access-Control-Allow-Origin` header. Example: `https://dev.com,http://localohst:8000` | +| CORS__ALLOW_CREDENTIALS | true | Set the `Access-Control-Allow-Credentials` header | +| CORS__ALLOW_METHODS | `*` | Set the `Access-Control-Allow-Methods` header | +| CORS__ALLOW_HEADERS | `*` | Set the `Access-Control-Allow-Headers` header | +| AUTHENTICATION__ACCESS_TOKEN__SECRET_KEY | secret1 | Access token's salt | +| AUTHENTICATION__REFRESH_TOKEN__SECRET_KEY | secret2 | Refresh token salt | +| AUTHENTICATION__ALGORITHM | HS256 | The JWT's algorithm | +| AUTHENTICATION__ACCESS_TOKEN__EXPIRATION | 30 | Time in minutes after which the access token will stop working | +| AUTHENTICATION__REFRESH_TOKEN__EXPIRATION | 30 | Time in minutes after which the refresh token will stop working | +| ADMIN_DOMAIN | - | Admin panel domain | ##### ✋ Mandatory: @@ -82,7 +79,7 @@ cp .env.default .env ``` -
+
## 👨‍🦯 Local development @@ -115,7 +112,7 @@ pipenv shell pipenv sync --dev ``` -
+
> 🛑 **NOTE:** if you don't use `pipenv` for some reason remember that you will not have automatically exported variables from your `.env` file. > @@ -136,7 +133,7 @@ set -o allexport; source .env; set +o allexport > 🛑 **NOTE:** Please do not forget about environment variables! Now all environment variables for the Postgres Database which runs in docker are already passed to docker-compose.yaml from the .env file. -
+
### 3. Provide code quality ✨ @@ -177,7 +174,7 @@ P.S. You don't need to do this additional step if you run application via Docker uvicorn src.main:app --proxy-headers --port {PORT} --reload ``` -
+
### 5. Running Tests ▶️ @@ -216,8 +213,18 @@ psql# create user test; # Set password for the user psql# alter user test with password 'test'; ``` -
-
+ +#### Test coverage + +To correctly calculate test coverage, you need to run the coverage with the `--concurrency=thread,gevent` parameter: + +```bash +coverage run --concurrency=thread,gevent -m pytest +coverage report -m +``` + +
+
## 🐳 Docker development @@ -631,4 +638,4 @@ postgresql+asyncpg://:@:port/database For AWS S3 bucket next fields are required: `storage_region`,`storage_bucket`, `storage_access_key`,`storage_secret_key`. ### 3. Azure Blob -In case of Azure blob, specify your connection string into field `storage_secret_key` \ No newline at end of file +In case of Azure blob, specify your connection string into field `storage_secret_key` diff --git a/compose/fastapi/Dockerfile b/compose/fastapi/Dockerfile index cde3b952d2f..823764e8443 100644 --- a/compose/fastapi/Dockerfile +++ b/compose/fastapi/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=linux/x86_64 python:3.10.8-slim +FROM --platform=linux/x86_64 python:3.10.8-slim as base ARG PIPENV_EXTRA_ARGS @@ -7,11 +7,9 @@ ENV PYTHONPATH="src/" WORKDIR /app/ -RUN apt-get update \ +RUN apt-get -y update && apt-get -y upgrade \ # dependencies for building Python packages - && apt-get install -y build-essential curl \ - # cleaning up unused files - && rm -rf /var/lib/apt/lists/* + && apt-get install -y build-essential curl # Add local non-root user to avoid issue with files # created inside a container being owned by root. @@ -42,3 +40,11 @@ RUN sed -i 's/\r$//g' /fastapi-start && chmod +x /fastapi-start # Select internal user USER code + +# worker instructions +FROM base as worker + +USER root +RUN apt-get install -y --no-install-recommends ffmpeg && apt-get install -y imagemagick + +USER code diff --git a/compose/minio/create_bucket.sh b/compose/minio/create_bucket.sh new file mode 100755 index 00000000000..4fe34712e78 --- /dev/null +++ b/compose/minio/create_bucket.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +#/usr/bin/mc alias set myminio http://minio:9000 minioaccess miniosecret; +#/usr/bin/mc mb myminio/media; +#/usr/bin/mc policy set public myminio/media; +#exit 0; + +/usr/bin/mc config host add local http://minio:9000 minioaccess miniosecret; +#/usr/bin/mc rm -r --force local/${CDN__BUCKET}; +/usr/bin/mc mb -p local/${CDN__BUCKET}; +/usr/bin/mc policy set download local/${CDN__BUCKET}; +/usr/bin/mc policy set public local/${CDN__BUCKET}; +/usr/bin/mc anonymous set upload local/${CDN__BUCKET}; +/usr/bin/mc anonymous set download local/${CDN__BUCKET}; +/usr/bin/mc anonymous set public local/${CDN__BUCKET}; + +exit 0; \ No newline at end of file diff --git a/compose/postgres/Dockerfile b/compose/postgres/Dockerfile new file mode 100644 index 00000000000..1be60652f92 --- /dev/null +++ b/compose/postgres/Dockerfile @@ -0,0 +1,7 @@ +FROM postgres:15.4 + +RUN apt-get update && apt-get install -y curl + +RUN apt-get -y install postgresql-15-cron + +COPY ./compose/postgres/init-db /docker-entrypoint-initdb.d diff --git a/compose/postgres/init-db/002-pg-cron.sh b/compose/postgres/init-db/002-pg-cron.sh new file mode 100644 index 00000000000..1fbd0b78db4 --- /dev/null +++ b/compose/postgres/init-db/002-pg-cron.sh @@ -0,0 +1,6 @@ +dbname="$POSTGRES_DB" + +echo "shared_preload_libraries = 'pg_cron'" >> /var/lib/postgresql/data/postgresql.conf +echo "cron.database_name = '$dbname'" >> /var/lib/postgresql/data/postgresql.conf + +pg_ctl restart \ No newline at end of file diff --git a/compose/postgres/init-db/003-main.sql b/compose/postgres/init-db/003-main.sql new file mode 100644 index 00000000000..9e8f0f83769 --- /dev/null +++ b/compose/postgres/init-db/003-main.sql @@ -0,0 +1 @@ +CREATE EXTENSION pg_cron; \ No newline at end of file diff --git a/conftest.py b/conftest.py index a0211fea40a..dde57b49095 100644 --- a/conftest.py +++ b/conftest.py @@ -1,9 +1,9 @@ -import asyncio import os import pytest from alembic import command from alembic.config import Config +from pytest_asyncio import is_async_test alembic_configs = [Config("alembic.ini"), Config("alembic_arbitrary.ini")] @@ -20,12 +20,11 @@ def after(): os.environ.pop("PYTEST_APP_TESTING") -@pytest.fixture(scope="session") -def event_loop(): - policy = asyncio.get_event_loop_policy() - loop = policy.new_event_loop() - yield loop - loop.close() +def pytest_collection_modifyitems(items): + pytest_asyncio_tests = (item for item in items if is_async_test(item)) + session_scope_marker = pytest.mark.asyncio(scope="session") + for async_test in pytest_asyncio_tests: + async_test.add_marker(session_scope_marker) def pytest_sessionstart(): diff --git a/docker-compose.yaml b/docker-compose.yaml index ec76ca32344..563c509d0d6 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,12 @@ -version: "3" +version: "3.4" services: postgres: - image: postgres:14 + build: + context: . + dockerfile: ./compose/postgres/Dockerfile + image: mindlogger_postgres container_name: mindlogger_postgres environment: POSTGRES_HOST: ${DATABASE__HOST} @@ -35,6 +38,7 @@ services: build: context: . dockerfile: ./compose/fastapi/Dockerfile + target: base container_name: mindlogger_app entrypoint: /fastapi-entrypoint command: /fastapi-start @@ -47,11 +51,13 @@ services: - 8000:80 volumes: - ./:/app/ + - ./uploads/:/app/uploads/ worker: build: context: . dockerfile: ./compose/fastapi/Dockerfile + target: worker image: mindlogger_worker container_name: mindlogger_worker entrypoint: /fastapi-entrypoint @@ -94,10 +100,33 @@ services: container_name: mindlogger_mongo volumes: - db_data:/data/db - - + + minio: + image: minio/minio + container_name: mindlogger_minio + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioaccess + MINIO_ROOT_PASSWORD: miniosecret + volumes: + - datastore:/data + command: server --console-address ":9001" /data + + createbuckets: + image: minio/mc + container_name: mindlogger_minio_mc + environment: + CDN__BUCKET: "${CDN__BUCKET:-media}" + depends_on: + - minio + volumes: + - './compose/minio:/etc/minio' + entrypoint: /etc/minio/create_bucket.sh + volumes: pg_data: {} db_data: {} - \ No newline at end of file + datastore: {} \ No newline at end of file diff --git a/src/apps/activities/api/activities.py b/src/apps/activities/api/activities.py index 8ca46e0a307..89ec8ffb30b 100644 --- a/src/apps/activities/api/activities.py +++ b/src/apps/activities/api/activities.py @@ -1,3 +1,4 @@ +import asyncio import uuid from fastapi import Depends @@ -7,10 +8,16 @@ ActivitySingleLanguageWithItemsDetailPublic, ) from apps.activities.services.activity import ActivityService +from apps.applets.domain.applet import ( + AppletActivitiesDetailsPublic, + AppletSingleLanguageDetailMobilePublic, +) +from apps.applets.service import AppletService from apps.authentication.deps import get_current_user from apps.shared.domain import Response from apps.users import User from apps.workspaces.service.check_access import CheckAccessService +from apps.workspaces.service.user_applet_access import UserAppletAccessService from infrastructure.database import atomic from infrastructure.database.deps import get_session from infrastructure.http import get_language @@ -49,3 +56,42 @@ async def public_activity_retrieve( return Response( result=ActivitySingleLanguageWithItemsDetailPublic.from_orm(activity) ) + + +async def applet_activities( + applet_id: uuid.UUID, + user: User = Depends(get_current_user), + language: str = Depends(get_language), + session=Depends(get_session), +) -> Response[AppletActivitiesDetailsPublic]: + async with atomic(session): + service = AppletService(session, user.id) + await service.exist_by_id(applet_id) + await CheckAccessService(session, user.id).check_applet_detail_access( + applet_id + ) + + applet_future = service.get_single_language_by_id(applet_id, language) + nickname_future = UserAppletAccessService( + session, user.id, applet_id + ).get_nickname() + activities_future = ActivityService( + session, user.id + ).get_single_language_with_items_by_applet_id(applet_id, language) + futures = await asyncio.gather( + applet_future, + nickname_future, + activities_future, + ) + applet_detail = AppletSingleLanguageDetailMobilePublic.from_orm( + futures[0] + ) + respondent_meta = {"nickname": futures[1]} + activities = futures[2] + + result = AppletActivitiesDetailsPublic( + activities_details=activities, + applet_detail=applet_detail, + respondent_meta=respondent_meta, + ) + return Response(result=result) diff --git a/src/apps/activities/commands/__init__.py b/src/apps/activities/commands/__init__.py new file mode 100644 index 00000000000..17240108a9a --- /dev/null +++ b/src/apps/activities/commands/__init__.py @@ -0,0 +1,3 @@ +from apps.activities.commands.reindex_items import app as activities + +__all__ = ["activities"] diff --git a/src/apps/activities/commands/reindex_items.py b/src/apps/activities/commands/reindex_items.py new file mode 100644 index 00000000000..9a5113cfbd6 --- /dev/null +++ b/src/apps/activities/commands/reindex_items.py @@ -0,0 +1,215 @@ +import asyncio +import uuid +from functools import wraps + +import typer +from rich import print +from rich.style import Style +from rich.table import Table +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from apps.activities.domain.activity_full import ActivityFull +from apps.activities.domain.response_values import ( + MultiSelectionValues, + SingleSelectionValues, +) +from apps.activity_flows.domain.flow_update import ( + ActivityFlowItemUpdate, + FlowUpdate, +) +from apps.applets.domain.applet_create_update import AppletUpdate +from apps.applets.domain.applet_full import AppletFull +from apps.applets.service.applet import AppletService +from apps.workspaces.crud.user_applet_access import UserAppletAccessCRUD +from infrastructure.database import atomic, session_manager + +app = typer.Typer() + + +def coro(f): + @wraps(f) + def wrapper(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +def print_results(applets: list[tuple[uuid.UUID, str]]): + table = Table( + show_header=True, + title="Fixed applets", + title_style=Style(bold=True), + ) + table.add_column("#") + table.add_column("id") + table.add_column("display name") + i = 1 + for applet in applets: + table.add_row(str(i), str(applet[0]), applet[1]) + i += 1 + print(table) + + +async def get_applets_with_problem(session: AsyncSession) -> list[uuid.UUID]: + sql = text( + """ + with broken_activity_ids as ( + select distinct activity_id as id + from activity_items + where exists ( + select 1 + from + jsonb_array_elements(response_values->'options') as opt1, + jsonb_array_elements(response_values->'options') as opt2 + where opt1->>'value' = opt2->>'value' + and opt1->>'id' <> opt2->>'id' + ) + ) + select a.applet_id + from broken_activity_ids ba + join activities a on ba.id = a.id + """ + ) + result = await session.execute(sql) + return result.scalars().all() + + +async def is_need_to_override( + values: SingleSelectionValues | MultiSelectionValues, +) -> bool: + unique_count = len(set(map(lambda v: v.value, values.options))) + return unique_count != len(values.options) + + +async def override_indexes( + values: SingleSelectionValues | MultiSelectionValues, +) -> SingleSelectionValues | MultiSelectionValues: + for i in range(len(values.options)): + values.options[i].value = i + return values + + +async def _reindex_items(activities: list[ActivityFull]) -> list[ActivityFull]: + for activity in activities: + for item in activity.items: + if isinstance( + item.response_values, SingleSelectionValues + ) or isinstance(item.response_values, MultiSelectionValues): + fix = await is_need_to_override(item.response_values) + if fix: + item.response_values = await override_indexes( + item.response_values + ) + return activities + + +async def reindex_applet( + session: AsyncSession, applet_id: uuid.UUID +) -> AppletFull: + fake_user_id = uuid.uuid4() + applet = await AppletService(session, fake_user_id).get_full_applet( + applet_id + ) + role = await UserAppletAccessCRUD(session).get_applet_owner(applet_id) + fixed_activities = await _reindex_items(applet.activities) + activity_flows = [] + for flow in applet.activity_flows: + items = [] + for flow_item in flow.items: + activity = next( + filter( + lambda a: a.id == flow_item.activity_id, fixed_activities + ), + None, + ) + if not activity: + raise Exception(f"Activity {flow_item.activity_id} not found") + item = ActivityFlowItemUpdate( + id=flow_item.id, activity_key=activity.key + ) + items.append(item) + flow_update = FlowUpdate( + id=flow.id, + name=flow.name, + description=flow.description, + is_single_report=flow.is_single_report, + hide_badge=flow.hide_badge, + report_included_activity_name=flow.report_included_activity_name, + report_included_item_name=flow.report_included_item_name, + is_hidden=flow.is_hidden, + items=items, + ) + activity_flows.append(flow_update) + update_values = AppletUpdate( + display_name=applet.display_name, + description=applet.description, + about=applet.about, + image=applet.image, + watermark=applet.watermark, + theme_id=applet.theme_id, + link=applet.link, + require_login=applet.require_login, + pinned_at=applet.pinned_at, + retention_period=applet.retention_period, + retention_type=applet.retention_type, + stream_enabled=applet.stream_enabled, + encryption=applet.encryption, + activities=fixed_activities, + activity_flows=activity_flows, + ) + async with atomic(session): + await AppletService(session, role.owner_id).update( + applet.id, update_values + ) + return applet + + +@app.command(short_help="Find and fix activity_items automatically") +@coro +async def reindex_auto(): + s_maker = session_manager.get_session() + try: + async with s_maker() as session: + result = [] + applet_ids = await get_applets_with_problem(session) + count_all = len(applet_ids) + count = 0 + for applet_id in applet_ids: + count += 1 + print(f"Processing {count}/{count_all}") + applet = await reindex_applet(session, applet_id) + result.append( + ( + applet.id, + applet.display_name, + ) + ) + print_results(result) + except Exception as ex: + print(f"[bold red] {ex}") + finally: + await s_maker.remove() + + +@app.command(short_help="Fix indexation of activity items") +@coro +async def reindex( + applet_id: uuid.UUID = typer.Argument(..., help="Applet id"), +): + s_maker = session_manager.get_session() + try: + async with s_maker() as session: + applet = await reindex_applet(session, applet_id) + print_results( + [ + ( + applet.id, + applet.display_name, + ) + ] + ) + except Exception as ex: + print(f"[bold red] {ex}") + finally: + await s_maker.remove() diff --git a/src/apps/activities/crud/activity.py b/src/apps/activities/crud/activity.py index f47db0418fa..f94276b5913 100644 --- a/src/apps/activities/crud/activity.py +++ b/src/apps/activities/crud/activity.py @@ -43,6 +43,31 @@ async def get_by_applet_id( result = await self._execute(query) return result.scalars().all() + async def get_mobile_with_items_by_applet_id( + self, applet_id: uuid.UUID, is_reviewable=None + ) -> list: + query: Query = select( + ActivitySchema.id, + ActivitySchema.name, + ActivitySchema.description, + ActivitySchema.splash_screen, + ActivitySchema.image, + ActivitySchema.show_all_at_once, + ActivitySchema.is_skippable, + ActivitySchema.is_reviewable, + ActivitySchema.is_hidden, + ActivitySchema.response_is_editable, + ActivitySchema.order, + ActivitySchema.scores_and_reports, + ) + query = query.where(ActivitySchema.applet_id == applet_id) + if isinstance(is_reviewable, bool): + query = query.where(ActivitySchema.is_reviewable == is_reviewable) + query = query.order_by(ActivitySchema.order.asc()) + result = await self._execute(query) + + return result.all() + async def get_by_id(self, activity_id: uuid.UUID) -> ActivitySchema: query: Query = select(ActivitySchema) query = query.where(ActivitySchema.id == activity_id) diff --git a/src/apps/activities/crud/activity_history.py b/src/apps/activities/crud/activity_history.py index 9419a0dd2ca..2ee5ac94d7c 100644 --- a/src/apps/activities/crud/activity_history.py +++ b/src/apps/activities/crud/activity_history.py @@ -1,16 +1,9 @@ import uuid -from sqlalchemy import any_, exists, select +from sqlalchemy import distinct, false, select, update from sqlalchemy.orm import Query -from apps.activities.db.schemas import ( - ActivityHistorySchema, - ActivityItemHistorySchema, -) -from apps.activities.domain.response_type_config import ( - PerformanceTaskType, - ResponseType, -) +from apps.activities.db.schemas import ActivityHistorySchema, ActivitySchema from apps.activities.errors import ActivityHistoryDoeNotExist from apps.applets.db.schemas import AppletHistorySchema from infrastructure.database import BaseCRUD @@ -24,7 +17,7 @@ class ActivityHistoriesCRUD(BaseCRUD[ActivityHistorySchema]): async def create_many( self, activities: list[ActivityHistorySchema], - ): + ) -> None: await self._create_many(activities) async def retrieve_by_applet_version( @@ -41,9 +34,7 @@ async def retrieve_activities_by_applet_version( ) -> list[ActivityHistorySchema]: query: Query = select(ActivityHistorySchema) query = query.where(ActivityHistorySchema.applet_id == id_version) - query = query.where( - ActivityHistorySchema.is_reviewable == False # noqa: E712 - ) + query = query.where(ActivityHistorySchema.is_reviewable == false()) query = query.order_by(ActivityHistorySchema.order.asc()) result = await self._execute(query) return result.scalars().all() @@ -78,7 +69,9 @@ async def get_by_id( raise ActivityHistoryDoeNotExist() return schema - async def exist_by_activity_id_or_raise(self, activity_id: uuid.UUID): + async def exist_by_activity_id_or_raise( + self, activity_id: uuid.UUID + ) -> None: query: Query = select(ActivityHistorySchema) query = query.where(ActivityHistorySchema.id == activity_id) query = query.order_by(ActivityHistorySchema.created_at.asc()) @@ -112,17 +105,14 @@ async def get_applet_assessment( return db_result.scalars().first() async def get_reviewable_activities( - self, applet_id_versions: list[str] + self, activity_version_ids: list[str] ) -> list[ActivityHistorySchema]: - if not applet_id_versions: + if not activity_version_ids: return [] query: Query = ( select(ActivityHistorySchema) - .where( - ActivityHistorySchema.applet_id == any_(applet_id_versions), - ActivityHistorySchema.is_reviewable.is_(True), - ) + .where(ActivityHistorySchema.id_version.in_(activity_version_ids)) .order_by( ActivityHistorySchema.applet_id, ActivityHistorySchema.order ) @@ -135,73 +125,40 @@ async def get_reviewable_activities( async def get_by_applet_id_for_summary( self, applet_id: uuid.UUID ) -> list[ActivityHistorySchema]: - app_version_query: Query = select([AppletHistorySchema.id_version]) - app_version_query = app_version_query.where( - AppletHistorySchema.id == applet_id - ) - - activity_types_query: Query = select(ActivityItemHistorySchema.id) - activity_types_query = activity_types_query.where( - ActivityItemHistorySchema.response_type.in_( - [ - PerformanceTaskType.FLANKER, - PerformanceTaskType.GYROSCOPE, - PerformanceTaskType.TOUCH, - PerformanceTaskType.ABTRAILS, - ResponseType.STABILITYTRACKER, - ] - ) - ) - activity_types_query = activity_types_query.where( - ActivityItemHistorySchema.activity_id - == ActivityHistorySchema.id_version - ) - - query: Query = select( - ActivityHistorySchema, - exists(activity_types_query).label("is_performance"), - ) + query: Query = select(ActivityHistorySchema) query = query.join( AppletHistorySchema, AppletHistorySchema.id_version == ActivityHistorySchema.applet_id, ) - query = query.where( - ActivityHistorySchema.applet_id.in_(app_version_query) - ) query = query.where(AppletHistorySchema.id == applet_id) - query = query.where( - ActivityHistorySchema.is_reviewable == False # noqa - ) + query = query.where(ActivityHistorySchema.is_reviewable == false()) query = query.order_by( ActivityHistorySchema.id.desc(), ActivityHistorySchema.created_at.desc(), ) + # For each activity get only last version query = query.distinct(ActivityHistorySchema.id) db_result = await self._execute(query) - schemas = [] - for activity_history_schema, is_performance in db_result.all(): - activity_history_schema.is_performance_task = is_performance - schemas.append(activity_history_schema) - - return schemas + return db_result.scalars().all() async def get_by_applet_id_version( - self, applet_id_version: str + self, applet_id_version: str, non_performance=False ) -> ActivityHistorySchema: query: Query = select(ActivityHistorySchema) query = query.where( ActivityHistorySchema.applet_id == applet_id_version ) - query = query.where( - ActivityHistorySchema.is_reviewable == False # noqa - ) + query = query.where(ActivityHistorySchema.is_reviewable == false()) + if non_performance: + query = query.where( + ActivityHistorySchema.is_performance_task == false() + ) db_result = await self._execute(query) - return db_result.scalars().all() async def get_activities( self, activity_id: uuid.UUID, versions: list[str] | None - ): + ) -> list[ActivityHistorySchema]: query: Query = select(ActivityHistorySchema) query = query.join( AppletHistorySchema, @@ -213,3 +170,52 @@ async def get_activities( db_result = await self._execute(query) return db_result.scalars().all() + + async def get_activity_id_versions_for_report( + self, applet_id_version: str + ) -> list[str]: + """Return list of available id_version of activities for report. + Performance tasks are not used in PDF reports. So we should not send + answers on performance task to the report server because decryption + takes much time and CPU time is wasted on report server. + """ + query: Query = select(distinct(ActivityHistorySchema.id_version)) + query = query.where( + ActivityHistorySchema.applet_id == applet_id_version, + ActivityHistorySchema.is_performance_task == false(), + ) + db_result = await self._execute(query) + return db_result.scalars().all() + + async def update_by_id(self, id_, **values): + subquery: Query = select(ActivityHistorySchema.id_version) + subquery = subquery.where(ActivityHistorySchema.id == id_) + subquery = subquery.limit(1) + subquery = subquery.order_by(ActivityHistorySchema.created_at.desc()) + subquery = subquery.subquery() + + query = update(ActivityHistorySchema) + query = query.where( + ActivityHistorySchema.id_version.in_(select([subquery])) + ) + query = query.values(**values) + query = query.returning(ActivityHistorySchema) + await self._execute(query) + + async def get_assessment_version_id(self, applet: uuid.UUID) -> str: + query: Query = ( + select(ActivityHistorySchema.id_version) + .select_from(ActivitySchema) + .join( + ActivityHistorySchema, + ActivityHistorySchema.id == ActivitySchema.id, + ) + .where( + ActivitySchema.applet_id == applet, + ActivitySchema.is_reviewable.is_(True), + ) + .order_by(ActivityHistorySchema.created_at.desc()) + .limit(1) + ) + db_result = await self._execute(query) + return db_result.scalars().first() diff --git a/src/apps/activities/crud/activity_item_history.py b/src/apps/activities/crud/activity_item_history.py index cd09cdd5d95..4a4ad375a33 100644 --- a/src/apps/activities/crud/activity_item_history.py +++ b/src/apps/activities/crud/activity_item_history.py @@ -6,6 +6,7 @@ from apps.activities.db.schemas import ( ActivityHistorySchema, ActivityItemHistorySchema, + ActivitySchema, ) from apps.applets.db.schemas import AppletHistorySchema from infrastructure.database import BaseCRUD @@ -86,24 +87,64 @@ async def get_by_activity_id_versions( return db_result.scalars().all() async def get_applets_assessments( - self, applet_id_version: str + self, + applet_id: uuid.UUID, ) -> list[ActivityItemHistorySchema]: + subquery: Query = ( + select(ActivityHistorySchema.id_version) + .join( + ActivitySchema, ActivitySchema.id == ActivityHistorySchema.id + ) + .where( + ActivitySchema.is_reviewable.is_(True), + ActivitySchema.applet_id == applet_id, + ) + .order_by(ActivityHistorySchema.created_at.desc()) + .limit(1) + .subquery() + ) + query: Query = select(ActivityItemHistorySchema) query = query.join( ActivityHistorySchema, ActivityHistorySchema.id_version == ActivityItemHistorySchema.activity_id, ) - query = query.where( - ActivityHistorySchema.applet_id == applet_id_version + query = query.join( + ActivitySchema, ActivitySchema.id == ActivityHistorySchema.id ) + query = query.where(ActivitySchema.applet_id == applet_id) query = query.where( - ActivityHistorySchema.is_reviewable == True # noqa: E712 + ActivityHistorySchema.is_reviewable == True, # noqa: E712 + ActivityHistorySchema.id_version.in_(subquery), ) query = query.order_by(ActivityItemHistorySchema.order.asc()) db_result = await self._execute(query) - return db_result.scalars().all() + res = db_result.scalars().all() + return res + + async def get_assessment_activity_items( + self, id_version: str | None + ) -> list[ActivityItemHistorySchema | None]: + if not id_version: + return [] + query: Query = select(ActivityItemHistorySchema) + query = query.join( + ActivityHistorySchema, + ActivityHistorySchema.id_version + == ActivityItemHistorySchema.activity_id, + ) + query = query.join( + ActivitySchema, ActivitySchema.id == ActivityHistorySchema.id + ) + query = query.where( + ActivityHistorySchema.is_reviewable == True, # noqa: E712 + ActivityHistorySchema.id_version == id_version, + ) + db_result = await self._execute(query) + res = db_result.scalars().all() + return res async def get_activity_items( self, activity_id: uuid.UUID, versions: list[str] | None diff --git a/src/apps/activities/crud/reusable_item_choices.py b/src/apps/activities/crud/reusable_item_choices.py index 1ce023e4d99..2ec14ff3afb 100644 --- a/src/apps/activities/crud/reusable_item_choices.py +++ b/src/apps/activities/crud/reusable_item_choices.py @@ -39,7 +39,6 @@ async def get_item_templates( async def get_item_templates_count(self, user_id_: uuid.UUID) -> int: query: Query = select(count(ReusableItemChoiceSchema.id)) query = query.where(ReusableItemChoiceSchema.user_id == user_id_) - query = query.order_by(ReusableItemChoiceSchema.id) db_result = await self._execute(query) return db_result.scalars().first() or 0 diff --git a/src/apps/activities/db/schemas/activity.py b/src/apps/activities/db/schemas/activity.py index ba7735698bc..9d55a9aef1f 100644 --- a/src/apps/activities/db/schemas/activity.py +++ b/src/apps/activities/db/schemas/activity.py @@ -1,6 +1,17 @@ -from sqlalchemy import REAL, Boolean, Column, ForeignKey, String, Text, text +from sqlalchemy import ( + REAL, + Boolean, + Column, + ForeignKey, + String, + Text, + func, + text, +) from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.ext.hybrid import hybrid_property +from apps.activities.domain.response_type_config import PerformanceTaskType from infrastructure.database.base import Base __all__ = ["ActivitySchema", "ActivityHistorySchema"] @@ -23,6 +34,17 @@ class _BaseActivitySchema: extra_fields = Column( JSONB(), default=dict, server_default=text("'{}'::jsonb") ) + performance_task_type = Column(String(255), nullable=True) + + @hybrid_property + def is_performance_task(self) -> bool: + return self.performance_task_type in PerformanceTaskType.get_values() + + @is_performance_task.expression # type: ignore[no-redef] + def is_performance_task(cls) -> bool: + return func.coalesce(cls.performance_task_type, "").in_( + PerformanceTaskType.get_values() + ) class ActivitySchema(Base, _BaseActivitySchema): diff --git a/src/apps/activities/domain/activity.py b/src/apps/activities/domain/activity.py index 2ed5e0622c6..1eb45cd5a1d 100644 --- a/src/apps/activities/domain/activity.py +++ b/src/apps/activities/domain/activity.py @@ -9,6 +9,7 @@ ActivityItemSingleLanguageDetail, ActivityItemSingleLanguageDetailPublic, ) +from apps.activities.domain.scores_reports import ScoresAndReports from apps.shared.domain import InternalModel, PublicModel @@ -43,6 +44,20 @@ class ActivitySingleLanguageDetailPublic(ActivityBase, PublicModel): created_at: datetime +class ActivitySingleLanguageMobileDetailPublic(InternalModel): + id: uuid.UUID + name: str + description: str + image: str = "" + is_reviewable: bool = False + is_skippable: bool = False + show_all_at_once: bool = False + is_hidden: bool | None = False + response_is_editable: bool = False + order: int + splash_screen: str = "" + + class ActivitySingleLanguageWithItemsDetail(ActivityBase, InternalModel): id: uuid.UUID order: int @@ -59,3 +74,21 @@ class ActivitySingleLanguageWithItemsDetailPublic(ActivityBase, PublicModel): default_factory=list ) created_at: datetime + + +class ActivityLanguageWithItemsMobileDetailPublic(PublicModel): + id: uuid.UUID + name: str + description: str + splash_screen: str = "" + image: str = "" + show_all_at_once: bool = False + is_skippable: bool = False + is_reviewable: bool = False + is_hidden: bool | None = False + response_is_editable: bool = False + order: int + items: list[ActivityItemSingleLanguageDetailPublic] = Field( + default_factory=list + ) + scores_and_reports: ScoresAndReports | None = None diff --git a/src/apps/activities/domain/activity_base.py b/src/apps/activities/domain/activity_base.py index 3264fcca9c4..3cc1d1eca8f 100644 --- a/src/apps/activities/domain/activity_base.py +++ b/src/apps/activities/domain/activity_base.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, Field +from apps.activities.domain.response_type_config import PerformanceTaskType from apps.activities.domain.scores_reports import ( ScoresAndReports, SubscaleSetting, @@ -20,3 +21,5 @@ class ActivityBase(BaseModel): scores_and_reports: ScoresAndReports | None = None subscale_setting: SubscaleSetting | None = None report_included_item_name: str | None = None + performance_task_type: PerformanceTaskType | None = None + is_performance_task: bool = False diff --git a/src/apps/activities/domain/activity_create.py b/src/apps/activities/domain/activity_create.py index cfc3a9a69b5..c1ef6d9fd70 100644 --- a/src/apps/activities/domain/activity_create.py +++ b/src/apps/activities/domain/activity_create.py @@ -6,6 +6,7 @@ from apps.activities.domain.activity_item_base import BaseActivityItem from apps.activities.domain.custom_validation import ( validate_item_flow, + validate_performance_task_type, validate_score_and_sections, validate_subscales, ) @@ -29,10 +30,10 @@ class ActivityCreate(ActivityBase, InternalModel): @root_validator() def validate_existing_ids_for_duplicate(cls, values): - items = values.get("items", []) + items: list[ActivityItemCreate] = values.get("items", []) item_names = set() - for item in items: # type:ActivityItemCreate + for item in items: if item.name in item_names: raise DuplicateActivityItemNameNameError() item_names.add(item.name) @@ -49,3 +50,7 @@ def validate_scores_and_reports_conditional_logic(cls, values): @root_validator() def validate_subscales(cls, values): return validate_subscales(values) + + @root_validator() + def validate_performance_task_type(cls, values): + return validate_performance_task_type(values) diff --git a/src/apps/activities/domain/activity_full.py b/src/apps/activities/domain/activity_full.py index 0ece48ab74e..8eaffdd5652 100644 --- a/src/apps/activities/domain/activity_full.py +++ b/src/apps/activities/domain/activity_full.py @@ -1,15 +1,10 @@ import uuid from datetime import datetime -from pydantic import Field, validator +from pydantic import Field from apps.activities.domain.activity_base import ActivityBase from apps.activities.domain.activity_item_base import BaseActivityItem -from apps.activities.domain.custom_validation import ( - validate_is_performance_task, - validate_performance_task_type, -) -from apps.activities.domain.response_type_config import PerformanceTaskType from apps.shared.domain import InternalModel, PublicModel @@ -17,7 +12,6 @@ class ActivityItemFull(BaseActivityItem, InternalModel): id: uuid.UUID activity_id: uuid.UUID order: int - extra_fields: dict = Field(default_factory=dict) class ActivityItemHistoryFull(BaseActivityItem, InternalModel): @@ -25,7 +19,6 @@ class ActivityItemHistoryFull(BaseActivityItem, InternalModel): id_version: str activity_id: str order: int - extra_fields: dict = Field(default_factory=dict) class ActivityFull(ActivityBase, InternalModel): @@ -34,7 +27,6 @@ class ActivityFull(ActivityBase, InternalModel): items: list[ActivityItemFull] = Field(default_factory=list) order: int created_at: datetime - extra_fields: dict = Field(default_factory=dict) class PublicActivityItemFull(BaseActivityItem, PublicModel): @@ -46,13 +38,3 @@ class PublicActivityFull(ActivityBase, PublicModel): id: uuid.UUID items: list[PublicActivityItemFull] = Field(default_factory=list) created_at: datetime - is_performance_task: bool = False - performance_task_type: PerformanceTaskType | None = None - - @validator("is_performance_task", always=True) - def validate_is_performance_task_full(cls, value, values): - return validate_is_performance_task(value, values) - - @validator("performance_task_type", always=True) - def validate_performance_task_type_full(cls, value, values): - return validate_performance_task_type(value, values) diff --git a/src/apps/activities/domain/activity_history.py b/src/apps/activities/domain/activity_history.py index 499825f5881..eef48c19668 100644 --- a/src/apps/activities/domain/activity_history.py +++ b/src/apps/activities/domain/activity_history.py @@ -52,6 +52,7 @@ class ActivityHistory(InternalModel): is_hidden: bool | None = False scores_and_reports: ScoresAndReports | None = None subscale_setting: SubscaleSetting | None = None + performance_task_type: PerformanceTaskType | None = None class ActivityHistoryChange(InternalModel): @@ -99,8 +100,6 @@ class ActivityHistoryTranslatedExport(ActivityBase, PublicModel): version: str | None = None description: str # type: ignore[assignment] created_at: datetime.datetime - is_performance_task: bool = False - performance_task_type: PerformanceTaskType | None = None items: list[ActivityItemSingleLanguageDetailPublic] = Field( default_factory=list ) diff --git a/src/apps/activities/domain/activity_item_base.py b/src/apps/activities/domain/activity_item_base.py index 2eab7ccdc46..dfe1beea0f7 100644 --- a/src/apps/activities/domain/activity_item_base.py +++ b/src/apps/activities/domain/activity_item_base.py @@ -17,7 +17,6 @@ IncorrectResponseValueError, NullScoreError, ScoreRequiredForResponseValueError, - ScoreRequiredForValueError, SliderMinMaxValueError, SliderRowsValueError, ) @@ -30,6 +29,7 @@ class BaseActivityItem(BaseModel): question: dict[str, str] = Field(default_factory=dict) response_type: ResponseType + # smart_union ? response_values: PublicModel | None # ResponseValueConfig config: PublicModel # ResponseTypeConfig name: str @@ -66,69 +66,68 @@ def validate_name(cls, value): raise IncorrectNameCharactersError() return value + @validator("response_type", pre=True) + def validate_response_type(cls, value): + if value not in ResponseTypeValueConfig: + raise IncorrectResponseValueError(type=ResponseType) + return value + @validator("config", pre=True) def validate_config(cls, value, values): response_type = values.get("response_type") + # response type is checked in separate validator + if not response_type: + return value # wrap value in class to validate and pass value - if response_type in ResponseTypeValueConfig: + if type(value) is not ResponseTypeValueConfig[response_type]["config"]: + try: + value = ResponseTypeValueConfig[response_type]["config"]( + **value + ) + except Exception: + raise IncorrectConfigError( + type=ResponseTypeValueConfig[response_type]["config"] + ) + + return value + + @validator("response_values", pre=True) + def validate_response_values(cls, value, values): + response_type = values.get("response_type") + if not response_type: + return value + if response_type not in list(NoneResponseType): if ( type(value) - is not ResponseTypeValueConfig[response_type]["config"] + is not ResponseTypeValueConfig[response_type]["value"] ): try: - value = ResponseTypeValueConfig[response_type]["config"]( + value = ResponseTypeValueConfig[response_type]["value"]( **value ) + except BaseError as e: + raise e except Exception: - raise IncorrectConfigError( - type=ResponseTypeValueConfig[response_type]["config"] - ) - else: - raise IncorrectResponseValueError(type=ResponseType) - - return value - - @validator("response_values", pre=True) - def validate_response_type(cls, value, values): - response_type = values.get("response_type") - if response_type in ResponseTypeValueConfig: - if response_type not in list(NoneResponseType): - if ( - type(value) - is not ResponseTypeValueConfig[response_type]["value"] - ): - try: - value = ResponseTypeValueConfig[response_type][ - "value" - ](**value) - except BaseError as e: - raise e - except Exception: - raise IncorrectResponseValueError( - type=ResponseTypeValueConfig[response_type][ - "value" - ] - ) - else: - if ( - value is not None - and type(value) - is not ResponseTypeValueConfig[response_type]["value"] - ): raise IncorrectResponseValueError( type=ResponseTypeValueConfig[response_type]["value"] ) - elif ( - type(value) - is ResponseTypeValueConfig[response_type]["value"] - ): - value = None - else: - raise IncorrectResponseValueError(type=ResponseType) + if ( + value is not None + and type(value) + is not ResponseTypeValueConfig[response_type]["value"] + ): + raise IncorrectResponseValueError( + type=ResponseTypeValueConfig[response_type]["value"] + ) + elif ( + type(value) is ResponseTypeValueConfig[response_type]["value"] + ): + value = None + return value - @root_validator() + @root_validator(skip_on_failure=True) def validate_score_required(cls, values): # validate score fields of response values for each response type @@ -147,36 +146,24 @@ def validate_score_required(cls, values): if None in scores: raise ScoreRequiredForResponseValueError() - if response_type is ResponseType.SLIDER: - # if add_scores is True in config, - # then length of scores must be equal - # to max_value - min_value + 1 and must not include None - if config.add_scores: - if len(response_values.scores) != ( - response_values.max_value - response_values.min_value + 1 - ): - raise ScoreRequiredForValueError() - if None in response_values.scores: - raise NullScoreError() + if response_type == ResponseType.SLIDER: + # if add_scores is True in config, then scores should not be None + if config.add_scores and response_values.scores is None: + raise NullScoreError() - if response_type is ResponseType.SLIDERROWS: - # if add_scores is True in config, - # then length of scores in each row must be - # equal to max_value - min_value + 1 of each row - # and must not include None + if response_type == ResponseType.SLIDERROWS: + # if add_scores is True in config, then scores should not be None if config.add_scores: for row in response_values.rows: - if len(row.scores) != (row.max_value - row.min_value + 1): - raise ScoreRequiredForValueError() - if None in row.scores: + if row.scores is None: raise NullScoreError() if response_type in [ ResponseType.SINGLESELECTROWS, ResponseType.MULTISELECTROWS, ]: - # if add_scores is True in config, then score must be provided in each option of each row of response_values # noqa: E501 - if config.add_scores or config.add_tokens: + # data_matrix must be not null if add_scores or set_alerts are set + if config.add_scores or config.set_alerts: if response_values.data_matrix is None: raise DataMatrixRequiredError() @@ -199,7 +186,7 @@ def validate_conditional_logic(cls, value, values): return value - @root_validator() + @root_validator(skip_on_failure=True) def validate_is_hidden(cls, values): # cannot hide if conditional logic is set value = values.get("is_hidden") @@ -207,15 +194,13 @@ def validate_is_hidden(cls, values): raise HiddenWhenConditionalLogicSetError() return values - @root_validator() + @root_validator(skip_on_failure=True) def validate_slider_value_alert(cls, values): # validate slider value alert response_type = values.get("response_type") config = values.get("config") response_values = values.get("response_values") - if response_type in [ - ResponseType.SLIDER, - ]: + if response_type == ResponseType.SLIDER: if response_values.alerts is not None: if not config.set_alerts: raise AlertFlagMissingSliderItemError() @@ -228,9 +213,7 @@ def validate_slider_value_alert(cls, values): if alert.value is None: raise SliderMinMaxValueError() - elif response_type in [ - ResponseType.SLIDERROWS, - ]: + elif response_type == ResponseType.SLIDERROWS: for row in response_values.rows: if row.alerts is not None: for alert in row.alerts: @@ -241,7 +224,7 @@ def validate_slider_value_alert(cls, values): return values - @root_validator() + @root_validator(skip_on_failure=True) def validate_single_multi_alert(cls, values): # validate single/multi selection type alerts response_type = values.get("response_type") diff --git a/src/apps/activities/domain/activity_update.py b/src/apps/activities/domain/activity_update.py index 1004396ada6..31b7ea0e9cb 100644 --- a/src/apps/activities/domain/activity_update.py +++ b/src/apps/activities/domain/activity_update.py @@ -6,6 +6,7 @@ from apps.activities.domain.activity_item_base import BaseActivityItem from apps.activities.domain.custom_validation import ( validate_item_flow, + validate_performance_task_type, validate_score_and_sections, validate_subscales, ) @@ -29,10 +30,10 @@ class ActivityUpdate(ActivityBase, InternalModel): @root_validator() def validate_existing_ids_for_duplicate(cls, values): - items = values.get("items", []) + items: list[ActivityItemUpdate] = values.get("items", []) item_names = set() - for item in items: # type:ActivityItemUpdate + for item in items: if item.name in item_names: raise DuplicateActivityItemNameNameError() item_names.add(item.name) @@ -50,6 +51,10 @@ def validate_score_and_sections_conditional_logic(cls, values): def validate_subscales(cls, values): return validate_subscales(values) + @root_validator() + def validate_performance_task_type(cls, values): + return validate_performance_task_type(values) + class ActivityReportConfiguration(PublicModel): report_included_item_name: str | None diff --git a/src/apps/activities/domain/custom_validation.py b/src/apps/activities/domain/custom_validation.py index 6b6570ace51..41830a0a266 100644 --- a/src/apps/activities/domain/custom_validation.py +++ b/src/apps/activities/domain/custom_validation.py @@ -219,36 +219,19 @@ def validate_subscales(values: dict): return values -def validate_is_performance_task(value: bool, values: dict): - # if items type is performance task type or contains part of the name - # of some performance task, then is_performance_task must be set - items = values.get("items", []) - for item in items: - for performance_task_type in list(PerformanceTaskType): - if performance_task_type in item.response_type: - return True - if item.response_type == ResponseType.STABILITYTRACKER: - return True - return value - - -def validate_performance_task_type(value: str | None, values: dict): +def validate_performance_task_type(values: dict): # if items type is performance task type or contains part of the name # of some performance task, then performance task type must be set items = values.get("items", []) for item in items: if item.response_type == ResponseType.STABILITYTRACKER: - for performance_task_type in list(PerformanceTaskType): - value = item.dict()["config"]["user_input_type"] - if value == performance_task_type: - return value - value = next( - ( - performance_task_type - for item in items - for performance_task_type in list(PerformanceTaskType) - if performance_task_type in item.response_type - ), - None, - ) - return value + value = item.dict()["config"]["user_input_type"] + for v in PerformanceTaskType.get_values(): + if value == v: + values["performance_task_type"] = value + elif item.response_type in ( + ResponseType.FLANKER, + ResponseType.ABTRAILS, + ): + values["performance_task_type"] = item.response_type + return values diff --git a/src/apps/activities/domain/response_type_config.py b/src/apps/activities/domain/response_type_config.py index 9405ef47168..e23aaf6dfa3 100644 --- a/src/apps/activities/domain/response_type_config.py +++ b/src/apps/activities/domain/response_type_config.py @@ -364,6 +364,10 @@ class PerformanceTaskType(str, Enum): TOUCH = "touch" ABTRAILS = "ABTrails" + @classmethod + def get_values(cls) -> list[str]: + return [i.value for i in cls] + ResponseTypeConfigOptions = [ TextConfig, diff --git a/src/apps/activities/domain/response_values.py b/src/apps/activities/domain/response_values.py index bf45d77962a..993519b7005 100644 --- a/src/apps/activities/domain/response_values.py +++ b/src/apps/activities/domain/response_values.py @@ -62,13 +62,13 @@ class ABTrailsValues(PublicModel): class _SingleSelectionValue(PublicModel): id: str | None = None text: str - image: str | None - score: int | None - tooltip: str | None + image: str | None = None + score: int | None = None + tooltip: str | None = None is_hidden: bool = Field(default=False) - color: Color | None - alert: str | None - value: int | None + color: Color | None = None + alert: str | None = None + value: int | None = None @validator("image") def validate_image(cls, value): @@ -131,10 +131,10 @@ class SliderValues(PublicModel): max_label: str | None = Field(..., max_length=100) min_value: NonNegativeInt = Field(default=0, max_value=11) max_value: NonNegativeInt = Field(default=12, max_value=12) - min_image: str | None - max_image: str | None - scores: list[int] | None - alerts: list[SliderValueAlert] | None + min_image: str | None = None + max_image: str | None = None + scores: list[int] | None = None + alerts: list[SliderValueAlert] | None = None @validator("min_image", "max_image") def validate_image(cls, value): @@ -150,9 +150,11 @@ def validate_min_max(cls, values): @root_validator def validate_scores(cls, values): - if values.get("scores") is not None and values.get("scores") != []: + # length of scores must be equal to max_value - min_value + 1 + scores = values.get("scores", []) + if scores: if ( - len(values.get("scores")) + len(scores) != values.get("max_value") - values.get("min_value") + 1 ): raise InvalidScoreLengthError() @@ -197,8 +199,8 @@ class SliderRowsValues(PublicModel): class _SingleSelectionOption(PublicModel): id: str | None = None text: str = Field(..., max_length=100) - image: str | None - tooltip: str | None + image: str | None = None + tooltip: str | None = None @validator("image") def validate_image(cls, value): @@ -214,8 +216,8 @@ def validate_id(cls, value): class _SingleSelectionRow(PublicModel): id: str | None = None row_name: str = Field(..., max_length=100) - row_image: str | None - tooltip: str | None + row_image: str | None = None + tooltip: str | None = None @validator("row_image") def validate_image(cls, value): @@ -230,9 +232,9 @@ def validate_id(cls, value): class _SingleSelectionDataOption(PublicModel): option_id: str - score: int | None - alert: str | None - value: int | None + score: int | None = None + alert: str | None = None + value: int | None = None class _SingleSelectionDataRow(PublicModel): @@ -247,15 +249,13 @@ def validate_options(cls, value): class SingleSelectionRowsValues(PublicModel): rows: list[_SingleSelectionRow] options: list[_SingleSelectionOption] - data_matrix: list[_SingleSelectionDataRow] | None + data_matrix: list[_SingleSelectionDataRow] | None = None @validator("data_matrix") def validate_data_matrix(cls, value, values): if value is not None: if len(value) != len(values["rows"]): - raise InvalidDataMatrixError( - message="data_matrix must have the same length as rows" - ) + raise InvalidDataMatrixError() for row in value: if len(row.options) != len(values["options"]): raise InvalidDataMatrixByOptionError() diff --git a/src/apps/activities/domain/scores_reports.py b/src/apps/activities/domain/scores_reports.py index 469ae886154..0d1cf97ef59 100644 --- a/src/apps/activities/domain/scores_reports.py +++ b/src/apps/activities/domain/scores_reports.py @@ -146,6 +146,14 @@ def __validate_sections(csl, value): # noqa return value +class ScoreConditionalLogicMobile(PublicModel): + id: str + name: str + flag_score: bool = False + match: Match = Field(default=Match.ALL) + conditions: list[ScoreCondition] + + class SubscaleCalculationType(str, Enum): SUM = "sum" AVERAGE = "average" diff --git a/src/apps/activities/errors.py b/src/apps/activities/errors.py index adb78f9abc6..80e96014d0f 100644 --- a/src/apps/activities/errors.py +++ b/src/apps/activities/errors.py @@ -29,10 +29,12 @@ class InvalidVersionError(ValidationError): class IncorrectConfigError(FieldError): + message_is_template: bool = True message = _("config must be of type {type}.") class IncorrectResponseValueError(FieldError): + message_is_template: bool = True message = _("response_values must be of type {type}.") diff --git a/src/apps/activities/fixtures/activities.json b/src/apps/activities/fixtures/activities.json index f435bc9ede9..f528a6e80d6 100644 --- a/src/apps/activities/fixtures/activities.json +++ b/src/apps/activities/fixtures/activities.json @@ -130,5 +130,26 @@ "response_is_editable": false, "order": 2 } + }, + { + "table": "activities", + "fields": { + "id": "09e3dbf0-aefb-4d0e-9177-bdb321bf3621", + "created_at": "2023-01-05T15:49:51.752113", + "updated_at": "2023-01-05T15:49:51.752113", + "applet_id": "92917a56-d586-4613-b7aa-991f2c4b15b1", + "name": "PHQ3", + "description": { + "en": "PHQ2", + "fr": "PHQ2" + }, + "splash_screen": "", + "image": "", + "show_all_at_once": false, + "is_skippable": false, + "is_reviewable": true, + "response_is_editable": false, + "order": 1.0 + } } ] \ No newline at end of file diff --git a/src/apps/activities/fixtures/activity_histories.json b/src/apps/activities/fixtures/activity_histories.json index 08f670cb2d8..9d8fa527ff1 100644 --- a/src/apps/activities/fixtures/activity_histories.json +++ b/src/apps/activities/fixtures/activity_histories.json @@ -25,7 +25,7 @@ "reports": [ { "type": "score", - "name":"Score 1", + "name": "Score 1", "id": "score_1", "calculation_type": "sum" } @@ -173,10 +173,10 @@ "updated_at": "2023-01-05T15:49:51.752113", "is_deleted": false, "applet_id": "92917a56-d586-4613-b7aa-991f2c4b15b1_1.9.9", - "name": "PHQ2 new", + "name": "Flanker", "description": { - "en": "PHQ2", - "fr": "PHQ2" + "en": "It is flanker because it has items with flanker response type", + "fr": "It is flanker because it has items with flanker response type" }, "splash_screen": "", "image": "", @@ -196,7 +196,8 @@ "calculation_type": "sum" } ] - } + }, + "performance_task_type": "flanker" } } -] \ No newline at end of file +] diff --git a/src/apps/activities/fixtures/activity_item_histories.json b/src/apps/activities/fixtures/activity_item_histories.json index c5ef44eaaea..06b7eea978f 100644 --- a/src/apps/activities/fixtures/activity_item_histories.json +++ b/src/apps/activities/fixtures/activity_item_histories.json @@ -22,7 +22,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d3", @@ -31,7 +32,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d4", @@ -40,7 +42,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d5", @@ -49,7 +52,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -94,7 +98,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66e3", @@ -103,7 +108,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66e4", @@ -112,7 +118,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66e5", @@ -121,7 +128,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -195,7 +203,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d3", @@ -204,7 +213,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d4", @@ -213,7 +223,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d5", @@ -222,7 +233,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, diff --git a/src/apps/activities/fixtures/activity_items.json b/src/apps/activities/fixtures/activity_items.json index 4bac88a162f..6de8489f6f1 100644 --- a/src/apps/activities/fixtures/activity_items.json +++ b/src/apps/activities/fixtures/activity_items.json @@ -22,7 +22,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d3", @@ -31,7 +32,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d4", @@ -40,7 +42,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d5", @@ -49,7 +52,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -93,7 +97,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66e3", @@ -102,7 +107,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66e4", @@ -111,7 +117,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66e5", @@ -120,7 +127,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -164,7 +172,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66f3", @@ -173,7 +182,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66f4", @@ -182,7 +192,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66f5", @@ -191,7 +202,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -235,7 +247,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66g3", @@ -244,7 +257,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66g4", @@ -253,7 +267,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66g5", @@ -262,7 +277,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -306,7 +322,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66h3", @@ -315,7 +332,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66h4", @@ -324,7 +342,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66h5", @@ -333,7 +352,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -377,7 +397,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66i3", @@ -386,7 +407,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66i4", @@ -395,7 +417,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66i5", @@ -404,7 +427,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -448,7 +472,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66j3", @@ -457,7 +482,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66j4", @@ -466,7 +492,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66j5", @@ -475,7 +502,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -519,7 +547,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66k3", @@ -528,7 +557,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66k4", @@ -537,7 +567,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66k5", @@ -546,7 +577,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -590,7 +622,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66l3", @@ -599,7 +632,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66l4", @@ -608,7 +642,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66l5", @@ -617,7 +652,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -661,7 +697,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66m3", @@ -670,7 +707,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66m4", @@ -679,7 +717,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66m5", @@ -688,7 +727,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -732,7 +772,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66n3", @@ -741,7 +782,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66n4", @@ -750,7 +792,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66n5", @@ -759,7 +802,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -803,7 +847,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66o3", @@ -812,7 +857,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66o4", @@ -821,7 +867,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66o5", @@ -830,7 +877,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -874,7 +922,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66p3", @@ -883,7 +932,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66p4", @@ -892,7 +942,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66p5", @@ -901,7 +952,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -945,7 +997,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66q3", @@ -954,7 +1007,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66q4", @@ -963,7 +1017,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66q5", @@ -972,7 +1027,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1016,7 +1072,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66r3", @@ -1025,7 +1082,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66r4", @@ -1034,7 +1092,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66r5", @@ -1043,7 +1102,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1087,7 +1147,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66s3", @@ -1096,7 +1157,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66s4", @@ -1105,7 +1167,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66s5", @@ -1114,7 +1177,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1158,7 +1222,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66t3", @@ -1167,7 +1232,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66t4", @@ -1176,7 +1242,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66t5", @@ -1185,7 +1252,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1229,7 +1297,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66u3", @@ -1238,7 +1307,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66u4", @@ -1247,7 +1317,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66u5", @@ -1256,7 +1327,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1300,7 +1372,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66v3", @@ -1309,7 +1382,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66v4", @@ -1318,7 +1392,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66v5", @@ -1327,7 +1402,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1371,7 +1447,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66w3", @@ -1380,7 +1457,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66w4", @@ -1389,7 +1467,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66w5", @@ -1398,7 +1477,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1442,7 +1522,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66x3", @@ -1451,7 +1532,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66x4", @@ -1460,7 +1542,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66x5", @@ -1469,7 +1552,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1513,7 +1597,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66y3", @@ -1522,7 +1607,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66y4", @@ -1531,7 +1617,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66y5", @@ -1540,7 +1627,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1584,7 +1672,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66z3", @@ -1593,7 +1682,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66z4", @@ -1602,7 +1692,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66z5", @@ -1611,7 +1702,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1655,7 +1747,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db67d3", @@ -1664,7 +1757,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db67d4", @@ -1673,7 +1767,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db67d5", @@ -1682,7 +1777,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1726,7 +1822,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db68d3", @@ -1735,7 +1832,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db68d4", @@ -1744,7 +1842,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db68d5", @@ -1753,7 +1852,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1797,7 +1897,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db69d3", @@ -1806,7 +1907,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db69d4", @@ -1815,7 +1917,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db69d5", @@ -1824,7 +1927,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1868,7 +1972,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db61d3", @@ -1877,7 +1982,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db61d4", @@ -1886,7 +1992,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db61d5", @@ -1895,7 +2002,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1939,7 +2047,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db62d3", @@ -1948,7 +2057,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db62d4", @@ -1957,7 +2067,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db62d5", @@ -1966,7 +2077,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -1987,4 +2099,4 @@ "order": 9.0 } } -] \ No newline at end of file +] diff --git a/src/apps/activities/router.py b/src/apps/activities/router.py index bcd1c0e4c72..36e74950404 100644 --- a/src/apps/activities/router.py +++ b/src/apps/activities/router.py @@ -3,6 +3,7 @@ from apps.activities.api.activities import ( activity_retrieve, + applet_activities, public_activity_retrieve, ) from apps.activities.api.reusable_item_choices import ( @@ -16,6 +17,7 @@ from apps.activities.domain.reusable_item_choices import ( PublicReusableItemChoice, ) +from apps.applets.domain.applet import AppletActivitiesDetailsPublic from apps.shared.domain import Response, ResponseMulti from apps.shared.domain.response import ( AUTHENTICATION_ERROR_RESPONSES, @@ -79,3 +81,13 @@ **DEFAULT_OPENAPI_RESPONSE, }, )(public_activity_retrieve) + +router.get( + "/applet/{applet_id}", + status_code=status.HTTP_200_OK, + responses={ + status.HTTP_200_OK: {"model": Response[AppletActivitiesDetailsPublic]}, + **AUTHENTICATION_ERROR_RESPONSES, + **DEFAULT_OPENAPI_RESPONSE, + }, +)(applet_activities) diff --git a/src/apps/activities/services/activity.py b/src/apps/activities/services/activity.py index e082c516634..779ef7209ee 100644 --- a/src/apps/activities/services/activity.py +++ b/src/apps/activities/services/activity.py @@ -1,10 +1,12 @@ import uuid -from apps.activities.crud import ActivitiesCRUD +from apps.activities.crud import ActivitiesCRUD, ActivityHistoriesCRUD from apps.activities.db.schemas import ActivitySchema from apps.activities.domain.activity import ( ActivityDuplicate, + ActivityLanguageWithItemsMobileDetailPublic, ActivitySingleLanguageDetail, + ActivitySingleLanguageMobileDetailPublic, ActivitySingleLanguageWithItemsDetail, ) from apps.activities.domain.activity_create import ( @@ -22,9 +24,10 @@ ActivityDoeNotExist, ) from apps.activities.services.activity_item import ActivityItemService -from apps.applets.crud import AppletsCRUD -from apps.schedule.crud.events import ActivityEventsCRUD +from apps.applets.crud import AppletsCRUD, UserAppletAccessCRUD +from apps.schedule.crud.events import ActivityEventsCRUD, EventCRUD from apps.schedule.service.schedule import ScheduleService +from apps.workspaces.domain.constants import Role class ActivityService: @@ -67,6 +70,7 @@ async def create( order=index + 1, report_included_item_name=activity_data.report_included_item_name, # noqa: E501 extra_fields=activity_data.extra_fields, + performance_task_type=activity_data.performance_task_type, ) ) @@ -113,7 +117,11 @@ async def create( # add default schedule for activities await ScheduleService(self.session).create_default_schedules( applet_id=applet_id, - activity_ids=[activity.id for activity in activities], + activity_ids=[ + activity.id + for activity in activities + if not activity.is_reviewable + ], is_activity=True, ) @@ -172,6 +180,7 @@ async def update_create( report_included_item_name=( activity_data.report_included_item_name ), + performance_task_type=activity_data.performance_task_type, ) ) @@ -225,12 +234,41 @@ async def update_create( # Create default events for new activities if new_activities: - await ScheduleService(self.session).create_default_schedules( + respondents_in_applet = await UserAppletAccessCRUD( + self.session + ).get_user_id_applet_and_role( applet_id=applet_id, - activity_ids=list(new_activities), - is_activity=True, + role=Role.RESPONDENT, ) + respondents_with_indvdl_schdl: list[uuid.UUID] = [] + for respondent in respondents_in_applet: + respondent_uuid = uuid.UUID(f"{respondent}") + number_of_indvdl_events = await EventCRUD( + self.session + ).count_individual_events_by_user( + applet_id=applet_id, user_id=respondent_uuid + ) + if number_of_indvdl_events > 0: + respondents_with_indvdl_schdl.append(respondent_uuid) + + if respondents_with_indvdl_schdl: + for respondent_uuid in respondents_with_indvdl_schdl: + await ScheduleService( + self.session + ).create_default_schedules( + applet_id=applet_id, + activity_ids=list(new_activities), + is_activity=True, + respondent_id=respondent_uuid, + ) + else: + await ScheduleService(self.session).create_default_schedules( + applet_id=applet_id, + activity_ids=list(new_activities), + is_activity=True, + ) + return activities async def remove_applet_activities(self, applet_id: uuid.UUID): @@ -266,10 +304,80 @@ async def get_single_language_by_applet_id( subscale_setting=schema.subscale_setting, created_at=schema.created_at, report_included_item_name=schema.report_included_item_name, + performance_task_type=schema.performance_task_type, + is_performance_task=schema.is_performance_task, + ) + ) + return activities + + async def get_single_language_by_applet_id_mobile( + self, applet_id: uuid.UUID, language: str + ) -> list[ActivitySingleLanguageMobileDetailPublic]: + schemas = await ActivitiesCRUD(self.session).get_by_applet_id( + applet_id, is_reviewable=False + ) + activities = [] + for schema in schemas: + activities.append( + ActivitySingleLanguageMobileDetailPublic( + id=schema.id, + name=schema.name, + description=self._get_by_language( + schema.description, language + ), + image=schema.image, + is_reviewable=schema.is_reviewable, + is_skippable=schema.is_skippable, + show_all_at_once=schema.show_all_at_once, + is_hidden=schema.is_hidden, + response_is_editable=schema.response_is_editable, + order=schema.order, + splash_screen=schema.splash_screen, ) ) return activities + async def get_single_language_with_items_by_applet_id( + self, applet_id: uuid.UUID, language: str + ) -> list[ActivityLanguageWithItemsMobileDetailPublic]: + schemas = await ActivitiesCRUD( + self.session + ).get_mobile_with_items_by_applet_id(applet_id, is_reviewable=False) + + activities = [] + activity_ids = [] + for schema in schemas: + activity = ActivityLanguageWithItemsMobileDetailPublic( + id=schema.id, + name=schema.name, + description=self._get_by_language( + schema.description, language + ), + splash_screen=schema.splash_screen, + image=schema.image, + show_all_at_once=schema.show_all_at_once, + is_skippable=schema.is_skippable, + is_reviewable=schema.is_reviewable, + is_hidden=schema.is_hidden, + response_is_editable=schema.response_is_editable, + order=schema.order, + scores_and_reports=schema.scores_and_reports, + ) + + activities.append(activity) + activity_ids.append(activity.id) + + activity_items_map = await ActivityItemService( + self.session + ).get_single_language_by_activity_ids( + activity_ids=activity_ids, language=language + ) + + for activity in activities: + activity.items = activity_items_map.get(activity.id, []) + + return activities + async def get_full_activities( self, applet_id: uuid.UUID ) -> list[ActivityFull]: @@ -317,6 +425,8 @@ async def get_by_applet_id_for_duplicate( is_hidden=schema.is_hidden, scores_and_reports=schema.scores_and_reports, subscale_setting=schema.subscale_setting, + performance_task_type=schema.performance_task_type, + is_performance_task=schema.is_performance_task, ) activity_map[activity.id] = activity activities.append(activity) @@ -403,6 +513,11 @@ def _get_by_language(values: dict, language: str): async def update_report( self, activity_id: uuid.UUID, schema: ActivityReportConfiguration ): - await ActivitiesCRUD(self.session).update_by_id( - activity_id, **schema.dict(by_alias=False, exclude_unset=True) - ) + crud_list: list[type[ActivitiesCRUD] | type[ActivityHistoriesCRUD]] = [ + ActivitiesCRUD, + ActivityHistoriesCRUD, + ] + for crud in crud_list: + await crud(self.session).update_by_id( + activity_id, **schema.dict(by_alias=False, exclude_unset=True) + ) diff --git a/src/apps/activities/services/activity_change.py b/src/apps/activities/services/activity_change.py new file mode 100644 index 00000000000..88f824d1e70 --- /dev/null +++ b/src/apps/activities/services/activity_change.py @@ -0,0 +1,336 @@ +from apps.activities.domain.activity_history import ( + ActivityHistoryChange, + ActivityHistoryFull, +) +from apps.activities.domain.scores_reports import ( + Score, + ScoresAndReports, + Section, + SubscaleCalculationType, + SubscaleSetting, +) +from apps.activities.services.activity_item_change import ( + ActivityItemChangeService, + ChangeStatusEnum, + group, +) +from apps.shared.changes_generator import EMPTY_VALUES, BaseChangeGenerator + + +class ScoresAndReportsChangeService(BaseChangeGenerator): + field_name_verbose_name_map = { + "generate_report": "Scores & Reports: Generate Report", + "show_score_summary": "Scores & Reports: Show Score Summary", + # TODO: Add separate ChangeService for reports attrs + "score": "Scores & Reports: Score", + "section": "Scores & Reports: Section", + } + + def check_changes( + self, + value: ScoresAndReports | None, + changes: list[str], + ) -> None: + # Possible this case is unavailable, but model parent model declared + # this field as optional + if not value: + return + for key, val in value: + if isinstance(val, bool): + verbose_name = self.field_name_verbose_name_map[key] + self._populate_bool_changes(verbose_name, val, changes) + else: + for rep in val: + verbose_name = self.field_name_verbose_name_map[rep.type] + vn = f"{verbose_name} {rep.name}" + changes.append(self._change_text_generator.added_text(vn)) + + def check_update_changes( + self, + parent_field_name: str, + old_value: ScoresAndReports | None, + value: ScoresAndReports | None, + changes: list[str], + ) -> None: + if value and not old_value: + self.check_changes(value, changes) + # Possible this case is not allowed from UI, but need to be ready + elif not value and old_value: + changes.append( + self._change_text_generator.removed_text(parent_field_name) + ) + elif value and value != old_value: + for key, val in value: + old_val = getattr(old_value, key) + if isinstance(val, bool): + vn = self.field_name_verbose_name_map[key] + self._populate_bool_changes(vn, val, changes) + elif key == "reports": + self.__check_for_changes(val, old_val, "score", changes) + self.__check_for_changes(val, old_val, "section", changes) + + def __check_for_changes( + self, + value: list[Score | Section], + old_value: list[Score | Section], + type_: str, + changes: list[str], + ) -> None: + # Assumption: names are unique + old = {v.name: v for v in old_value if v.type == type_} + new = {v.name: v for v in value if v.type == type_} + deleted = list(set(old) - set(new)) + inseted = list(set(new) - set(old)) + vn = self.field_name_verbose_name_map[type_] + for k, v in new.items(): + old_v = old.get(k) + if old_v and old_v != v: + changes.append( + self._change_text_generator.updated_text(f"{vn} {v.name}") + ) + for name in deleted: + changes.append( + self._change_text_generator.removed_text(f"{vn} {name}") + ) + for name in inseted: + changes.append( + self._change_text_generator.added_text(f"{vn} {name}") + ) + + +class SubscaleSettingChangeService(BaseChangeGenerator): + field_name_verbose_name_map = { + "calculate_total_score": "Subscale Configuration: Calculate total score", # noqa E501 + "subscales": "Subscale Configuration: Subscale", + "total_scores_table_data": "Subscale Configuration: Total Scores Table Data", # noqa E501 + } + verbose_total_score_map = { + "sum": "Sum of Item Scores", + "average": "Average of Item Scores", + } + + def check_changes( + self, + value: SubscaleSetting | None, + changes: list[str], + ) -> None: + if not value: + return + for key, val in value: + vn = self.field_name_verbose_name_map[key] + if isinstance(val, list): + for v in val: + changes.append( + self._change_text_generator.added_text( + f"{vn} {v.name}" + ) + ) + elif isinstance(val, SubscaleCalculationType): + changes.append( + self._change_text_generator.set_text( + vn, self.verbose_total_score_map[val] + ) + ) + else: + changes.append(self._change_text_generator.added_text(vn)) + + def check_update_changes( + self, + parent_field_name: str, + old_value: SubscaleSetting | None, + value: SubscaleSetting | None, + changes: list[str], + ) -> None: + if value and not old_value: + self.check_changes(value, changes) + elif not value and old_value: + changes.append( + self._change_text_generator.removed_text(parent_field_name) + ) + elif value and value != old_value: + for key, val in value: + old_val = getattr(old_value, key) + vn = self.field_name_verbose_name_map[key] + if key == "subscales": + # Assumption: names are unique + old_scales_map = {v.name: v for v in old_val} + new_scales_map = {v.name: v for v in val} + inserted_scales = list( + set(new_scales_map) - set(old_scales_map) + ) + deleted_scales = list( + set(old_scales_map) - set(new_scales_map) + ) + for k, v in new_scales_map.items(): + old_v = old_scales_map.get(k) + if old_v and old_v != v: + changes.append( + self._change_text_generator.updated_text( + f"{vn} {k}" + ) + ) + for name in deleted_scales: + changes.append( + self._change_text_generator.removed_text( + f"{vn} {name}" + ) + ) + for name in inserted_scales: + changes.append( + self._change_text_generator.added_text( + f"{vn} {name}" + ) + ) + + elif key == "calculate_total_score": + verbose_value_map = { + "sum": "Sum of Item Scores", + "average": "Average of Item Scores", + } + if val and val != old_val: + changes.append( + self._change_text_generator.set_text( + vn, verbose_value_map[val] + ) + ) + elif val: + changes.append( + self._change_text_generator.set_text( + vn, verbose_value_map[val] + ) + ) + elif old_val: + changes.append( + self._change_text_generator.removed_text(vn) + ) + + +class ActivityChangeService(BaseChangeGenerator): + field_name_verbose_name_map = { + "name": "Activity Name", + "description": "Activity Description", + "splash_screen": "Splash Screen", + "image": "Activity Image", + "order": "Activity Order", + "show_all_at_once": "Show all questions at once", + "is_skippable": "Allow to skip all items", + "is_reviewable": "Turn the Activity to the Reviewer dashboard assessment", # noqa E501 + "response_is_editable": "Disable the respondent's ability to change the response", # noqa E501 + "report_included_item_name": "Report's name included item name", + "order": "Activity Order", + # NOTE: is_hidden should be inverted + "is_hidden": "Activity Visibility", + "scores_and_reports": "Scores & Reports option", + "subscale_setting": "Subscale Setting option", + } + + def __init__(self, old_version: str, new_version: str) -> None: + self._sar_service = ScoresAndReportsChangeService() + self._scale_service = SubscaleSettingChangeService() + self._old_version = old_version + self._new_version = new_version + super().__init__() + + def init_change(self, name: str, state: str) -> ActivityHistoryChange: + match state: + case ChangeStatusEnum.ADDED: + method = self._change_text_generator.added_text + case ChangeStatusEnum.UPDATED: + method = self._change_text_generator.updated_text + case ChangeStatusEnum.REMOVED: + method = self._change_text_generator.removed_text + case _: + raise ValueError("Not Suppported State") + return ActivityHistoryChange(name=method((f"Activity {name}"))) + + def get_changes( + self, activities: list[ActivityHistoryFull] + ) -> list[ActivityHistoryChange]: + grouped = group(activities, self._new_version) + item_service = ActivityItemChangeService( + self._old_version, self._new_version + ) + result: list[ActivityHistoryChange] = [] + for _, (old_activity, new_activity) in grouped.items(): + if not old_activity and new_activity: + change = self.init_change( + new_activity.name, ChangeStatusEnum.ADDED + ) + change.changes = self.get_changes_insert(new_activity) + change.items = item_service.get_changes(new_activity.items) + result.append(change) + elif not new_activity and old_activity: + change = self.init_change( + old_activity.name, ChangeStatusEnum.REMOVED + ) + result.append(change) + elif new_activity and old_activity: + changes = self.get_changes_update(old_activity, new_activity) + changes_items = item_service.get_changes( + old_activity.items + new_activity.items + ) + + if changes or changes_items: + change = self.init_change( + new_activity.name, + ChangeStatusEnum.UPDATED, + ) + change.changes = changes + change.items = changes_items + result.append(change) + return result + + def get_changes_insert( + self, new_activity: ActivityHistoryFull + ) -> list[str]: + changes: list[str] = list() + for ( + field_name, + verbose_name, + ) in self.field_name_verbose_name_map.items(): + value = getattr(new_activity, field_name) + if isinstance(value, ScoresAndReports): + self._sar_service.check_changes(value, changes) + elif isinstance(value, SubscaleSetting): + self._scale_service.check_changes(value, changes) + elif isinstance(value, bool): + self._populate_bool_changes(verbose_name, value, changes) + elif value not in EMPTY_VALUES: + changes.append( + self._change_text_generator.changed_text( + verbose_name, value, is_initial=True + ) + ) + return changes + + def get_changes_update( + self, + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + ) -> list[str]: + changes: list[str] = list() + for ( + field_name, + verbose_name, + ) in self.field_name_verbose_name_map.items(): + value = getattr(new_activity, field_name) + old_value = getattr(old_activity, field_name) + if field_name == "scores_and_reports": + self._sar_service.check_update_changes( + verbose_name, old_value, value, changes + ) + elif field_name == "subscale_setting": + self._scale_service.check_update_changes( + verbose_name, old_value, value, changes + ) + elif isinstance(value, bool): + if value != old_value: + self._populate_bool_changes(verbose_name, value, changes) + elif value != old_value: + is_initial = old_value in EMPTY_VALUES + changes.append( + self._change_text_generator.changed_text( + verbose_name, value, is_initial=is_initial + ) + ) + return changes diff --git a/src/apps/activities/services/activity_history.py b/src/apps/activities/services/activity_history.py index c3d6110fb41..557d45e9d4b 100644 --- a/src/apps/activities/services/activity_history.py +++ b/src/apps/activities/services/activity_history.py @@ -1,5 +1,4 @@ import uuid -from typing import Optional from apps.activities.crud import ActivityHistoriesCRUD from apps.activities.db.schemas import ActivityHistorySchema @@ -9,11 +8,10 @@ ActivityHistoryFull, ) from apps.activities.domain.activity_full import ActivityFull -from apps.activities.domain.activity_item_history import ActivityItemHistory +from apps.activities.services.activity_change import ActivityChangeService from apps.activities.services.activity_item_history import ( ActivityItemHistoryService, ) -from apps.shared.changes_generator import ChangeGenerator, ChangeTextGenerator __all__ = ["ActivityHistoryService"] @@ -53,7 +51,7 @@ async def add(self, activities: list[ActivityFull]): if activity.subscale_setting else None, report_included_item_name=activity.report_included_item_name, # noqa: E501 - extra_fields=activity.extra_fields, + performance_task_type=activity.performance_task_type, ) ) @@ -62,17 +60,11 @@ async def add(self, activities: list[ActivityFull]): self.session, self._applet_id, self._version ).add(activity_items) - async def get_changes(self, prev_version: str): - old_id_version = f"{self._applet_id}_{prev_version}" - return await self._get_activity_changes(old_id_version) - - async def _get_activity_changes( - self, old_applet_id_version: str + async def get_changes( + self, prev_version: str ) -> list[ActivityHistoryChange]: - changes_generator = ChangeTextGenerator() - change_activity_generator = ChangeGenerator() + old_applet_id_version = f"{self._applet_id}_{prev_version}" - activity_changes: list[ActivityHistoryChange] = [] activity_schemas = await ActivityHistoriesCRUD( self.session ).retrieve_by_applet_ids( @@ -85,85 +77,8 @@ async def _get_activity_changes( activity.items = await ActivityItemHistoryService( self.session, self._applet_id, self._version ).get_by_activity_id_versions([activity.id_version]) - - activity_groups = self._group_and_sort_activities_or_items(activities) - for _, (prev_activity, new_activity) in activity_groups.items(): - if not prev_activity and new_activity: - activity_changes.append( - ActivityHistoryChange( - name=changes_generator.added_text( - f"activity by name {new_activity.name}" - ), - changes=change_activity_generator.generate_activity_insert( # noqa: E501 - new_activity - ), - items=change_activity_generator.generate_activity_items_insert( # noqa: E501 - getattr(new_activity, "items", []) - ), - ) - ) - elif not new_activity and prev_activity: - activity_changes.append( - ActivityHistoryChange( - name=changes_generator.removed_text( - f"activity by name {prev_activity.name}" - ) - ) - ) - elif new_activity and prev_activity: - has_changes = False - ( - changes, - has_changes, - ) = change_activity_generator.generate_activity_update( - new_activity, prev_activity - ) - changes_items = [] - ( - changes_items, - has_changes, - ) = change_activity_generator.generate_activity_items_update( - self._group_and_sort_activities_or_items( - getattr(new_activity, "items", []) - + getattr(prev_activity, "items", []) - ), - ) - - if has_changes: - activity_changes.append( - ActivityHistoryChange( - name=changes_generator.updated_text( - f"Activity {new_activity.name}" - ), - changes=changes, - items=changes_items, - ) - ) - return activity_changes - - def _group_and_sort_activities_or_items( - self, items: list[ActivityHistoryFull] | list[ActivityItemHistory] - ) -> dict[ - uuid.UUID, - tuple[Optional[ActivityHistoryFull], Optional[ActivityHistoryFull]] - | tuple[Optional[ActivityItemHistory], Optional[ActivityItemHistory]], - ]: - groups_map: dict = dict() - for item in items: - group = groups_map.get(item.id) - if not group: - if self._version in item.id_version.split("_"): - group = (None, item) - else: - group = (item, None) - elif group: - if self._version in item.id_version.split("_"): - group = (group[0], item) - else: - group = (item, group[1]) - groups_map[item.id] = group - - return groups_map + service = ActivityChangeService(prev_version, self._version) + return service.get_changes(activities) async def get_by_history_ids( self, activity_ids: list[str] @@ -185,10 +100,12 @@ async def get_by_id(self, activity_id: uuid.UUID) -> ActivityHistory: ) return ActivityHistory.from_orm(schema) - async def get_full(self) -> list[ActivityHistoryFull]: + async def get_full( + self, non_performance=False + ) -> list[ActivityHistoryFull]: schemas = await ActivityHistoriesCRUD( self.session - ).get_by_applet_id_version(self._applet_id_version) + ).get_by_applet_id_version(self._applet_id_version, non_performance) activities = [] activity_ids = [] activity_map = dict() diff --git a/src/apps/activities/services/activity_item.py b/src/apps/activities/services/activity_item.py index 5d88f919ff1..978c2a453fe 100644 --- a/src/apps/activities/services/activity_item.py +++ b/src/apps/activities/services/activity_item.py @@ -8,6 +8,7 @@ from apps.activities.domain.activity_item import ( ActivityItemDuplicate, ActivityItemSingleLanguageDetail, + ActivityItemSingleLanguageDetailPublic, ) from apps.activities.domain.activity_update import PreparedActivityItemUpdate @@ -82,6 +83,36 @@ async def get_single_language_by_activity_id( ) return items + async def get_single_language_by_activity_ids( + self, activity_ids: list[uuid.UUID], language: str + ) -> dict[uuid.UUID, list[ActivityItemSingleLanguageDetailPublic]]: + """Return all Items in map by event activity_ids.""" + schemas = await ActivityItemsCRUD(self.session).get_by_activity_ids( + activity_ids + ) + items_map: dict[ + uuid.UUID, list[ActivityItemSingleLanguageDetailPublic] + ] = dict() + for schema in schemas: + items_map.setdefault(schema.activity_id, list()) + items_map[schema.activity_id].append( + ActivityItemSingleLanguageDetailPublic( + id=schema.id, + activity_id=schema.activity_id, + question=self._get_by_language(schema.question, language), + response_type=schema.response_type, + # TODO: get answers by language + config=schema.config, + response_values=schema.response_values, + order=schema.order, + name=schema.name, + conditional_logic=schema.conditional_logic, + allow_edit=schema.allow_edit, + is_hidden=schema.is_hidden, + ) + ) + return items_map + async def get_items_by_activity_ids( self, activity_ids: list[uuid.UUID] ) -> list[ActivityItemFull]: diff --git a/src/apps/activities/services/activity_item_change.py b/src/apps/activities/services/activity_item_change.py new file mode 100644 index 00000000000..53d0ed25b4b --- /dev/null +++ b/src/apps/activities/services/activity_item_change.py @@ -0,0 +1,444 @@ +import enum +import uuid +from typing import TypeVar + +from apps.activities.domain.activity_history import ( + ActivityHistoryFull, + ActivityItemHistoryFull, +) +from apps.activities.domain.activity_item_history import ( + ActivityItemHistoryChange, +) +from apps.activities.domain.conditional_logic import ConditionalLogic +from apps.activities.domain.conditions import ( + Condition, + MinMaxPayload, + OptionPayload, + ValuePayload, +) +from apps.activities.domain.response_type_config import ( + AdditionalResponseOption, + ResponseType, +) +from apps.shared.changes_generator import BaseChangeGenerator + +GT = TypeVar("GT", ActivityHistoryFull, ActivityItemHistoryFull) +RGT = TypeVar("RGT", None, ActivityHistoryFull, ActivityItemHistoryFull) + + +class ChangeStatusEnum(str, enum.Enum): + ADDED = "added" + UPDATED = "updated" + REMOVED = "removed" + + +def group( + items: list[GT], new_version: str +) -> dict[uuid.UUID, tuple[RGT, RGT]]: + groups_map: dict = dict() + for item in items: + group = groups_map.get(item.id) + if not group: + if new_version in item.id_version.split("_"): + group = (None, item) + else: + group = (item, None) + elif group: + if new_version in item.id_version.split("_"): + group = (group[0], item) + else: + group = (item, group[1]) + groups_map[item.id] = group + + return groups_map + + +class ConfigChangeService(BaseChangeGenerator): + field_name_verbose_name_map = { + "remove_back_button": "Remove Back Button", + "remove_undo_button": "Remove Undo Button", + "navigation_to_top": "Navigation To Top", + "skippable_item": "Skippable Item", + "play_once": "Play Once", + "randomize_options": "Randomize Options", + "show_tick_marks": "Show Tick Marks", + "show_tick_labels": "Show Tick Marks Labels", + "continuous_slider": "Use Continius Slider", + "max_response_length": "Max Response Length", + "correct_answer_required": "Correct answer required", + "numerical_response_required": "Numerical Response Required", + "response_data_identifier": "Response Data Identifier", + "response_required": "Response Required", + "correct_answer": "Correct Answer", + "is_identifier": "Is Identifier", + "timer": "Timer", + "add_scores": "Add Scores", + "set_alerts": "Set Alerts", + "add_tooltip": "Add Tooltips", + "set_palette": "Set Color Palette", + "add_tokens": "Tokens", + # Additional options + "text_input_option": "Add Text Input Option", + "text_input_required": "Input Required", + } + + def check_changes(self, value, changes: list[str]) -> None: + if not value: + return + for key, val in value: + if isinstance(val, bool): + verbose_name = self.field_name_verbose_name_map[key] + self._populate_bool_changes(verbose_name, val, changes) + + elif isinstance(val, AdditionalResponseOption): + for k, v in val: + verbose_name = self.field_name_verbose_name_map[k] + self._populate_bool_changes(verbose_name, v, changes) + elif val: + verbose_name = self.field_name_verbose_name_map[key] + changes.append( + self._change_text_generator.set_text(verbose_name, val) + ) + + def check_update_changes( + self, old_value, new_value, changes: list[str] + ) -> None: + if new_value == old_value: + return + for key, val in new_value: + old_val = getattr(old_value, key) + if val != old_val: + if isinstance(val, bool): + vn = self.field_name_verbose_name_map[key] + self._populate_bool_changes(vn, val, changes) + elif isinstance(val, AdditionalResponseOption): + for k, v in val: + old_v = getattr(old_val, k) + if v != old_v: + vn = self.field_name_verbose_name_map[k] + if isinstance(v, bool): + self._populate_bool_changes(vn, v, changes) + elif val != old_val: + vn = self.field_name_verbose_name_map[key] + changes.append( + self._change_text_generator.set_text(vn, val) + ) + + +class ResponseOptionChangeService(BaseChangeGenerator): + def check_changes( + self, + type_, + value, + changes, + ) -> None: + if type_ in (ResponseType.SINGLESELECT, ResponseType.MULTISELECT): + self.__process_container_attr(value, "options", "text", changes) + elif type_ == ResponseType.SLIDERROWS.value: + self.__process_container_attr(value, "rows", "label", changes) + elif type_ in ( + ResponseType.SINGLESELECTROWS, + ResponseType.MULTISELECTROWS, + ): + self.__process_container_attr(value, "rows", "row_name", changes) + self.__process_container_attr(value, "options", "text", changes) + + def check_changes_update( + self, + type_, + old_value, + new_value, + changes, + ) -> None: + if type_ in (ResponseType.SINGLESELECT, ResponseType.MULTISELECT): + old_options = old_value.options + options = {o.id: o for o in new_value.options} + old_options = {o.id: o for o in old_value.options} + for k, v in old_options.items(): + new = options.get(k) + if not new: + changes.append( + self._change_text_generator.removed_text( + f"{v.text} | {v.value} option" + ) + ) + for k, v in options.items(): + old = old_options.get(k) + if not old: + changes.append( + self._change_text_generator.added_text( + f"{v.text} | {v.value} option" + ) + ) + elif old.text != v.text: + changes.append( + self._change_text_generator.changed_text( + f"{old.text} | {old.value} option text", + f"{v.text} | {v.value}", + ) + ) + elif type_ == ResponseType.SLIDERROWS.value: + new_rows = {row.id: row.label for row in new_value.rows} + old_rows = {row.id: row.label for row in old_value.rows} + for k, v in new_rows.items(): + old_label = old_rows.get(k) + if not old_label: + changes.append( + self._change_text_generator.added_text(f"Row {v}") + ) + elif old_label != v: + changes.append( + self._change_text_generator.changed_text( + f"Row label {old_label}", v + ) + ) + for k, v in old_rows.items(): + new_label = new_rows.get(k) + if not new_label: + changes.append( + self._change_text_generator.removed_text(f"Row {v}") + ) + elif type_ in ( + ResponseType.SINGLESELECTROWS, + ResponseType.MULTISELECTROWS, + ): + new_rows = {row.id: row.row_name for row in new_value.rows} + old_rows = {row.id: row.row_name for row in old_value.rows} + new_options = {o.id: o.text for o in new_value.options} + old_options = {o.id: o.text for o in old_value.options} + for k, v in new_rows.items(): + old_row_name = old_rows.get(k) + if not old_row_name: + changes.append( + self._change_text_generator.added_text(f"Row {v}") + ) + elif old_row_name != v: + changes.append( + self._change_text_generator.changed_text( + f"Row name {old_row_name}", v + ) + ) + for k, v in old_rows.items(): + new_row_name = new_rows.get(k) + if not new_row_name: + changes.append( + self._change_text_generator.removed_text(f"Row {v}") + ) + for k, v in new_options.items(): + old_text = old_options.get(k) + if not old_text: + changes.append( + self._change_text_generator.added_text(f"{v} option") + ) + elif old_text != v: + changes.append( + self._change_text_generator.changed_text( + f"Option text {old_text}", v + ) + ) + for k, v in old_options.items(): + new_text = new_options.get(k) + if not new_text: + changes.append( + self._change_text_generator.removed_text(f"{v} option") + ) + + def __process_container_attr( + self, + value, + container_attr_name: str, + op_attr_name: str, + changes: list[str], + ) -> None: + container = getattr(value, container_attr_name) + for i in container: + name = getattr(i, op_attr_name) + val = getattr(i, "value", None) + if container_attr_name == "rows": + text = f"Row {name}" + elif val is not None: + text = f"{name} | {val} option" + else: + text = f"{name}" + changes.append(self._change_text_generator.added_text(text)) + + +class ConditionalLogicChangeService(BaseChangeGenerator): + def check_changes( + self, + parent_field: str, + value: ConditionalLogic, + changes: list[str], + method_name="added_text", + ) -> None: + message = f"{parent_field}: If {value.match.capitalize()}: " + conds: list[str] = [] + for condition in value.conditions: + condition_type = condition.type.lower().replace("_", " ") + conds.append( + f"{condition.item_name} {condition_type} {self.__get_payload(condition)}" # noqa: E501 + ) + message = message + ", ".join(conds) + changes.append( + getattr(self._change_text_generator, method_name)(message) + ) + + def check_update_changes( + self, + parent_field: str, + old_value: ConditionalLogic | None, + new_value: ConditionalLogic | None, + changes: list[str], + ) -> None: + if new_value and not old_value: + self.check_changes(parent_field, new_value, changes) + elif not new_value and old_value: + changes.append( + self._change_text_generator.removed_text(parent_field) + ) + # Because we can not check conditional logic identity (there are no + # any ids or other unique fields) we just write that logic was update + # to the new value. + elif new_value != old_value: + self.check_changes( + parent_field, new_value, changes, method_name="updated_text" # type: ignore [arg-type] # noqa: E501 + ) + + @staticmethod + def __get_payload(condition: Condition) -> str: + if isinstance(condition.payload, OptionPayload): + return condition.payload.option_value + elif isinstance(condition.payload, ValuePayload): + return str(condition.payload.value) + elif isinstance(condition.payload, MinMaxPayload): + min_value = condition.payload.min_value + max_value = condition.payload.max_value + return f"{min_value} and {max_value}" + return "true" if condition.payload.value else "false" + + +class ActivityItemChangeService(BaseChangeGenerator): + field_name_verbose_name_map = { + "name": "Item Name", + "question": "Displayed Content", + "response_type": "Item Type", + "response_values": "Response Options", + "conditional_logic": "Item Flow", + "order": "Item Order", + "is_hidden": "Item Visibility", + "config": "Settings", + } + + def __init__(self, old_version: str, new_version: str) -> None: + self._conf_change_service = ConfigChangeService() + self._resp_vals_change_service = ResponseOptionChangeService() + self._cond_logic_change_service = ConditionalLogicChangeService() + self._old_version = old_version + self._new_version = new_version + super().__init__() + + def init_change(self, name: str, state: str) -> ActivityItemHistoryChange: + match state: + case ChangeStatusEnum.ADDED: + method = self._change_text_generator.added_text + case ChangeStatusEnum.UPDATED: + method = self._change_text_generator.updated_text + case ChangeStatusEnum.REMOVED: + method = self._change_text_generator.removed_text + case _: + raise ValueError("Not Suppported State") + return ActivityItemHistoryChange(name=method((f"Item {name}"))) + + def get_changes_insert(self, item: ActivityItemHistoryFull) -> list[str]: + changes: list[str] = [] + for ( + field_name, + verbose_name, + ) in self.field_name_verbose_name_map.items(): + value = getattr(item, field_name) + if isinstance(value, bool): + self._populate_bool_changes(verbose_name, value, changes) + elif field_name == "response_values": + self._resp_vals_change_service.check_changes( + item.response_type, value, changes + ) + elif field_name == "config": + self._conf_change_service.check_changes(value, changes) + elif field_name == "conditional_logic": + if value: + self._cond_logic_change_service.check_changes( + verbose_name, value, changes + ) + elif value: + changes.append( + self._change_text_generator.changed_text( + verbose_name, value, is_initial=True + ) + ) + + return changes + + def get_changes( + self, items: list[ActivityItemHistoryFull] + ) -> list[ActivityItemHistoryChange]: + grouped = group(items, self._new_version) + + result: list[ActivityItemHistoryChange] = [] + for _, (old_item, new_item) in grouped.items(): + if not old_item and new_item: + change = self.init_change( + new_item.name, ChangeStatusEnum.ADDED + ) + change.changes = self.get_changes_insert(new_item) + result.append(change) + elif not new_item and old_item: + change = self.init_change( + old_item.name, ChangeStatusEnum.REMOVED + ) + elif new_item and old_item: + changes = self.get_changes_update(old_item, new_item) + if changes: + change = self.init_change( + new_item.name, ChangeStatusEnum.UPDATED + ) + change.changes = changes + result.append(change) + + return result + + def get_changes_update( + self, + old_item: ActivityItemHistoryFull, + new_item: ActivityItemHistoryFull, + ) -> list[str]: + changes: list[str] = list() + + for ( + field_name, + verbose_name, + ) in self.field_name_verbose_name_map.items(): + value = getattr(new_item, field_name) + old_value = getattr(old_item, field_name) + if isinstance(value, bool): + if value != old_value: + self._populate_bool_changes(verbose_name, value, changes) + elif field_name == "response_values": + self._resp_vals_change_service.check_changes_update( + new_item.response_type, old_value, value, changes + ) + elif field_name == "config": + self._conf_change_service.check_update_changes( + old_value, value, changes + ) + elif field_name == "conditional_logic": + self._cond_logic_change_service.check_update_changes( + verbose_name, old_value, value, changes + ) + elif value and value != old_value: + changes.append( + self._change_text_generator.changed_text( + verbose_name, value + ) + ) + + return changes diff --git a/src/apps/activities/services/activity_item_history.py b/src/apps/activities/services/activity_item_history.py index 62b6dff7930..64f6585b00f 100644 --- a/src/apps/activities/services/activity_item_history.py +++ b/src/apps/activities/services/activity_item_history.py @@ -36,7 +36,6 @@ async def add(self, activity_items: list[ActivityItemFull]): if item.conditional_logic else None, allow_edit=item.allow_edit, - extra_fields=item.extra_fields, is_hidden=item.is_hidden, ) ) diff --git a/src/apps/activities/tests/test_activities.py b/src/apps/activities/tests/test_activities.py index 3a4d94847ba..17f259c4c90 100644 --- a/src/apps/activities/tests/test_activities.py +++ b/src/apps/activities/tests/test_activities.py @@ -5,6 +5,7 @@ class TestActivities(BaseTest): fixtures = [ "users/fixtures/users.json", + "themes/fixtures/themes.json", "folders/fixtures/folders.json", "applets/fixtures/applets.json", "applets/fixtures/applet_user_accesses.json", @@ -14,6 +15,7 @@ class TestActivities(BaseTest): login_url = "/auth/login" activity_detail = "/activities/{pk}" + activities_applet = "/activities/applet/{applet_id}" public_activity_detail = "public/activities/{pk}" @rollback @@ -42,6 +44,232 @@ async def test_activity_detail(self): == "Feeling down, depressed, or hopeless?" ) + @rollback + async def test_activities_applet(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + response = await self.client.get( + self.activities_applet.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ) + ) + + assert response.status_code == 200, response.json() + result = response.json()["result"] + + assert len(result["activitiesDetails"]) == 1 + assert ( + result["activitiesDetails"][0]["id"] + == "09e3dbf0-aefb-4d0e-9177-bdb321bf3611" + ) + assert result["activitiesDetails"][0]["name"] == "PHQ2" + assert result["activitiesDetails"][0]["description"] == "PHQ2 en" + assert result["activitiesDetails"][0]["splashScreen"] == "" + assert result["activitiesDetails"][0]["image"] == "" + assert result["activitiesDetails"][0]["showAllAtOnce"] is False + assert result["activitiesDetails"][0]["isSkippable"] is False + assert result["activitiesDetails"][0]["isReviewable"] is False + assert result["activitiesDetails"][0]["isHidden"] is False + assert result["activitiesDetails"][0]["responseIsEditable"] is False + assert result["activitiesDetails"][0]["order"] == 1 + + assert len(result["activitiesDetails"][0]["items"]) == 2 + items = sorted( + result["activitiesDetails"][0]["items"], key=lambda x: x["id"] + ) + assert items[0]["id"] == "a18d3409-2c96-4a5e-a1f3-1c1c14be0011" + assert ( + items[0]["question"] + == "Little interest or pleasure in doing things?" + ) + assert items[0]["responseType"] == "singleSelect" + assert items[0]["name"] == "test1" + assert items[0]["isHidden"] is None + assert items[0]["conditionalLogic"] is None + assert items[0]["allowEdit"] is None + assert items[0]["responseValues"]["paletteName"] is None + assert len(items[0]["responseValues"]["options"]) == 4 + options_0 = sorted( + items[0]["responseValues"]["options"], key=lambda x: x["id"] + ) + assert options_0[0]["id"] == "2ba4bb83-ed1c-4140-a225-c2c9b4db66d2" + assert options_0[0]["text"] == "Not at all" + assert options_0[0]["image"] is None + assert options_0[0]["score"] is None + assert options_0[0]["tooltip"] is None + assert options_0[0]["isHidden"] is False + assert options_0[0]["color"] is None + assert options_0[0]["alert"] is None + assert options_0[0]["value"] == 0 + assert options_0[1]["id"] == "2ba4bb83-ed1c-4140-a225-c2c9b4db66d3" + assert options_0[1]["text"] == "Several days" + assert options_0[1]["image"] is None + assert options_0[1]["score"] is None + assert options_0[1]["tooltip"] is None + assert options_0[1]["isHidden"] is False + assert options_0[1]["color"] is None + assert options_0[1]["alert"] is None + assert options_0[1]["value"] == 1 + assert options_0[2]["id"] == "2ba4bb83-ed1c-4140-a225-c2c9b4db66d4" + assert options_0[2]["text"] == "More than half the days" + assert options_0[2]["image"] is None + assert options_0[2]["score"] is None + assert options_0[2]["tooltip"] is None + assert options_0[2]["isHidden"] is False + assert options_0[2]["color"] is None + assert options_0[2]["alert"] is None + assert options_0[2]["value"] == 2 + assert options_0[3]["id"] == "2ba4bb83-ed1c-4140-a225-c2c9b4db66d5" + assert options_0[3]["text"] == "Nearly every day" + assert options_0[3]["image"] is None + assert options_0[3]["score"] is None + assert options_0[3]["tooltip"] is None + assert options_0[3]["isHidden"] is False + assert options_0[3]["color"] is None + assert options_0[3]["alert"] is None + assert options_0[3]["value"] == 3 + assert items[0]["config"]["removeBackButton"] is False + assert items[0]["config"]["skippableItem"] is False + assert items[0]["config"]["randomizeOptions"] is False + assert items[0]["config"]["timer"] == 0 + assert items[0]["config"]["addScores"] is False + assert items[0]["config"]["setAlerts"] is False + assert items[0]["config"]["addTooltip"] is False + assert items[0]["config"]["setPalette"] is False + assert items[0]["config"]["addTokens"] is None + assert ( + items[0]["config"]["additionalResponseOption"]["textInputOption"] + is False + ) + assert ( + items[0]["config"]["additionalResponseOption"]["textInputRequired"] + is False + ) + + assert items[1]["id"] == "a18d3409-2c96-4a5e-a1f3-1c1c14be0012" + assert items[1]["question"] == "Feeling down, depressed, or hopeless?" + assert items[1]["responseType"] == "singleSelect" + assert items[1]["name"] == "test" + assert items[1]["isHidden"] is None + assert items[1]["conditionalLogic"] is None + assert items[1]["allowEdit"] is None + assert items[1]["responseValues"]["paletteName"] is None + assert len(items[1]["responseValues"]["options"]) == 4 + options_1 = sorted( + items[1]["responseValues"]["options"], key=lambda x: x["id"] + ) + assert options_1[0]["id"] == "2ba4bb83-ed1c-4140-a225-c2c9b4db66e2" + assert options_1[0]["text"] == "Not at all" + assert options_1[0]["image"] is None + assert options_1[0]["score"] is None + assert options_1[0]["tooltip"] is None + assert options_1[0]["isHidden"] is False + assert options_1[0]["color"] is None + assert options_1[0]["alert"] is None + assert options_1[0]["value"] == 0 + assert options_1[1]["id"] == "2ba4bb83-ed1c-4140-a225-c2c9b4db66e3" + assert options_1[1]["text"] == "Several days" + assert options_1[1]["image"] is None + assert options_1[1]["score"] is None + assert options_1[1]["tooltip"] is None + assert options_1[1]["isHidden"] is False + assert options_1[1]["color"] is None + assert options_1[1]["alert"] is None + assert options_1[1]["value"] == 1 + assert options_1[2]["id"] == "2ba4bb83-ed1c-4140-a225-c2c9b4db66e4" + assert options_1[2]["text"] == "More than half the days" + assert options_1[2]["image"] is None + assert options_1[2]["score"] is None + assert options_1[2]["tooltip"] is None + assert options_1[2]["isHidden"] is False + assert options_1[2]["color"] is None + assert options_1[2]["alert"] is None + assert options_1[2]["value"] == 2 + assert options_1[3]["id"] == "2ba4bb83-ed1c-4140-a225-c2c9b4db66e5" + assert options_1[3]["text"] == "Nearly every day" + assert options_1[3]["image"] is None + assert options_1[3]["score"] is None + assert options_1[3]["tooltip"] is None + assert options_1[3]["isHidden"] is False + assert options_1[3]["color"] is None + assert options_1[3]["alert"] is None + assert options_1[3]["value"] == 3 + assert items[1]["config"]["removeBackButton"] is False + assert items[1]["config"]["skippableItem"] is False + assert items[1]["config"]["randomizeOptions"] is False + assert items[1]["config"]["timer"] == 0 + assert items[1]["config"]["addScores"] is False + assert items[1]["config"]["setAlerts"] is False + assert items[1]["config"]["addTooltip"] is False + assert items[1]["config"]["setPalette"] is False + assert items[1]["config"]["addTokens"] is None + assert ( + items[1]["config"]["additionalResponseOption"]["textInputOption"] + is False + ) + assert ( + items[1]["config"]["additionalResponseOption"]["textInputRequired"] + is False + ) + + assert result["activitiesDetails"][0]["scoresAndReports"] is None + + assert ( + result["appletDetail"]["id"] + == "92917a56-d586-4613-b7aa-991f2c4b15b1" + ) + assert result["appletDetail"]["displayName"] == "Applet 1" + assert result["appletDetail"]["version"] == "1.0.0" + assert ( + result["appletDetail"]["description"] + == "Patient Health Questionnaire" + ) + assert ( + result["appletDetail"]["about"] == "Patient Health Questionnaire" + ) + assert result["appletDetail"]["image"] == "" + assert result["appletDetail"]["watermark"] == "" + assert ( + result["appletDetail"]["theme"]["id"] + == "3e31a64e-449f-4788-8516-eca7809f1a41" + ) + assert result["appletDetail"]["theme"]["name"] == "Theme 1" + assert result["appletDetail"]["theme"]["logo"] == "logo1.jpg" + assert ( + result["appletDetail"]["theme"]["backgroundImage"] == "image1.jpg" + ) + assert result["appletDetail"]["theme"]["primaryColor"] == "#000" + assert result["appletDetail"]["theme"]["secondaryColor"] == "#f00" + assert result["appletDetail"]["theme"]["tertiaryColor"] == "#fff" + assert len(result["appletDetail"]["activities"]) == 1 + assert ( + result["appletDetail"]["activities"][0]["id"] + == "09e3dbf0-aefb-4d0e-9177-bdb321bf3611" + ) + assert result["appletDetail"]["activities"][0]["name"] == "PHQ2" + assert ( + result["appletDetail"]["activities"][0]["description"] == "PHQ2 en" + ) + assert result["appletDetail"]["activities"][0]["image"] == "" + assert result["appletDetail"]["activities"][0]["isReviewable"] is False + assert result["appletDetail"]["activities"][0]["isSkippable"] is False + assert ( + result["appletDetail"]["activities"][0]["showAllAtOnce"] is False + ) + assert result["appletDetail"]["activities"][0]["isHidden"] is False + assert ( + result["appletDetail"]["activities"][0]["responseIsEditable"] + is False + ) + assert result["appletDetail"]["activities"][0]["order"] == 1 + assert result["appletDetail"]["activities"][0]["splashScreen"] == "" + assert result["appletDetail"]["activityFlows"] == [] + + assert result["respondentMeta"] == { + "nickname": "Mindlogger ChildMindInstitute" + } + @rollback async def test_public_activity_detail(self): response = await self.client.get( diff --git a/src/apps/activities/tests/test_reusable_items.py b/src/apps/activities/tests/test_reusable_items.py index c6464dbf927..ee3e3842cdf 100644 --- a/src/apps/activities/tests/test_reusable_items.py +++ b/src/apps/activities/tests/test_reusable_items.py @@ -11,6 +11,7 @@ class TestReusableItem(BaseTest): create_url = "activities/item_choices" update_url = "activities/item_choices" delete_url = "activities/item_choices/{id}" + retrieve_url = "activities/item_choices" @rollback async def test_create_item_choice(self): @@ -111,3 +112,24 @@ async def test_create_item_choice_with_long_int_value(self): res_data = response.json() assert response.status_code == 422, res_data + + @rollback + async def test_retrieve_item_choice(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + create_data = dict( + token_name="Average age 3", + token_value="21", + input_type="radiobutton", + ) + + response = await self.client.post(self.create_url, data=create_data) + created_data = response.json()["result"] + assert response.status_code == 201, response.json() + assert response.json()["result"]["id"] + + response = await self.client.get(self.retrieve_url) + assert response.status_code == 200, response.json() + assert response.json()["count"] == 1 + assert response.json()["result"][0] == created_data diff --git a/src/apps/activities/tests/unit/test_activity_change.py b/src/apps/activities/tests/unit/test_activity_change.py new file mode 100644 index 00000000000..d7550d6915e --- /dev/null +++ b/src/apps/activities/tests/unit/test_activity_change.py @@ -0,0 +1,381 @@ +import datetime +import uuid + +import pytest +from pytest import FixtureRequest + +from apps.activities.domain.activity_history import ActivityHistoryFull +from apps.activities.domain.scores_reports import ( + CalculationType, + ReportType, + Score, + ScoresAndReports, + Section, + Subscale, + SubscaleCalculationType, + SubscaleItem, + SubscaleItemType, + SubscaleSetting, +) +from apps.activities.services.activity_change import ActivityChangeService +from apps.shared.enums import Language + + +@pytest.fixture +def activity_history_id() -> uuid.UUID: + return uuid.UUID("00000000-0000-0000-0000-000000000000") + + +@pytest.fixture +def old_version() -> str: + return "1.0.0" + + +@pytest.fixture +def new_version() -> str: + return "2.0.0" + + +@pytest.fixture +def old_applet_id(old_version: str) -> str: + return f"00000000-0000-0000-0000-000000000000_{old_version}" + + +@pytest.fixture +def new_applet_id(new_version: str) -> str: + return f"00000000-0000-0000-0000-000000000000_{new_version}" + + +@pytest.fixture +def activity_change_service(old_version, new_version) -> ActivityChangeService: + return ActivityChangeService(old_version, new_version) + + +@pytest.fixture +def old_id_version(activity_history_id: uuid.UUID, old_version: str) -> str: + return f"{activity_history_id}_{old_version}" + + +@pytest.fixture +def new_id_version(activity_history_id: uuid.UUID, new_version: str) -> str: + return f"{activity_history_id}_{new_version}" + + +@pytest.fixture +def score() -> Score: + return Score( + type=ReportType.score, + name="testscore", + id=str(uuid.uuid4()), + calculation_type=CalculationType.SUM, + ) + + +@pytest.fixture +def section() -> Section: + return Section(type=ReportType.section, name="testsection") + + +@pytest.fixture +def subscale() -> Subscale: + return Subscale( + name="test", + scoring=SubscaleCalculationType.AVERAGE, + items=[SubscaleItem(name="subscale_item", type=SubscaleItemType.ITEM)], + ) + + +@pytest.fixture +def subscale_setting(subscale: Subscale) -> SubscaleSetting: + return SubscaleSetting( + calculate_total_score=SubscaleCalculationType.AVERAGE, + subscales=[subscale], + ) + + +@pytest.fixture +def scores_and_reports(score: Score, section: Section) -> ScoresAndReports: + return ScoresAndReports( + generate_report=True, + show_score_summary=True, + reports=[score, section], + ) + + +@pytest.fixture +def old_activity( + activity_history_id: uuid.UUID, old_applet_id: str, old_id_version: str +) -> ActivityHistoryFull: + return ActivityHistoryFull( + id=activity_history_id, + applet_id=old_applet_id, + id_version=old_id_version, + name="testname", + description=dict(en=""), + splash_screen="", + image="", + show_all_at_once=False, + is_skippable=False, + is_reviewable=False, + response_is_editable=False, + order=1, + created_at=datetime.datetime.utcnow(), + is_hidden=False, + ) + + +@pytest.fixture +def new_activity( + activity_history_id: uuid.UUID, new_applet_id: str, new_version: str +) -> ActivityHistoryFull: + return ActivityHistoryFull( + id=activity_history_id, + applet_id=new_applet_id, + id_version=new_version, + name="testname", + description=dict(en=""), + splash_screen="", + image="", + show_all_at_once=False, + is_skippable=False, + is_reviewable=False, + response_is_editable=False, + order=1, + created_at=datetime.datetime.utcnow(), + is_hidden=False, + ) + + +def test_initial_activity_activity_changes( + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + initial_changes = [ + "Activity Name was set to testname", + "Activity Order was set to 1", + "Show all questions at once was disabled", + "Allow to skip all items was disabled", + "Turn the Activity to the Reviewer dashboard assessment was disabled", + "Disable the respondent's ability to change the response was disabled", + "Activity Visibility was enabled", + ] + changes = activity_change_service.get_changes_insert(new_activity) + assert len(changes) == len(initial_changes) + assert set(changes) == set(changes) + + +def test_initial_activity_hidden_activity_change( + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + new_activity.is_hidden = True + changes = activity_change_service.get_changes_insert(new_activity) + assert changes + assert "Activity Visibility was disabled" in changes + + +def test_initial_activity_bool_value_is_enabled( + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + new_activity.show_all_at_once = True + changes = activity_change_service.get_changes_insert(new_activity) + assert changes + assert "Show all questions at once was enabled" in changes + + +def test_initial_activity_description_was_set( + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + new_activity.description = {Language.ENGLISH: "HELLO"} + changes = activity_change_service.get_changes_insert(new_activity) + assert changes + assert "Activity Description was set to HELLO" in changes + + +@pytest.mark.parametrize( + "fixture_name, exp_changes", + ( + ( + "scores_and_reports", + [ + "Scores & Reports: Generate Report was enabled", + "Scores & Reports: Show Score Summary was enabled", + "Scores & Reports: Score testscore was added", + "Scores & Reports: Section testsection was added", + ], + ), + ( + "subscale_setting", + [ + "Subscale Configuration: Calculate total score was added", + "Subscale Configuration: Subscale test was added", + ], + ), + ), +) +def test_initial_activity_complex_fields_are_set( + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, + request: FixtureRequest, + fixture_name: str, + exp_changes: list[str], +): + fixture = request.getfixturevalue(fixture_name) + # We can use fixture name like attribute + setattr(new_activity, fixture_name, fixture) + changes = activity_change_service.get_changes_insert(new_activity) + for change in exp_changes: + assert change in changes + + +def test_new_activity_version_no_changes( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert not changes + + +def test_new_activity_version_is_hidden( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + new_activity.is_hidden = True + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert len(changes) == 1 + assert changes[0] == "Activity Visibility was disabled" + + +def test_new_activity_version_bool_field_enabled( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + new_activity.show_all_at_once = True + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert len(changes) == 1 + assert changes[0] == "Show all questions at once was enabled" + + +def test_new_activity_order_was_changed( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + new_activity.order = 2 + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert len(changes) == 1 + assert changes[0] == "Activity Order was changed to 2" + + +def test_new_activity_description_was_set( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + new_activity.description = {Language.ENGLISH: "BOOM"} + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert len(changes) == 1 + assert changes[0] == "Activity Description was set to BOOM" + + +def test_new_activity_description_was_changed( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, +): + old_activity.description = {Language.ENGLISH: "old"} + new_activity.description = {Language.ENGLISH: "new"} + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert len(changes) == 1 + assert changes[0] == "Activity Description was changed to new" + + +def test_new_activity_scores_and_reports_is_none( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, + scores_and_reports: ScoresAndReports, +): + old_activity.scores_and_reports = scores_and_reports + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert len(changes) == 1 + assert changes[0] == "Scores & Reports option was removed" + + +def test_new_activity_scores_and_reports_was_removed( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, + scores_and_reports: ScoresAndReports, +): + old_activity.scores_and_reports = scores_and_reports + # This is most realistic way + new_activity.scores_and_reports = ScoresAndReports( + generate_report=False, show_score_summary=False, reports=[] + ) + exp_changes = [ + "Scores & Reports: Generate Report was disabled", + "Scores & Reports: Show Score Summary was disabled", + "Scores & Reports: Score testscore was removed", + "Scores & Reports: Section testsection was removed", + ] + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert len(changes) == len(exp_changes) + assert set(changes) == set(exp_changes) + + +def test_new_activity_subscale_setting_is_none( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, + subscale_setting: SubscaleSetting, +): + old_activity.subscale_setting = subscale_setting + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert len(changes) == 1 + assert changes[0] == "Subscale Setting option was removed" + + +def test_new_activity_subscale_setting_was_removed( + old_activity: ActivityHistoryFull, + new_activity: ActivityHistoryFull, + activity_change_service: ActivityChangeService, + subscale_setting: SubscaleSetting, +): + old_activity.subscale_setting = subscale_setting + # This is most realistic way + new_activity.subscale_setting = SubscaleSetting( + subscales=list(), calculate_total_score=None + ) + exp_changes = [ + "Subscale Configuration: Calculate total score was removed", + "Subscale Configuration: Subscale test was removed", + ] + changes = activity_change_service.get_changes_update( + old_activity, new_activity + ) + assert len(changes) == len(exp_changes) + assert set(changes) == set(exp_changes) diff --git a/src/apps/activities/tests/unit/test_activity_item_change.py b/src/apps/activities/tests/unit/test_activity_item_change.py new file mode 100644 index 00000000000..d8d160b947c --- /dev/null +++ b/src/apps/activities/tests/unit/test_activity_item_change.py @@ -0,0 +1,584 @@ +import uuid +from typing import Any + +import pytest + +from apps.activities.domain.activity_history import ActivityItemHistoryFull +from apps.activities.domain.conditional_logic import ConditionalLogic, Match +from apps.activities.domain.conditions import ( + ConditionType, + EqualCondition, + ValuePayload, +) +from apps.activities.domain.response_type_config import ( + AdditionalResponseOption, + ResponseType, + SingleSelectionConfig, +) +from apps.activities.domain.response_values import ( + SingleSelectionRowsValues, + SingleSelectionValues, + SliderRowsValue, + SliderRowsValues, + _SingleSelectionOption, + _SingleSelectionRow, + _SingleSelectionValue, +) +from apps.activities.services.activity_item_change import ( + ActivityItemChangeService, + ConditionalLogicChangeService, + ConfigChangeService, + ResponseOptionChangeService, +) +from apps.shared.enums import Language + +TEST_UUID = uuid.UUID("00000000-0000-0000-0000-000000000000") + + +@pytest.fixture +def old_version() -> str: + return "1.0.0" + + +@pytest.fixture +def new_version() -> str: + return "2.0.0" + + +@pytest.fixture +def item_change_service(old_version, new_version) -> ActivityItemChangeService: + return ActivityItemChangeService(old_version, new_version) + + +@pytest.fixture +def old_id_version(old_version: str) -> str: + return f"{TEST_UUID}_{old_version}" + + +@pytest.fixture +def new_id_version(new_version: str) -> str: + return f"{TEST_UUID}_{new_version}" + + +@pytest.fixture +def single_selection_values() -> SingleSelectionValues: + return SingleSelectionValues( + palette_name=None, + options=[ + _SingleSelectionValue(id=str(TEST_UUID), text="o1", value=0), + ], + ) + + +@pytest.fixture +def single_selection_config() -> SingleSelectionConfig: + return SingleSelectionConfig( + randomize_options=False, + timer=0, + add_scores=False, + set_alerts=False, + add_tooltip=False, + add_tokens=False, + additional_response_option=AdditionalResponseOption( + text_input_option=False, text_input_required=False + ), + remove_back_button=False, + set_palette=False, + skippable_item=False, + ) + + +@pytest.fixture +def slider_rows_values() -> SliderRowsValues: + return SliderRowsValues( + rows=[ + SliderRowsValue( + id=str(TEST_UUID), + label="TEST", + min_label=None, + max_label=None, + min_value=0, + max_value=5, + ) + ] + ) + + +@pytest.fixture +def single_select_rows_values() -> SingleSelectionRowsValues: + return SingleSelectionRowsValues( + rows=[_SingleSelectionRow(id=str(TEST_UUID), row_name="row1")], + options=[_SingleSelectionOption(id=str(TEST_UUID), text="option 1")], + ) + + +@pytest.fixture +def conditional_logic() -> ConditionalLogic: + return ConditionalLogic( + conditions=[ + EqualCondition( + item_name="test_item", + type=ConditionType.EQUAL, + payload=ValuePayload(value=1), + ) + ] + ) + + +@pytest.fixture +def old_item( + old_id_version: str, + single_selection_values: SingleSelectionValues, + single_selection_config: SingleSelectionConfig, +) -> ActivityItemHistoryFull: + return ActivityItemHistoryFull( + id=TEST_UUID, + id_version=old_id_version, + activity_id=old_id_version, + order=1, + question={Language.ENGLISH: "Question"}, + response_type=ResponseType.SINGLESELECT, + response_values=single_selection_values, + config=single_selection_config, + name="test_item", + is_hidden=False, + allow_edit=False, + ) + + +@pytest.fixture +def new_item( + old_item: ActivityItemHistoryFull, new_id_version: str +) -> ActivityItemHistoryFull: + new_item = old_item.copy(deep=True) + new_item.id_version = new_id_version + new_item.activity_id = new_id_version + return new_item + + +def test_initial_single_selection_values_change( + single_selection_values: SingleSelectionValues, +) -> None: + service = ResponseOptionChangeService() + changes: list[str] = [] + service.check_changes( + ResponseType.SINGLESELECT, single_selection_values, changes + ) + assert changes == ["o1 | 0 option was added"] + + +def test_single_selection_values_update( + single_selection_values: SingleSelectionValues, +) -> None: + new_ssv = single_selection_values.copy(deep=True) + new_text = "newoptionname" + new_ssv.options[0].text = new_text + changes: list[str] = [] + service = ResponseOptionChangeService() + service.check_changes_update( + ResponseType.SINGLESELECT, single_selection_values, new_ssv, changes + ) + assert len(changes) == 1 + assert changes[0] == f"o1 | 0 option text was changed to {new_text} | 0" + + +def test_single_selection_remove_insert_with_the_same_name( + single_selection_values: SingleSelectionValues, +) -> None: + new_ssv = single_selection_values.copy(deep=True) + new_ssv.options[0].id = str(uuid.uuid4()) + changes: list[str] = [] + service = ResponseOptionChangeService() + service.check_changes_update( + ResponseType.SINGLESELECT, single_selection_values, new_ssv, changes + ) + exp_changes = ["o1 | 0 option was removed", "o1 | 0 option was added"] + assert changes == exp_changes + + +def test_single_selection_added_new_option( + single_selection_values: SingleSelectionValues, +) -> None: + new_ssv = single_selection_values.copy(deep=True) + op = new_ssv.options[0].copy(deep=True) + op.id = str(uuid.uuid4()) + op.text = "o2" + new_ssv.options.append(op) + changes: list[str] = [] + service = ResponseOptionChangeService() + service.check_changes_update( + ResponseType.SINGLESELECT, single_selection_values, new_ssv, changes + ) + assert changes == ["o2 | 0 option was added"] + + +def test_initial_slider_rows_values( + slider_rows_values: SliderRowsValues, +) -> None: + service = ResponseOptionChangeService() + changes: list[str] = [] + service.check_changes(ResponseType.SLIDERROWS, slider_rows_values, changes) + assert changes == [f"Row {slider_rows_values.rows[0].label} was added"] + + +def test_slider_rows_values_update( + slider_rows_values: SliderRowsValues, +) -> None: + new = slider_rows_values.copy(deep=True) + old_label = slider_rows_values.rows[0].label + new_label = "newlabel" + new.rows[0].label = new_label + changes: list[str] = [] + service = ResponseOptionChangeService() + service.check_changes_update( + ResponseType.SLIDERROWS, slider_rows_values, new, changes + ) + assert len(changes) == 1 + assert changes[0] == f"Row label {old_label} was changed to {new_label}" + + +def test_slider_rows_values_added_new_row( + slider_rows_values: SliderRowsValues, +) -> None: + new = slider_rows_values.copy(deep=True) + # Just copy for test new row one more time and change id + new_row = new.rows[0].copy(deep=True) + new_row.id = str(uuid.uuid4()) + new.rows.append(new_row) + changes: list[str] = [] + service = ResponseOptionChangeService() + service.check_changes_update( + ResponseType.SLIDERROWS, slider_rows_values, new, changes + ) + assert len(changes) == 1 + assert changes == [f"Row {new_row.label} was added"] + + +def test_slider_rows_values_removed_row( + slider_rows_values: SliderRowsValues, +) -> None: + new = slider_rows_values.copy(deep=True) + new.rows = [] + label = slider_rows_values.rows[0].label + changes: list[str] = [] + service = ResponseOptionChangeService() + service.check_changes_update( + ResponseType.SLIDERROWS, slider_rows_values, new, changes + ) + assert len(changes) == 1 + assert changes == [f"Row {label} was removed"] + + +def test_initial_single_select_rows_values( + single_select_rows_values: SingleSelectionRowsValues, +) -> None: + service = ResponseOptionChangeService() + changes: list[str] = [] + row_name = single_select_rows_values.rows[0].row_name + option_text = single_select_rows_values.options[0].text + service.check_changes( + ResponseType.SINGLESELECTROWS, single_select_rows_values, changes + ) + exp_changes = [ + f"Row {row_name} was added", + f"{option_text} was added", + ] + assert changes == exp_changes + + +def test_single_select_rows_row_name_update( + single_select_rows_values: SingleSelectionRowsValues, +) -> None: + service = ResponseOptionChangeService() + changes: list[str] = [] + old_name = single_select_rows_values.rows[0].row_name + new = single_select_rows_values.copy(deep=True) + new_name = "new row name" + new.rows[0].row_name = new_name + service.check_changes_update( + ResponseType.SINGLESELECTROWS, single_select_rows_values, new, changes + ) + assert changes == [f"Row name {old_name} was changed to {new_name}"] + + +def test_single_select_rows_option_text_update( + single_select_rows_values: SingleSelectionRowsValues, +) -> None: + service = ResponseOptionChangeService() + changes: list[str] = [] + old_text = single_select_rows_values.options[0].text + new = single_select_rows_values.copy(deep=True) + new_text = "new" + new.options[0].text = new_text + service.check_changes_update( + ResponseType.SINGLESELECTROWS, single_select_rows_values, new, changes + ) + assert changes == [f"Option text {old_text} was changed to {new_text}"] + + +@pytest.mark.parametrize( + "attr_name, exp_changes", + ( + ("rows", ["Row row1 was added"]), + ("options", ["option 1 option was added"]), + ), +) +def test_single_select_rows_new_response_value_was_added( + single_select_rows_values: SingleSelectionRowsValues, + attr_name: str, + exp_changes: list[str], +) -> None: + service = ResponseOptionChangeService() + changes: list[str] = [] + new = single_select_rows_values.copy(deep=True) + # for test just copy and change id + new_attr = getattr(new, attr_name)[0].copy(deep=True) + new_attr.id = str(uuid.uuid4()) + getattr(new, attr_name).append(new_attr) + service.check_changes_update( + ResponseType.SINGLESELECTROWS, single_select_rows_values, new, changes + ) + assert changes == exp_changes + + +# NOTE: For simple unit test for tracking changes we can avoid business logic +# that at least one row and one option is required. +@pytest.mark.parametrize( + "attr_name, exp_changes", + ( + ("rows", ["Row row1 was removed"]), + ("options", ["option 1 option was removed"]), + ), +) +def test_single_select_rows_new_response_value_was_removed( + single_select_rows_values: SingleSelectionRowsValues, + attr_name: str, + exp_changes: list[str], +) -> None: + service = ResponseOptionChangeService() + changes: list[str] = [] + new = single_select_rows_values.copy(deep=True) + setattr(new, attr_name, []) + service.check_changes_update( + ResponseType.SINGLESELECTROWS, single_select_rows_values, new, changes + ) + assert changes == exp_changes + + +def test_conditional_logic_added(conditional_logic: ConditionalLogic): + service = ConditionalLogicChangeService() + changes: list[str] = [] + parent_name = ActivityItemChangeService.field_name_verbose_name_map[ + "conditional_logic" + ] + service.check_changes(parent_name, conditional_logic, changes) + condition = conditional_logic.conditions[0] + condition_type = condition.type.lower().replace("_", " ") + item_name = condition.item_name + value = condition.payload.value # type: ignore + assert changes == [ + f"{parent_name}: If All: " + f"{item_name} {condition_type} {value} was added" + ] + + +def test_conditional_logic_changed(conditional_logic: ConditionalLogic): + service = ConditionalLogicChangeService() + changes: list[str] = [] + parent_name = ActivityItemChangeService.field_name_verbose_name_map[ + "conditional_logic" + ] + new = conditional_logic.copy(deep=True) + new.match = Match.ANY + service.check_update_changes(parent_name, conditional_logic, new, changes) + condition = conditional_logic.conditions[0] + condition_type = condition.type.lower().replace("_", " ") + item_name = condition.item_name + value = condition.payload.value # type: ignore + assert changes == [ + f"{parent_name}: If {new.match.capitalize()}: " + f"{item_name} {condition_type} {value} was updated" + ] + + +def test_conditional_logic_removed( + conditional_logic: ConditionalLogic, +) -> None: + service = ConditionalLogicChangeService() + changes: list[str] = [] + parent_name = ActivityItemChangeService.field_name_verbose_name_map[ + "conditional_logic" + ] + new = None + service.check_update_changes(parent_name, conditional_logic, new, changes) + assert changes == [f"{parent_name} was removed"] + + +def test_initial_single_selection_config_change( + single_selection_config: SingleSelectionConfig, +) -> None: + changes: list[str] = [] + service = ConfigChangeService() + service.check_changes(single_selection_config, changes) + exp_changes = [ + "Remove Back Button was disabled", + "Skippable Item was disabled", + "Randomize Options was disabled", + "Add Scores was disabled", + "Set Alerts was disabled", + "Add Tooltips was disabled", + "Set Color Palette was disabled", + "Tokens was disabled", + "Add Text Input Option was disabled", + "Input Required was disabled", + ] + assert changes == exp_changes + + +def test_initial_single_selection_with_timer( + single_selection_config: SingleSelectionConfig, +) -> None: + timer = 99 + single_selection_config.timer = timer + changes: list[str] = [] + service = ConfigChangeService() + service.check_changes(single_selection_config, changes) + assert f"Timer was set to {timer}" in changes + + +@pytest.mark.parametrize( + "option, exp_change_msg", + ( + ("text_input_option", "Add Text Input Option was enabled"), + ("text_input_required", "Input Required was enabled"), + ), +) +def test_initial_single_selection_additional_option_enabled( + single_selection_config: SingleSelectionConfig, + option: str, + exp_change_msg: str, +) -> None: + setattr(single_selection_config.additional_response_option, option, True) + changes: list[str] = [] + service = ConfigChangeService() + service.check_changes(single_selection_config, changes) + assert exp_change_msg in changes + + +def test_single_selection_config_updated( + single_selection_config: SingleSelectionConfig, +) -> None: + new = single_selection_config.copy(deep=True) + new.remove_back_button = True + service = ConfigChangeService() + changes: list[str] = [] + service.check_update_changes(single_selection_config, new, changes) + assert changes == ["Remove Back Button was enabled"] + + +@pytest.mark.parametrize( + "option, exp_change_msg", + ( + ("text_input_option", "Add Text Input Option was enabled"), + ("text_input_required", "Input Required was enabled"), + ), +) +def test_single_selection_additional_option_enabled( + single_selection_config: SingleSelectionConfig, + option: str, + exp_change_msg: str, +) -> None: + new = single_selection_config.copy(deep=True) + setattr(new.additional_response_option, option, True) + changes: list[str] = [] + service = ConfigChangeService() + service.check_update_changes(single_selection_config, new, changes) + assert exp_change_msg in changes + + +def test_single_selection_config_timer_was_added( + single_selection_config: SingleSelectionConfig, +) -> None: + timer = 99 + new = single_selection_config.copy(deep=True) + new.timer = timer + changes: list[str] = [] + service = ConfigChangeService() + service.check_update_changes(single_selection_config, new, changes) + assert [f"Timer was set to {timer}"] == changes + + +def test_initial_version_changes( + new_item: ActivityItemHistoryFull, + item_change_service: ActivityItemChangeService, +) -> None: + # NOTE: for another response types initial changes can be different + single_select_exp_changes = [ + "Item Name was set to test_item", + "Displayed Content was set to Question", + "Item Type was set to singleSelect", + "o1 | 0 option was added", + "Item Order was set to 1", + "Item Visibility was enabled", + "Remove Back Button was disabled", + "Skippable Item was disabled", + "Randomize Options was disabled", + "Add Scores was disabled", + "Set Alerts was disabled", + "Add Tooltips was disabled", + "Set Color Palette was disabled", + "Tokens was disabled", + "Add Text Input Option was disabled", + "Input Required was disabled", + ] + changes = item_change_service.get_changes_insert(new_item) + assert changes == single_select_exp_changes + + +def test_no_changes_in_versions( + old_item: ActivityItemHistoryFull, + new_item: ActivityItemHistoryFull, + item_change_service: ActivityItemChangeService, +) -> None: + changes = item_change_service.get_changes_update(old_item, new_item) + assert not changes + + +def test_initial_item_is_hidden_true( + new_item: ActivityItemHistoryFull, + item_change_service: ActivityItemChangeService, +) -> None: + new_item.is_hidden = True + changes = item_change_service.get_changes_insert(new_item) + assert "Item Visibility was disabled" in changes + + +@pytest.mark.parametrize( + "field, value, exp_change_msg", + ( + ( + "question", + {Language.ENGLISH: "New Question"}, + "Displayed Content was changed to New Question", + ), + ( + "is_hidden", + True, + "Item Visibility was disabled", + ), + ("order", 2, "Item Order was changed to 2"), + ("name", "new name", "Item Name was changed to newname"), + ), +) +def test_field_changed( + old_item: ActivityItemHistoryFull, + new_item: ActivityItemHistoryFull, + item_change_service: ActivityItemChangeService, + field: str, + value: Any, + exp_change_msg: str, +) -> None: + setattr(new_item, field, value) + changes = item_change_service.get_changes_update(old_item, new_item) + assert len(changes) == 1 + assert changes[0] == exp_change_msg diff --git a/src/apps/activity_flows/crud/flow_history.py b/src/apps/activity_flows/crud/flow_history.py index cd4851b4bc1..1cf479ab8ac 100644 --- a/src/apps/activity_flows/crud/flow_history.py +++ b/src/apps/activity_flows/crud/flow_history.py @@ -2,6 +2,7 @@ from sqlalchemy.orm import Query from apps.activity_flows.db.schemas import ActivityFlowHistoriesSchema +from apps.applets.db.schemas import AppletHistorySchema from infrastructure.database import BaseCRUD __all__ = ["FlowsHistoryCRUD"] @@ -48,3 +49,26 @@ async def get_by_applet_id( query = query.order_by(ActivityFlowHistoriesSchema.order.asc()) result = await self._execute(query) return result.scalars().all() + + async def retrieve_by_applet_ids( + self, applet_versions: list[str] + ) -> list[ActivityFlowHistoriesSchema]: + """ + retrieve flows by applet id_version fields + order by id + """ + query: Query = select(ActivityFlowHistoriesSchema) + query = query.join( + AppletHistorySchema, + AppletHistorySchema.id_version + == ActivityFlowHistoriesSchema.applet_id, + ) + query = query.where( + AppletHistorySchema.id_version.in_(applet_versions) + ) + query = query.order_by( + ActivityFlowHistoriesSchema.id.asc(), + ActivityFlowHistoriesSchema.updated_at.asc(), + ) + db_result = await self._execute(query) + return db_result.scalars().all() diff --git a/src/apps/activity_flows/crud/flow_item_history.py b/src/apps/activity_flows/crud/flow_item_history.py index 45418cbed83..77c82715d3a 100644 --- a/src/apps/activity_flows/crud/flow_item_history.py +++ b/src/apps/activity_flows/crud/flow_item_history.py @@ -1,10 +1,13 @@ +from pydantic import parse_obj_as from sqlalchemy import and_, or_, select from sqlalchemy.orm import Query +from apps.activities.db.schemas import ActivityHistorySchema from apps.activity_flows.db.schemas import ( ActivityFlowHistoriesSchema, ActivityFlowItemHistorySchema, ) +from apps.activity_flows.domain.flow_full import FlowItemHistoryFull from infrastructure.database import BaseCRUD __all__ = ["FlowItemHistoriesCRUD"] @@ -74,3 +77,27 @@ async def get_by_map( query = query.where(or_(*filters)) db_result = await self._execute(query) return db_result.scalars().all() + + async def get_by_flow_id_versions( + self, id_versions: list[str] + ) -> list[FlowItemHistoryFull]: + query: Query = select( + ActivityFlowItemHistorySchema.id, + ActivityFlowItemHistorySchema.activity_flow_id, + ActivityFlowItemHistorySchema.activity_id, + ActivityFlowItemHistorySchema.id_version, + ActivityFlowItemHistorySchema.order, + ActivityHistorySchema.name, + ) + query = query.join( + ActivityHistorySchema, + ActivityHistorySchema.id_version + == ActivityFlowItemHistorySchema.activity_id, + ) + query = query.where( + ActivityFlowItemHistorySchema.activity_flow_id.in_(id_versions) + ) + query = query.order_by(ActivityFlowItemHistorySchema.order.asc()) + db_result = await self._execute(query) + res = db_result.all() + return [parse_obj_as(FlowItemHistoryFull, row) for row in res] diff --git a/src/apps/activity_flows/domain/__init__.py b/src/apps/activity_flows/domain/__init__.py new file mode 100644 index 00000000000..4c11dd3cb5c --- /dev/null +++ b/src/apps/activity_flows/domain/__init__.py @@ -0,0 +1 @@ +from apps.activity_flows.domain.flow_history import * # noqa: F401, F403 diff --git a/src/apps/activity_flows/domain/flow.py b/src/apps/activity_flows/domain/flow.py index c2b84eeb3c1..92e4e41d0ab 100644 --- a/src/apps/activity_flows/domain/flow.py +++ b/src/apps/activity_flows/domain/flow.py @@ -11,7 +11,6 @@ class Flow(FlowBase, InternalModel): id: uuid.UUID applet_id: uuid.UUID order: int - extra_fields: dict | None = Field(default_factory=dict) class FlowPublic(FlowBase, PublicModel): @@ -35,6 +34,17 @@ class FlowSingleLanguageDetailPublic(FlowPublic): created_at: datetime +class FlowSingleLanguageMobileDetailPublic(InternalModel): + id: uuid.UUID + name: str + description: str + hide_badge: bool = False + is_single_report: bool = False + order: int + is_hidden: bool | None = False + activity_ids: list[uuid.UUID] = Field(default_factory=list) + + class FlowDuplicate(FlowBase, InternalModel): id: uuid.UUID order: int diff --git a/src/apps/activity_flows/domain/flow_full.py b/src/apps/activity_flows/domain/flow_full.py index 54c1f629ae5..a39c6be2bfe 100644 --- a/src/apps/activity_flows/domain/flow_full.py +++ b/src/apps/activity_flows/domain/flow_full.py @@ -17,7 +17,9 @@ class FlowItemHistoryFull(InternalModel): id: uuid.UUID activity_flow_id: str activity_id: str + id_version: str order: int + name: str | None = None class FlowFull(FlowBase, InternalModel): @@ -25,7 +27,6 @@ class FlowFull(FlowBase, InternalModel): items: list[ActivityFlowItemFull] = Field(default_factory=list) order: int created_at: datetime - extra_fields: dict = Field(default_factory=dict) class FlowHistoryFull(FlowBase, InternalModel): @@ -34,7 +35,6 @@ class FlowHistoryFull(FlowBase, InternalModel): items: list[FlowItemHistoryFull] = Field(default_factory=list) order: int created_at: datetime - extra_fields: dict = Field(default_factory=dict) class PublicActivityFlowItemFull(FlowItemBase, PublicModel): diff --git a/src/apps/activity_flows/domain/flow_history.py b/src/apps/activity_flows/domain/flow_history.py new file mode 100644 index 00000000000..36643306768 --- /dev/null +++ b/src/apps/activity_flows/domain/flow_history.py @@ -0,0 +1,30 @@ +from pydantic import Field + +from apps.shared.domain import InternalModel, PublicModel + +__all__ = [ + "ActivityFlowItemHistoryChange", + "ActivityFlowHistoryChange", + "PublicActivityFlowHistoryChange", +] + + +class ActivityFlowItemHistoryChange(InternalModel): + name: str | None = None + changes: list[str] | None = None + + +class ActivityFlowHistoryChange(InternalModel): + name: str | None = None + changes: list[str] | None = Field(default_factory=list) + items: list[ActivityFlowItemHistoryChange] | None = Field( + default_factory=list + ) + + +class PublicActivityFlowHistoryChange(PublicModel): + name: str | None = None + changes: list[str] | None = Field(default_factory=list) + items: list[ActivityFlowItemHistoryChange] | None = Field( + default_factory=list + ) diff --git a/src/apps/activity_flows/service/flow.py b/src/apps/activity_flows/service/flow.py index e5c54e22772..b8ced3df7bb 100644 --- a/src/apps/activity_flows/service/flow.py +++ b/src/apps/activity_flows/service/flow.py @@ -18,8 +18,10 @@ PreparedFlowItemUpdate, ) from apps.activity_flows.service.flow_item import FlowItemService -from apps.schedule.crud.events import FlowEventsCRUD +from apps.applets.crud import UserAppletAccessCRUD +from apps.schedule.crud.events import EventCRUD, FlowEventsCRUD from apps.schedule.service.schedule import ScheduleService +from apps.workspaces.domain.constants import Role class FlowService: @@ -166,12 +168,41 @@ async def update_create( # Create default events for new activities if new_flows: - await ScheduleService(self.session).create_default_schedules( + respondents_in_applet = await UserAppletAccessCRUD( + self.session + ).get_user_id_applet_and_role( applet_id=applet_id, - activity_ids=list(new_flows), - is_activity=False, + role=Role.RESPONDENT, ) + respondents_with_indvdl_schdl = [] + for respondent in respondents_in_applet: + respondent_uuid = uuid.UUID(f"{respondent}") + number_of_indvdl_events = await EventCRUD( + self.session + ).count_individual_events_by_user( + applet_id=applet_id, user_id=respondent_uuid + ) + if number_of_indvdl_events > 0: + respondents_with_indvdl_schdl.append(respondent_uuid) + + if respondents_with_indvdl_schdl: + for respondent_uuid in respondents_with_indvdl_schdl: + await ScheduleService( + self.session + ).create_default_schedules( + applet_id=applet_id, + activity_ids=list(new_flows), + is_activity=False, + respondent_id=respondent_uuid, + ) + else: + await ScheduleService(self.session).create_default_schedules( + applet_id=applet_id, + activity_ids=list(new_flows), + is_activity=False, + ) + return flows async def remove_applet_flows(self, applet_id: uuid.UUID): diff --git a/src/apps/activity_flows/service/flow_change.py b/src/apps/activity_flows/service/flow_change.py new file mode 100644 index 00000000000..8ef8e9eb914 --- /dev/null +++ b/src/apps/activity_flows/service/flow_change.py @@ -0,0 +1,136 @@ +import uuid + +from apps.activity_flows.domain.flow_full import ( + FlowHistoryFull, + FlowItemHistoryFull, +) +from apps.activity_flows.domain.flow_history import ( + ActivityFlowItemHistoryChange, +) +from apps.shared.changes_generator import BaseChangeGenerator + + +class ActivityFlowChangeService(BaseChangeGenerator): + field_name_verbose_name_map = { + "name": "Activity Flow Name", + "description": "Activity Flow Description", + "is_single_report": "Combine reports into a single file", + "hide_badge": "Hide Badge", + "is_hidden": "Activity Flow Visibility", + } + + def generate_flow_insert(self, flow: FlowHistoryFull) -> list[str]: + changes: list[str] = list() + for ( + field_name, + verbose_name, + ) in self.field_name_verbose_name_map.items(): + value = getattr(flow, field_name) + if isinstance(value, bool): + self._populate_bool_changes(verbose_name, value, changes) + elif value: + changes.append( + self._change_text_generator.changed_text( + verbose_name, value, is_initial=True + ), + ) + return changes + + def generate_flow_update( + self, new_flow: FlowHistoryFull, old_flow: FlowHistoryFull + ) -> list[str]: + changes: list[str] = list() + for ( + field_name, + verbose_name, + ) in self.field_name_verbose_name_map.items(): + new_value = getattr(new_flow, field_name) + old_value = getattr(old_flow, field_name) + if isinstance(new_value, bool): + if new_value != old_value: + self._populate_bool_changes( + verbose_name, new_value, changes + ) + elif new_value != old_value: + changes.append( + self._change_text_generator.changed_text( + verbose_name, new_value + ), + ) + return changes + + +class ActivityFlowItemChangeService(BaseChangeGenerator): + def generate_flow_items_insert( + self, flow_items: list[FlowItemHistoryFull] + ) -> list[ActivityFlowItemHistoryChange]: + change_items = [] + for item in flow_items: + change = ActivityFlowItemHistoryChange( + name=self._change_text_generator.added_text( + f"Activity {item.name}" + ) + ) + changes: list[str] = [] + for field, value in item: + if field == "order": + verbose_name = "Activity Order" + changes.append( + self._change_text_generator.set_text( + verbose_name, value + ) + ) + + change.changes = changes + change_items.append(change) + + return change_items + + def generate_flow_items_update( + self, + item_groups: dict[ + uuid.UUID, + tuple[FlowItemHistoryFull | None, FlowItemHistoryFull | None], + ], + ) -> list[ActivityFlowItemHistoryChange]: + change_items: list[ActivityFlowItemHistoryChange] = [] + + for _, (prev_item, new_item) in item_groups.items(): + if not prev_item and new_item: + change_items.extend( + self.generate_flow_items_insert([new_item]) + ) + elif not new_item and prev_item: + change_items.append( + ActivityFlowItemHistoryChange( + name=self._change_text_generator.removed_text( + f"Activity {prev_item.name}" + ) + ) + ) + elif prev_item and new_item: + changes = self._generate_flow_item_update(prev_item, new_item) + if changes: + change_items.append( + ActivityFlowItemHistoryChange( + name=self._change_text_generator.updated_text( + f"Activity {new_item.name}" + ), + changes=changes, + ) + ) + + return change_items + + def _generate_flow_item_update( + self, prev_item: FlowItemHistoryFull, new_item: FlowItemHistoryFull + ) -> list[str]: + changes: list[str] = [] + if prev_item.order != new_item.order: + changes.append( + self._change_text_generator.changed_text( + "Activity Order", + new_item.order, + ), + ) + return changes diff --git a/src/apps/activity_flows/service/flow_history.py b/src/apps/activity_flows/service/flow_history.py index 4243c6c45db..13736a4985f 100644 --- a/src/apps/activity_flows/service/flow_history.py +++ b/src/apps/activity_flows/service/flow_history.py @@ -2,10 +2,20 @@ from apps.activity_flows.crud import FlowsHistoryCRUD from apps.activity_flows.db.schemas import ActivityFlowHistoriesSchema -from apps.activity_flows.domain.flow_full import FlowFull, FlowHistoryFull +from apps.activity_flows.domain.flow_full import ( + FlowFull, + FlowHistoryFull, + FlowItemHistoryFull, +) +from apps.activity_flows.domain.flow_history import ActivityFlowHistoryChange +from apps.activity_flows.service.flow_change import ( + ActivityFlowChangeService, + ActivityFlowItemChangeService, +) from apps.activity_flows.service.flow_item_history import ( FlowItemHistoryService, ) +from apps.shared.changes_generator import ChangeTextGenerator class FlowHistoryService: @@ -33,7 +43,6 @@ async def add(self, flows: list[FlowFull]): order=flow.order, report_included_activity_name=flow.report_included_activity_name, # noqa: E501 report_included_item_name=flow.report_included_item_name, - extra_fields=flow.extra_fields, ) ) @@ -64,3 +73,97 @@ async def get_full(self) -> list[FlowHistoryFull]: flow_map[item.activity_flow_id].items.append(item) return flows + + async def get_changes( + self, prev_version: str + ) -> list[ActivityFlowHistoryChange]: + old_id_version = f"{self.applet_id}_{prev_version}" + return await self._get_changes(old_id_version) + + async def _get_changes( + self, old_id_version: str + ) -> list[ActivityFlowHistoryChange]: + changes_generator = ChangeTextGenerator() + flow_change_service = ActivityFlowChangeService() + flow_item_change_service = ActivityFlowItemChangeService() + flow_changes: list[ActivityFlowHistoryChange] = [] + flow_schemas = await FlowsHistoryCRUD( + self.session + ).retrieve_by_applet_ids([self.applet_id_version, old_id_version]) + flows = [] + for schema in flow_schemas: + flow = FlowHistoryFull.from_orm(schema) + flow.items = await FlowItemHistoryService( + self.session, self.applet_id, self.version + ).get_by_flow_id_versions([flow.id_version]) + flows.append(flow) + flow_groups = self._group_and_sort_flows_or_items(flows) + for _, (prev, new) in flow_groups.items(): + if not prev and new: + flow_changes.append( + ActivityFlowHistoryChange( + name=changes_generator.added_text( + f"Activity Flow by name {new.name}" + ), + changes=flow_change_service.generate_flow_insert( + new # type: ignore + ), + items=flow_item_change_service.generate_flow_items_insert( # noqa E501 + getattr(new, "items", []) + ), + ) + ) + elif not new and prev: + flow_changes.append( + ActivityFlowHistoryChange( + name=changes_generator.removed_text( + f"Activity Flow by name {prev.name}" + ) + ) + ) + elif new and prev: + changes = flow_change_service.generate_flow_update( + new, # type: ignore + prev, # type: ignore + ) + changes_items = flow_item_change_service.generate_flow_items_update( # noqa: E501 + self._group_and_sort_flows_or_items( + getattr(new, "items", []) + getattr(prev, "items", []) + ), # type: ignore + ) + + if changes or changes_items: + flow_changes.append( + ActivityFlowHistoryChange( + name=changes_generator.updated_text( + f"Activity Flow {new.name}" + ), + changes=changes, + items=changes_items, + ) + ) + return flow_changes + + def _group_and_sort_flows_or_items( + self, items: list[FlowHistoryFull] | list[FlowItemHistoryFull] + ) -> dict[ + uuid.UUID, + tuple[FlowHistoryFull | None, FlowHistoryFull | None] + | tuple[FlowItemHistoryFull | None, FlowItemHistoryFull | None], + ]: + groups_map: dict = dict() + for item in items: + group = groups_map.get(item.id) + if not group: + if self.version in item.id_version.split("_"): + group = (None, item) + else: + group = (item, None) + elif group: + if self.version in item.id_version.split("_"): + group = (group[0], item) + else: + group = (item, group[1]) + groups_map[item.id] = group + + return groups_map diff --git a/src/apps/activity_flows/service/flow_item_history.py b/src/apps/activity_flows/service/flow_item_history.py index cc2cacaf858..2e6144ed3c4 100644 --- a/src/apps/activity_flows/service/flow_item_history.py +++ b/src/apps/activity_flows/service/flow_item_history.py @@ -47,3 +47,10 @@ async def get_by_flow_ids( [f"{pk}_{self.version}" for pk in flow_ids] ) return [FlowItemHistoryFull.from_orm(schema) for schema in schemas] + + async def get_by_flow_id_versions( + self, activity_id_versions: list[str] + ) -> list[FlowItemHistoryFull]: + return await FlowItemHistoriesCRUD( + self.session + ).get_by_flow_id_versions(activity_id_versions) diff --git a/src/apps/answers/api.py b/src/apps/answers/api.py index 4045c3b8876..8fcc888843c 100644 --- a/src/apps/answers/api.py +++ b/src/apps/answers/api.py @@ -1,3 +1,4 @@ +import asyncio import base64 import datetime import uuid @@ -7,7 +8,10 @@ from pydantic import parse_obj_as from apps.activities.services import ActivityHistoryService -from apps.answers.deps.preprocess_arbitrary import get_answer_session +from apps.answers.deps.preprocess_arbitrary import ( + get_answer_session, + get_arbitraries_map, +) from apps.answers.domain import ( ActivityAnswerPublic, AnswerExistenceResponse, @@ -18,6 +22,7 @@ AnswersCheck, AppletActivityAnswerPublic, AppletAnswerCreate, + AppletCompletedEntities, AssessmentAnswerCreate, AssessmentAnswerPublic, IdentifierPublic, @@ -37,7 +42,10 @@ SummaryActivityFilter, ) from apps.answers.service import AnswerService -from apps.applets.service import AppletService +from apps.applets.crud import AppletsCRUD +from apps.applets.db.schemas import AppletSchema +from apps.applets.errors import InvalidVersionError, NotValidAppletHistory +from apps.applets.service import AppletHistoryService, AppletService from apps.authentication.deps import get_current_user from apps.shared.deps import get_i18n from apps.shared.domain import Response, ResponseMulti @@ -49,8 +57,9 @@ ) from apps.users import UsersCRUD from apps.users.domain import User +from apps.workspaces.domain.constants import Role from apps.workspaces.service.check_access import CheckAccessService -from infrastructure.database import atomic +from infrastructure.database import atomic, session_manager from infrastructure.database.deps import get_session @@ -64,6 +73,12 @@ async def create_answer( await CheckAccessService(session, user.id).check_answer_create_access( schema.applet_id ) + try: + await AppletHistoryService( + session, schema.applet_id, schema.version + ).get() + except NotValidAppletHistory: + raise InvalidVersionError() service = AnswerService(session, user.id, answer_session) async with atomic(answer_session): answer = await service.create_answer(schema) @@ -497,6 +512,91 @@ async def applet_completed_entities( return Response(result=data) +async def _get_arbitrary_answer( + session, + from_date: datetime.date, + arb_uri: str, + applets_version_map: dict[uuid.UUID, str], + user_id: uuid.UUID | None = None, +) -> list[AppletCompletedEntities]: + arb_session_maker = session_manager.get_session(arb_uri) + try: + async with arb_session_maker() as arb_session: + data = await AnswerService( + session, + user_id=user_id, + arbitrary_session=arb_session, + ).get_completed_answers_data_list(applets_version_map, from_date) + finally: + await arb_session_maker.remove() + + return data + + +async def applets_completed_entities( + from_date: datetime.date = Query(..., alias="fromDate"), + user: User = Depends(get_current_user), + session=Depends(get_session), +) -> ResponseMulti[AppletCompletedEntities]: + async with atomic(session): + # applets for this endpoint must be equal to + # applets from /applets?roles=respondent endpoint + query_params: QueryParams = QueryParams( + filters={"roles": Role.RESPONDENT, "flat_list": False}, + limit=10000, + ) + applets: list[AppletSchema] = await AppletsCRUD( + session + ).get_applets_by_roles( + user_id=user.id, + roles=[Role.RESPONDENT], + query_params=query_params, + exclude_without_encryption=True, + ) + + applets_version_map: dict[uuid.UUID, str] = dict() + for applet in applets: + applets_version_map[applet.id] = applet.version + applet_ids: list[uuid.UUID] = list(applets_version_map.keys()) + + arb_uri_applet_ids_map: dict[ + str | None, list[uuid.UUID] + ] = await get_arbitraries_map(applet_ids, session) + + data_future_list = [] + for arb_uri, arb_applet_ids in arb_uri_applet_ids_map.items(): + applets_version_arb_map: dict[uuid.UUID, str] = dict() + for applet_id in arb_applet_ids: + applets_version_arb_map[applet_id] = applets_version_map[ + applet_id + ] + + if arb_uri: + data = _get_arbitrary_answer( + session, + from_date, + arb_uri, + applets_version_arb_map, + user_id=user.id, + ) + else: + data = AnswerService( + session, user_id=user.id + ).get_completed_answers_data_list( + applets_version_arb_map, from_date + ) + data_future_list.append(data) + + entities_lists = await asyncio.gather(*data_future_list) + entities = [] + + for entities_list in entities_lists: + if entities_list: + entities += entities_list + + return ResponseMulti(result=entities, count=len(entities)) + + async def answers_existence_check( schema: AnswersCheck = Body(...), user: User = Depends(get_current_user), diff --git a/src/apps/answers/commands/__init__.py b/src/apps/answers/commands/__init__.py new file mode 100644 index 00000000000..91333301bed --- /dev/null +++ b/src/apps/answers/commands/__init__.py @@ -0,0 +1,5 @@ +from apps.answers.commands.convert_assessments import ( # noqa: F401 + app as convert_assessments, +) + +__all__ = ["convert_assessments"] diff --git a/src/apps/answers/commands/convert_assessments.py b/src/apps/answers/commands/convert_assessments.py new file mode 100644 index 00000000000..b9b76fd809b --- /dev/null +++ b/src/apps/answers/commands/convert_assessments.py @@ -0,0 +1,59 @@ +import asyncio +from functools import wraps +from typing import Optional + +import typer +from rich import print + +from apps.answers.crud.assessment_crud import AssessmentCRUD +from infrastructure.database import atomic, session_manager + +app = typer.Typer() + + +def coro(f): + @wraps(f) + def wrapper(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +@app.command(short_help="Convert current assessments to version agnostic") +@coro +async def convert( + database_uri: Optional[str] = typer.Option( + None, + "--db-uri", + "-d", + help="Local or arbitrary server database uri", + ), +): + try: + if database_uri: + local_or_arb = session_manager.get_session(database_uri) + else: + local_or_arb = session_manager.get_session() + # Going to arbitrary or local db to get assessments + async with local_or_arb() as session: + crud = AssessmentCRUD(session) + assessments = await crud.get_all_assessments_data() + + # Going to local db to find activity id + local = session_manager.get_session() + async with local() as session: + async with atomic(session): + crud = AssessmentCRUD(session) + answers = await crud.get_updated_assessment(assessments) + await local.remove() + + # Return to arbitrary or local to update + async with local_or_arb() as session: + async with atomic(session): + crud = AssessmentCRUD(session) + for answer in answers: + await crud.update(answer) + await local_or_arb.remove() + + except Exception as ex: + print(f"[bold red] {ex}") diff --git a/src/apps/answers/crud/answer_items.py b/src/apps/answers/crud/answer_items.py index 094435b5492..08d8320adfe 100644 --- a/src/apps/answers/crud/answer_items.py +++ b/src/apps/answers/crud/answer_items.py @@ -5,10 +5,8 @@ from sqlalchemy.orm import Query from apps.answers.db.schemas import AnswerItemSchema, AnswerSchema -from apps.answers.domain import AnswerReview from apps.shared.filtering import Comparisons, FilterField, Filtering from apps.shared.query_params import QueryParams -from apps.users import UserSchema from infrastructure.database.crud import BaseCRUD @@ -106,38 +104,18 @@ async def get_assessment( query = query.where(AnswerItemSchema.answer_id == answer_id) query = query.where(AnswerItemSchema.respondent_id == user_id) query = query.where(AnswerItemSchema.is_assessment == True) # noqa - db_result = await self._execute(query) - return db_result.scalars().first() async def get_reviews_by_answer_id( self, answer_id: uuid.UUID, activity_items: list - ) -> list[AnswerReview]: - query: Query = select( - AnswerItemSchema, - UserSchema.first_name, - UserSchema.last_name, - ) - query = query.join( - UserSchema, UserSchema.id == AnswerItemSchema.respondent_id - ) + ) -> list[AnswerItemSchema]: + query: Query = select(AnswerItemSchema) query = query.where(AnswerItemSchema.answer_id == answer_id) - query = query.where(AnswerItemSchema.is_assessment == True) # noqa + query = query.where(AnswerItemSchema.is_assessment.is_(True)) db_result = await self._execute(query) - results = [] - for schema, first_name, last_name in db_result.all(): - results.append( - AnswerReview( - reviewer_public_key=schema.user_public_key, - answer=schema.answer, - item_ids=schema.item_ids, - items=activity_items, - reviewer=dict(first_name=first_name, last_name=last_name), - ) - ) - return results + return db_result.scalars().all() # noqa async def get_respondent_answer( self, answer_id: uuid.UUID @@ -223,3 +201,17 @@ async def get_applet_answers_by_activity_history_ids( ) db_result = await self._execute(query) return db_result.all() + + async def get_assessment_activity_id( + self, answer_id: uuid.UUID + ) -> list[tuple[uuid.UUID, str]] | None: + query: Query = select( + AnswerItemSchema.respondent_id, + AnswerItemSchema.assessment_activity_id, + ) + query = query.where( + AnswerItemSchema.answer_id == answer_id, + AnswerItemSchema.is_assessment.is_(True), + ) + db_result = await self._execute(query) + return db_result.all() # noqa diff --git a/src/apps/answers/crud/answers.py b/src/apps/answers/crud/answers.py index f92be9d0e6a..fafaa3edeaf 100644 --- a/src/apps/answers/crud/answers.py +++ b/src/apps/answers/crud/answers.py @@ -19,6 +19,7 @@ from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Query from sqlalchemy.sql import Values +from sqlalchemy.sql.elements import BooleanClauseList from apps.activities.db.schemas import ( ActivityHistorySchema, @@ -37,7 +38,7 @@ Version, ) from apps.answers.errors import AnswerNotFoundError, AnswerRetentionType -from apps.applets.db.schemas import AppletHistorySchema, AppletSchema +from apps.applets.db.schemas import AppletHistorySchema from apps.shared.filtering import Comparisons, FilterField, Filtering from apps.shared.paging import paging from apps.workspaces.domain.constants import DataRetention @@ -60,6 +61,9 @@ def filter_respondent_ids(self, field, value): from_date = FilterField( AnswerItemSchema.created_at, Comparisons.GREAT_OR_EQUAL ) + to_date = FilterField( + AnswerItemSchema.created_at, Comparisons.LESS_OR_EQUAL + ) class AnswersCRUD(BaseCRUD[AnswerSchema]): @@ -148,7 +152,6 @@ async def get_applet_answers( limit=None, **filters, ) -> tuple[list[RespondentAnswerData], int]: - reviewed_answer_id = case( (AnswerItemSchema.is_assessment.is_(True), AnswerSchema.id), else_=null(), @@ -160,7 +163,10 @@ async def get_applet_answers( ) activity_history_id = case( - (AnswerItemSchema.is_assessment.is_(True), null()), + ( + AnswerItemSchema.is_assessment.is_(True), + AnswerItemSchema.assessment_activity_id, + ), else_=AnswerSchema.activity_history_id, ) @@ -345,64 +351,6 @@ async def get_by_applet_activity_created_at( db_result = await self._execute(query) return db_result.scalars().all() - async def get_activity_flow_by_answer_id( - self, answer_id: uuid.UUID - ) -> bool: - query: Query = select(AnswerItemSchema, ActivityFlowHistoriesSchema) - query = query.join( - AnswerSchema, AnswerSchema.id == AnswerItemSchema.answer_id - ) - query = query.join( - ActivityFlowHistoriesSchema, - ActivityFlowHistoriesSchema.id_version - == AnswerSchema.flow_history_id, - isouter=True, - ) - query = query.where(AnswerItemSchema.is_assessment == False) # noqa - query = query.where(AnswerSchema.id == answer_id) - - db_result = await self._execute(query) - ( - _, - flow_history_schema, - ) = ( - db_result.first() - ) # type: AnswerItemSchema, ActivityFlowHistoriesSchema - if not flow_history_schema: - return False - return flow_history_schema.is_single_report - - async def get_applet_info_by_answer_id( - self, answer: AnswerSchema - ) -> tuple[AppletHistorySchema, ActivityHistorySchema]: - query: Query = select( - AppletSchema, - ActivityHistorySchema, - ) - query = query.join( - AppletHistorySchema, - AppletHistorySchema.id == AppletSchema.id, - isouter=True, - ) - query = query.join( - ActivityHistorySchema, - and_( - ActivityHistorySchema.applet_id - == AppletHistorySchema.id_version, - ActivityHistorySchema.id_version == answer.activity_history_id, - ), - isouter=True, - ) - query = query.where( - and_( - AppletSchema.id == answer.applet_id, - AppletHistorySchema.version == answer.version, - ) - ) - db_result = await self._execute(query) - res = db_result.first() - return res - async def get_activities_which_has_answer( self, activity_hist_ids: list[str], respondent_id: uuid.UUID | None ) -> list[str]: @@ -494,6 +442,97 @@ async def get_completed_answers_data( activity_flows=flows, ) + async def get_completed_answers_data_list( + self, + applets_version_map: dict[uuid.UUID, str], + respondent_id: uuid.UUID, + from_date: datetime.date, + ) -> list[AppletCompletedEntities]: + is_completed = or_( + AnswerSchema.is_flow_completed, + AnswerSchema.flow_history_id.is_(None), + ) + + applet_version_filter_list: list[BooleanClauseList] = list() + for applet_id, version in applets_version_map.items(): + applet_version_filter_list.append( + and_( + AnswerSchema.applet_id == applet_id, + AnswerSchema.version == version, + ) + ) + applet_version_filter: BooleanClauseList = or_( + *applet_version_filter_list + ) + + query: Query = ( + select( + AnswerSchema.id.label("answer_id"), + AnswerSchema.applet_id, + AnswerSchema.submit_id, + AnswerSchema.activity_history_id, + AnswerSchema.flow_history_id, + AnswerItemSchema.scheduled_event_id, + AnswerItemSchema.local_end_date, + AnswerItemSchema.local_end_time, + ) + .join( + AnswerItemSchema, AnswerItemSchema.answer_id == AnswerSchema.id + ) + .where( + AnswerSchema.respondent_id == respondent_id, + AnswerItemSchema.local_end_date >= from_date, + is_completed, + ) + .where(applet_version_filter) + .order_by( + AnswerSchema.activity_history_id, + AnswerSchema.flow_history_id, + AnswerItemSchema.scheduled_event_id, + AnswerItemSchema.local_end_date.desc(), + AnswerItemSchema.local_end_time.desc(), + ) + .distinct( + AnswerSchema.activity_history_id, + AnswerSchema.flow_history_id, + AnswerItemSchema.scheduled_event_id, + ) + ) + + db_result = await self._execute(query) + data = db_result.all() + + applet_activities_flows_map: dict[uuid.UUID, dict[str, list]] = dict() + for row in data: + applet_activities_flows_map.setdefault( + row.applet_id, {"activities": [], "flows": []} + ) + if row.flow_history_id: + applet_activities_flows_map[row.applet_id]["flows"].append( + CompletedEntity(**row, id=row.flow_history_id) + ) + else: + applet_activities_flows_map[row.applet_id][ + "activities" + ].append(CompletedEntity(**row, id=row.activity_history_id)) + + result_list: list[AppletCompletedEntities] = list() + for applet_id, version in applets_version_map.items(): + result_list.append( + AppletCompletedEntities( + id=applet_id, + version=version, + activities=applet_activities_flows_map.get( + applet_id, {"activities": [], "flows": []} + )["activities"], + activity_flows=applet_activities_flows_map.get( + applet_id, {"activities": [], "flows": []} + )["flows"], + ) + ) + + return result_list + async def get_latest_applet_version(self, applet_id: uuid.UUID) -> str: query: Query = select(AnswerSchema.applet_history_id) query = query.where(AnswerSchema.applet_id == applet_id) @@ -513,6 +552,7 @@ async def get_applet_user_answer_items( AnswerItemSchema.answer, AnswerItemSchema.events, AnswerItemSchema.identifier, + AnswerItemSchema.migrated_data, ) .select_from(AnswerSchema) .join( @@ -603,3 +643,32 @@ async def update_encrypted_fields( ) await self._execute(query) + + async def is_single_report_flow(self, answer_flow_id: str | None) -> bool: + query: Query = select(ActivityFlowHistoriesSchema) + query = query.where( + ActivityFlowHistoriesSchema.id_version == answer_flow_id + ) + db_result = await self._execute(query) + db_result = db_result.first() + flow_history_schema = ( + db_result[0] if db_result else None + ) # type: ActivityFlowHistoriesSchema | None + if not flow_history_schema: + return False + return flow_history_schema.is_single_report + + async def get_last_activity( + self, respondent_ids: list[uuid.UUID], applet_id: uuid.UUID | None + ) -> dict[uuid.UUID, datetime.datetime]: + query: Query = ( + select( + AnswerSchema.respondent_id, func.max(AnswerSchema.created_at) + ) + .group_by(AnswerSchema.respondent_id) + .where(AnswerSchema.respondent_id.in_(respondent_ids)) + ) + if applet_id: + query = query.where(AnswerSchema.applet_id == applet_id) + result = await self._execute(query) + return {t[0]: t[1] for t in result.all()} diff --git a/src/apps/answers/crud/assessment_crud.py b/src/apps/answers/crud/assessment_crud.py new file mode 100644 index 00000000000..5103cea7eee --- /dev/null +++ b/src/apps/answers/crud/assessment_crud.py @@ -0,0 +1,62 @@ +import uuid + +from sqlalchemy import select +from sqlalchemy.orm import Query + +from apps.activities.db.schemas import ActivityHistorySchema, ActivitySchema +from apps.answers.crud.answer_items import AnswerItemsCRUD +from apps.answers.db.schemas import AnswerItemSchema, AnswerSchema + + +class AssessmentCRUD(AnswerItemsCRUD): + async def get_all_assessments_data( + self, + ) -> list[tuple[AnswerItemSchema, uuid.UUID, str]]: + query: Query = select( + AnswerItemSchema, AnswerSchema.applet_id, AnswerSchema.version + ) + query = query.join( + AnswerSchema, AnswerSchema.id == AnswerItemSchema.answer_id + ) + query = query.where(AnswerItemSchema.is_assessment.is_(True)) + result = await self._execute(query) + return result.all() # noqa + + async def _get_assessment_by_applet( + self, applet_id: uuid.UUID + ) -> uuid.UUID | None: + query: Query = select(ActivitySchema.id) + query = query.where( + ActivitySchema.applet_id == applet_id, + ActivitySchema.is_reviewable.is_(True), + ) + result = await self._execute(query) + return result.scalars().first() + + async def _check_activity_version(self, id_version: str) -> bool: + query: Query = select(ActivityHistorySchema) + query = query.where(ActivityHistorySchema.id_version == id_version) + result = await self._execute(query) + schema: ActivityHistorySchema = result.scalars().first() + if not schema: + return False + return schema.is_reviewable + + async def get_updated_assessment( + self, answer_data: list[tuple[AnswerItemSchema, uuid.UUID, str]] + ) -> list[AnswerSchema]: + answers = [] + for data in answer_data: + answer, applet_id, version = data + activity_id = await self._get_assessment_by_applet(applet_id) + activity_id_version = f"{activity_id}_{version}" + is_valid = await self._check_activity_version(activity_id_version) + if not is_valid: + print( + f"Assessment version {activity_id_version} does not exist" + ) + continue + answer.assessment_activity_id = activity_id_version + print(f"{answer.id=} {applet_id=} {activity_id_version}") + answers.append(answer) + return answers diff --git a/src/apps/answers/db/schemas.py b/src/apps/answers/db/schemas.py index 0d24c885e4a..144f2409c15 100644 --- a/src/apps/answers/db/schemas.py +++ b/src/apps/answers/db/schemas.py @@ -59,3 +59,4 @@ class AnswerItemSchema(Base): local_end_time = Column(Time, nullable=True) is_assessment = Column(Boolean()) migrated_data = Column(JSONB()) + assessment_activity_id = Column(Text(), nullable=True, index=True) diff --git a/src/apps/answers/deps/preprocess_arbitrary.py b/src/apps/answers/deps/preprocess_arbitrary.py index 059e2b44479..21c0d675aee 100644 --- a/src/apps/answers/deps/preprocess_arbitrary.py +++ b/src/apps/answers/deps/preprocess_arbitrary.py @@ -28,12 +28,21 @@ async def get_arbitrary_info( return None +async def get_arbitraries_map( + applet_ids: list[uuid.UUID], session: AsyncSession +) -> dict[str | None, list[uuid.UUID]]: + """Returning map {"arbitrary_uri": [applet_ids]}""" + return await WorkspaceService(session, uuid.uuid4()).get_arbitraries_map( + applet_ids + ) + + async def preprocess_arbitrary_url( applet_id: uuid.UUID | None = None, schema: ArbitraryPreprocessor | None = None, session=Depends(get_session), ) -> Optional[str]: - if schema: + if schema and schema.applet_id: return await get_arbitrary_info(schema.applet_id, session) elif applet_id: return await get_arbitrary_info(applet_id, session) @@ -50,3 +59,23 @@ async def get_answer_session(url=Depends(preprocess_arbitrary_url)): yield session else: yield None + + +async def get_answer_session_by_owner_id( + owner_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +): + service = WorkspaceService(session, uuid.uuid4()) + server_info = await service.get_arbitrary_info_by_owner_id(owner_id) + if server_info and server_info.use_arbitrary: + url = server_info.database_uri + session_maker = session_manager.get_session(url) if url else None + if settings.env == "testing": + yield session_maker + elif session_maker: + async with session_maker() as session: + yield session + else: + yield None + else: + yield None diff --git a/src/apps/answers/domain/answers.py b/src/apps/answers/domain/answers.py index 748de1cb393..49a96a9f401 100644 --- a/src/apps/answers/domain/answers.py +++ b/src/apps/answers/domain/answers.py @@ -55,7 +55,7 @@ class Slider(InternalModel): class ItemAnswerCreate(InternalModel): answer: str | None events: str | None - item_ids: list[uuid.UUID] | None + item_ids: list[uuid.UUID] identifier: str | None scheduled_time: datetime.datetime | None start_time: datetime.datetime @@ -112,6 +112,7 @@ class AssessmentAnswerCreate(InternalModel): answer: str item_ids: list[uuid.UUID] reviewer_public_key: str + assessment_version_id: str class AnswerDate(InternalModel): @@ -180,7 +181,11 @@ class AssessmentAnswer(InternalModel): answer: str | None item_ids: list[str] = Field(default_factory=list) items: list[PublicActivityItemFull] = Field(default_factory=list) + items_last: list[PublicActivityItemFull] | None = Field( + default_factory=list + ) is_edited: bool = False + versions: list[str] = [] class Reviewer(InternalModel): @@ -235,6 +240,10 @@ class AssessmentAnswerPublic(PublicModel): answer: str | None item_ids: list[str] = Field(default_factory=list) items: list[PublicActivityItemFull] = Field(default_factory=list) + items_last: list[PublicActivityItemFull] | None = Field( + default_factory=list + ) + versions: list[str] class AnswerNote(InternalModel): @@ -446,3 +455,4 @@ class AnswerItemDataEncrypted(InternalModel): class UserAnswerItemData(AnswerItemDataEncrypted): user_public_key: str + migrated_data: dict | None diff --git a/src/apps/answers/errors.py b/src/apps/answers/errors.py index f35970c5083..d35cdb4f575 100644 --- a/src/apps/answers/errors.py +++ b/src/apps/answers/errors.py @@ -60,6 +60,7 @@ class ActivityIsNotAssessment(ValidationError): class ReportServerError(ValidationError): + message_is_template: bool = True message = _("Report server error {message}.") diff --git a/src/apps/answers/filters.py b/src/apps/answers/filters.py index 70220eddb05..a9a224fd143 100644 --- a/src/apps/answers/filters.py +++ b/src/apps/answers/filters.py @@ -33,7 +33,8 @@ class AppletSubmitDateFilter(BaseQueryParams): class AnswerExportFilters(BaseQueryParams): respondent_ids: list[uuid.UUID] | None = Field(Query(None)) - from_date: datetime.date | None = None + from_date: datetime.datetime | None = None + to_date: datetime.datetime | None = None limit: int = 10000 diff --git a/src/apps/answers/fixtures/arbitrary_server_answers.json b/src/apps/answers/fixtures/arbitrary_server_answers.json index 4fcef36a7a4..49354931289 100644 --- a/src/apps/answers/fixtures/arbitrary_server_answers.json +++ b/src/apps/answers/fixtures/arbitrary_server_answers.json @@ -1,20 +1,20 @@ [ { - "table": "users", - "fields": { - "id": "6cde911e-8a57-47c0-b6b2-685b3664f418", - "email": "5c88e00db1ec7020e8a0fd0f1dd225ff93e9088821d554d5fc437216", - "first_name": "PWrlro9d6WB767UVp43dNg==", - "last_name": "USpl8mH9r0f1/ffBdCO/jw==", - "hashed_password": "$2b$12$sU9Wy1Qo.JokoEyY9PCLeeAQvejVV5j0xW0Mwxwr2VempxeRJejNy", - "is_deleted": false, - "is_super_admin": false, - "created_at": "2023-08-07T12:39:38.111", - "updated_at": "2023-08-11T10:32:41.603", - "last_seen_at": "2023-08-11T10:32:41.602", - "is_anonymous_respondent": false, - "email_encrypted": "mWykF1OYv6Z/KkYLEA8lAbt7GQqBX7Xy57II/r9DqhM=" - }, + "table": "users", + "fields": { + "id": "6cde911e-8a57-47c0-b6b2-685b3664f418", + "email": "5c88e00db1ec7020e8a0fd0f1dd225ff93e9088821d554d5fc437216", + "first_name": "PWrlro9d6WB767UVp43dNg==", + "last_name": "USpl8mH9r0f1/ffBdCO/jw==", + "hashed_password": "$2b$12$sU9Wy1Qo.JokoEyY9PCLeeAQvejVV5j0xW0Mwxwr2VempxeRJejNy", + "is_deleted": false, + "is_super_admin": false, + "created_at": "2023-08-07T12:39:38.111", + "updated_at": "2023-08-11T10:32:41.603", + "last_seen_at": "2023-08-11T10:32:41.602", + "is_anonymous_respondent": false, + "email_encrypted": "mWykF1OYv6Z/KkYLEA8lAbt7GQqBX7Xy57II/r9DqhM=" + }, "note": { "Password": "Test1234!", "plain_email": "ivan@mindlogger.com", @@ -78,12 +78,12 @@ "user_id": "6cde911e-8a57-47c0-b6b2-685b3664f418", "workspace_name": "eRPtCowGI0bcq4BEzB3lGA==", "is_modified": false, - "database_uri": "postgresql+asyncpg://postgres:postgres@localhost:5432/test_arbitrary", - "storage_type": "key", - "storage_access_key": "key", - "storage_secret_key": "DefaultEndpointsProtocol=https;AccountName=youraccountname;AccountKey=youraccountkey;EndpointSuffix=core.windows.net", - "storage_region": "region", - "storage_url": "url", + "database_uri": "ehwQOnQu9X8higAEDMzDhH9HB5a0jO4FsgU/awOCwMgFccefd2//Ek6e0bKAwvauf6pwAS+PWDChFns1WIyL/z4oH52gEIKpYsBjUmznqYc=", + "storage_type": "gEkn6yn8U2jb0U+Ln1KSAw==", + "storage_access_key": "gEkn6yn8U2jb0U+Ln1KSAw==", + "storage_secret_key": "ww3Y0xCDnjCMQk2Kbmzoh0PGFmo4B1FGaaO7w7h0cUDoLgzFgpIJyoko/xTilJzIMINMa1j4V722KLW1NugxGkjqb+f9bWXJ2ytObwgbw8z6RbqZsJNOp6GFzT+az9Wpq7IW82Lyjok7ctS7R63oGTdDXbOpKzIJtu0/YAGCaNE=", + "storage_region": "5yruV8O3eukVP4fK/A5M6g==", + "storage_url": "KJ3yVM/QBmlmDQXx3CuuTw==", "use_arbitrary": true }, "note": { @@ -91,40 +91,43 @@ } }, { - "table": "applets", - "fields": { - "id": "92917a56-d586-4613-b7aa-991f2c4b15b8", - "encryption": { - "public_key": "-----BEGIN PUBLIC KEY-----\nMIGaMFMGCSqGSIb3DQEDATBGAkEA5PPjxBOfS6rv0AL2HtIV02JaqYTyjmbU26uu ...", - "prime": "11991225580616300198418736930805539013218516911726243131907471503172221906921856236853325120086286157687782051503487707931996762114546244981907661201581863", - "base": "2", - "account_id": "2" - }, - "created_at": "2023-01-05T15:49:51.752113", - "updated_at": "2023-01-05T15:49:51.752113", - "is_deleted": false, - "is_published": false, - "display_name": "Applet 1", - "description": { - "en": "Patient Health Questionnaire" - }, - "about": { - "en": "Patient Health Questionnaire" + "table": "applets", + "fields": { + "id": "92917a56-d586-4613-b7aa-991f2c4b15b8", + "encryption": { + "public_key": "-----BEGIN PUBLIC KEY-----\nMIGaMFMGCSqGSIb3DQEDATBGAkEA5PPjxBOfS6rv0AL2HtIV02JaqYTyjmbU26uu ...", + "prime": "11991225580616300198418736930805539013218516911726243131907471503172221906921856236853325120086286157687782051503487707931996762114546244981907661201581863", + "base": "2", + "account_id": "2" + }, + "created_at": "2023-01-05T15:49:51.752113", + "updated_at": "2023-01-05T15:49:51.752113", + "is_deleted": false, + "is_published": false, + "display_name": "Applet 1", + "description": { + "en": "Patient Health Questionnaire" + }, + "about": { + "en": "Patient Health Questionnaire" + }, + "image": "", + "watermark": "", + "theme_id": "3e31a64e-449f-4788-8516-eca7809f1a41", + "version": "1.1.0", + "report_server_ip": "https://report-server.l.trackmage.com", + "report_public_key": "-----BEGIN PUBLIC KEY-----\nMII ...", + "report_recipients": [ + "tom@mindlogger.com", + "lucy@mindlogger.com" + ], + "report_include_user_id": false, + "report_include_case_id": false, + "report_email_body": "", + "link": "51857e10-6c05-4fa8-a2c8-725b8c1a0aa6", + "require_login": false }, - "image": "", - "watermark": "", - "theme_id": "3e31a64e-449f-4788-8516-eca7809f1a41", - "version": "1.1.0", - "report_server_ip": "https://report-server.l.trackmage.com", - "report_public_key": "-----BEGIN PUBLIC KEY-----\nMII ...", - "report_recipients": ["tom@mindlogger.com", "lucy@mindlogger.com"], - "report_include_user_id": false, - "report_include_case_id": false, - "report_email_body": "", - "link": "51857e10-6c05-4fa8-a2c8-725b8c1a0aa6", - "require_login": false - }, - "private_key": "private key" + "private_key": "private key" }, { "table": "user_applet_accesses", @@ -142,7 +145,8 @@ "meta": { "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a674781", "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674781" - } + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" } }, { @@ -180,7 +184,8 @@ "meta": { "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a674781", "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674781" - } + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" } }, { @@ -200,15 +205,18 @@ "theme_id": null, "version": "1.1.0", "user_id": "6cde911e-8a57-47c0-b6b2-685b3664f418", - "report_server_ip": "", - "report_public_key": "", - "report_recipients": [], + "report_server_ip": "https://report-server.l.trackmage.com", + "report_public_key": "-----BEGIN PUBLIC KEY-----\nMIIIIjANBgkqhkiG9w0BAQEFAAOCCA8AMIIICgKCCAEA0Ooez1FjFM1kW8YG9k81\nLCZBEVg7fK6qAv6PN/VC5xfdafQfi704Yvv2FaLQ1blH531eb89QjjWksmUxTCrL\nRKy3QQAerQp7kegdWHo3q0co6WFFjIRtt7MFxGIT5y4ZBeT0Jm6fHy8SXJCheRwB\ni0BqBRt3k5pvlYHV4u3tnUen4Neyn8r5NgYMQc77ZDD6wcIw0L7XnPbNB7WHP5w9\n0VfnnUqqeGLoiy2mTx/3kv7EKalUBBUSCY5qcbsmNr0x91/hSsnSeICREuKcpW1c\nbAsarFSyiRLkZEBh4FrdKfp9+ZLUqrejWltSdlkqY9ih8DtiaXEvJLjQp6V2aYrQ\nMZBYZdrg8c5/UI3yhSioB0TYxkI9lgGyTSrhqRjRtzQ+MEJ7cV0BWqji5BOO3OJ9\nrGC2Mb+3T6EzhNB6HqdRDfThJqDeThPtT/zEu5OU0UuW9NiEqP4twz2IEYVOCo/B\nWK+H5wgHkkuDsI06Z9ngxp0KhmMjGxrfHNLZ4r8mr4LyPxcASqxJ4rGRBO5EIRfs\niyo9dcp/BSJrGhZyB9vlY87qciuswjtjcsAFgVHqvYZYSmm3I+vJXUgPHoPOmbfp\nyoLwjuwiouKnh1W5NvTfZfjuP3XDRag++ZjQknZU5obybn+XvNMsyu9JZ8UWrIPt\nnK1k+PUa6wdHC8ecE/RF/utSaFDE9W+5wwd92PXvJhRcPA2y5ujvqqeFtk8VPSse\n7RQswjORJdkLwLNkSNWq8vAIlhYjcrhWlvYv29LxoE1WmvtgDNQAJbhHUqh1sFTY\nktTxq2mLsoPMhAnvAla3PnTSUSgpiLlJRBU43TO7oj3IQSurGE3NprvjToOwV4Lp\nLJe9xhcZNNVqlN01y5F0ULFz3bk0yuT3oq/9uKjf+OsxkzdLmhA5lTdsCutPJakh\nQ2gTGl9c+h1i1u+RL8NaU5D6yrDuU1VuMApdbp1vW/vah/Qw3KdXky23akEcFG5d\n+ffS82AYlDvT+6uUFGLRc5Ui4i2WEtQjr6wzXTLVI1tRj6s0sNSJ7Bx3euVxspYN\n6B0vCndvXQBm2rQKf49bgitNvtiUhaNBX1H9GzneFF3x7MSYfhN3WqR7QcdkEon5\nESGOe0W0/Fh9yM1R8yOnWGsN9FgtHSD1vHAc1s3SEhGwz83bCsao1quG3dnR7DNs\nufEfy4jscOw6UKSCjywALOXL7cpmJdyQROSBt2dCBFs8JyHdUUVOQAxzFdUptSY1\n3jNI1b6nXkYLYCyB5Ms0ux8W6AEcmqt/H0g2hZ+hKhQDe5kn+9BYgGydZs5w2gfo\nGlw/EJbE/xZIQSUMWb3n5jxvbnIF7puZbWwFr0ScN/HHtRAvp7erTmqZklrhqcbQ\nfIqUeih+R3WeglHqJwu4dmR5rQkUHniwAVPU1jSe7xYj9nanfv5g84md/TafypLp\nKY3Bjp5NzJP/EgGuEfWyBn4ORaCFmeyJYNIg8tGBV+/H9yr/+fO/Wx0KUTPOEOuY\nTThDLvlU162oV7qOO0fxi84y+z1emC1YyAXfYI2waDBg+iX4mqQSmz1xC35xn4cs\ngt+5dPcO/O4+/aPcQQET9BS/n9Xvot1RzulCs8A3XJz1lrIFhqndn1U3f+z4b1lu\nOudeN7LXjTPRivpEgXnvId+EHyO72fqTb/A/nUFyKxEq0tdC8n3BCUsXlXlx4ZUf\nNx0iImsrZFKr8IFoDNEXvbs+y9++h2W44eJdJvRJZjrkMOJhAIA9DaK3XJl914ck\n1/0jebCxk5ldby+beNSLi4LoFa/+HvLlbwgf9pgpSPSqOFoXmVSRNtjRPXO7yPDN\nvkMjO5xLrjNBE+LD60eJECwauaf7kOoyjNS8+PfYtHJx+An8IJ0yOnc7JeJcl5sC\n7yFq4260B59aIU3ZbFcbQ7rxSK5+7MAxpXlt/oVxz+/lONBiUYJ0nW52LElm9Vhg\nuHF21dExrQ+8HnG1eJhpx1vjDJyWXbyhZVHx9ebrYKjpQIHA/6sGsZG0OLV+TP2D\n8l/slQg2HpfjmmghDD/HDyeW/gnT528uDnYDAx0Ob/hupwva6N9IvldLHQd3RVlJ\nfka4jX2GZvjdpUywGy1dW++kS59Ms7nrt5P0/y2chg029d3ciHSufGTHhcdPRK3y\n1RdAIJtL+VCNR8/4CT6LLUHj+9eCcwww5xHDVZ63QhMvIPNn/DIUS48UcKM+hoFn\nniQBYMcWlP0mOV6hIbokwiXot/KjExBS8NE79XIwcKiejW8j4BId+oGL9etFm6dG\nL1mCL1FAhwpZoeOhTmhMNERH4hFHnTlYYnaHqRm8B5noCj245qIT/VsqCKW0NICi\nlmER7MOqPKvr6aUnC+8F5pYw0GeuoKWTOmVTO13fmhmV79bumaIMkZD1klZUkhJl\nqGvLj9ISJWu9vttpFOB2MLijDV5Sa9LcQYDEKCw9dqdG5BHs4uy+xtVRwA/oeJKX\ngSvzaQGJoffpFGhi5Jj26nyF8R19EtqVOKzRoxWoc9EfKPoWNEToR7ejT6LyomMx\nKtFZJ0cGgmUby2QjUpkq7T5TW9P29b8fBNAQ8bSYiV7PKh1gmk/IjTesPjtt0jiv\nKgowXEvK5IkXkSFbGtbmGvbQn5CwtNyMkv/TPhUmiVu8LRdwfO0Rr7JbaTLvymIQ\nma6ZATMyhNcnkCNgqJqAj85IT78hqqKGYQ7nGyPw2H7utErWFcSwLUwBAok/qOeb\nW2EFUykoTbF2EHjXjAO5b60CAwEAAQ==\n-----END PUBLIC KEY-----", + "report_recipients": [ + "tom@cmiml.net" + ], "report_include_user_id": false, "report_include_case_id": false, "report_email_body": "", "id_version": "92917a56-d586-4613-b7aa-991f2c4b15b8_1.1.0", "id": "92917a56-d586-4613-b7aa-991f2c4b15b8", - "display_name": "Applet0" + "display_name": "Applet0", + "stream_enabled": false }, "private_key": "" }, @@ -262,6 +270,27 @@ "extra_fields": {} } }, + { + "table": "activities", + "fields": { + "id": "09e3dbf0-aefb-4d0e-9177-bdb321bf3621", + "created_at": "2023-01-05T15:49:51.752113", + "updated_at": "2023-01-05T15:49:51.752113", + "applet_id": "92917a56-d586-4613-b7aa-991f2c4b15b8", + "name": "PHQ3", + "description": { + "en": "PHQ2", + "fr": "PHQ2" + }, + "splash_screen": "", + "image": "", + "show_all_at_once": false, + "is_skippable": false, + "is_reviewable": true, + "response_is_editable": false, + "order": 1.0 + } + }, { "table": "activity_items", "fields": { @@ -295,7 +324,7 @@ "color": null, "image": null, "score": null, - "value": 0, + "value": 1, "tooltip": null, "is_hidden": false } @@ -379,7 +408,7 @@ "color": null, "image": null, "score": null, - "value": 0, + "value": 1, "tooltip": null, "is_hidden": false } @@ -496,7 +525,7 @@ "reports": [ { "type": "score", - "name":"Score 1", + "name": "Score 1", "id": "score_1", "calculation_type": "sum" } @@ -570,7 +599,7 @@ "reports": [ { "type": "score", - "name":"Score 1", + "name": "Score 1", "id": "score_1", "calculation_type": "sum" } @@ -578,7 +607,7 @@ } } }, - { + { "table": "activity_item_histories", "fields": { "id": "a18d3409-2c96-4a5e-a1f3-1c1c14be0018", @@ -601,7 +630,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d3", @@ -610,7 +640,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d4", @@ -619,7 +650,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d5", @@ -628,7 +660,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -649,8 +682,114 @@ "order": 3, "id_version": "a18d3409-2c96-4a5e-a1f3-1c1c14be0018_1.1.0" } + }, + { + "table": "activity_histories", + "fields": { + "id": "09e3dbf0-aefb-4d0e-9177-bdb321bf3621", + "created_at": "2023-01-05T15:49:51.752113", + "updated_at": "2023-01-05T15:49:51.752113", + "is_deleted": false, + "applet_id": "92917a56-d586-4613-b7aa-991f2c4b15b8_1.1.0", + "name": "PHQ3", + "description": { + "en": "PHQ2", + "fr": "PHQ2" + }, + "splash_screen": "", + "image": "", + "show_all_at_once": false, + "is_skippable": false, + "is_reviewable": true, + "response_is_editable": false, + "order": 1.0, + "id_version": "09e3dbf0-aefb-4d0e-9177-bdb321bf3621_1.1.0", + "scores_and_reports": { + "generate_report": true, + "scores": [ + { + "name": "Score 1", + "id": "score_1", + "calculation_type": "sum" + } + ] + } + } + }, + { + "table": "activity_item_histories", + "fields": { + "id": "a18d3409-2c96-4a5e-a1f3-1c1c14be0021", + "name": "a18d3409_2c96_4a5e_a1f3_1c1c14be0021", + "created_at": "2023-01-05T15:49:51.752113", + "updated_at": "2023-01-05T15:49:51.752113", + "is_deleted": false, + "activity_id": "09e3dbf0-aefb-4d0e-9177-bdb321bf3621_1.1.0", + "question": { + "en": "Little interest or pleasure in doing things?", + "fr": "Peu dintérêt ou de plaisir à faire les choses ?" + }, + "response_type": "singleSelect", + "response_values": { + "options": [ + { + "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d2", + "text": "Not at all", + "image": "domain.com/image.jpg", + "score": null, + "tooltip": null, + "is_hidden": false, + "color": null, + "value": 0 + }, + { + "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d3", + "text": "Several days", + "image": "domain.com/image.jpg", + "score": null, + "tooltip": null, + "is_hidden": false, + "color": null, + "value": 1 + }, + { + "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d4", + "text": "More than half the days", + "image": "domain.com/image.jpg", + "score": null, + "tooltip": null, + "is_hidden": false, + "color": null, + "value": 2 + }, + { + "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d5", + "text": "Nearly every day", + "image": "domain.com/image.jpg", + "score": null, + "tooltip": null, + "is_hidden": false, + "color": null, + "value": 3 + } + ] + }, + "config": { + "randomize_options": false, + "timer": 0, + "add_scores": false, + "set_alerts": false, + "add_tooltip": false, + "set_palette": false, + "remove_back_button": false, + "skippable_item": false, + "additional_response_option": { + "text_input_option": false, + "text_input_required": false + } + }, + "order": 1, + "id_version": "a18d3409-2c96-4a5e-a1f3-1c1c14be0021_1.1.0" + } } - ] - - diff --git a/src/apps/answers/fixtures/duplicate_activity_in_flow.json b/src/apps/answers/fixtures/duplicate_activity_in_flow.json index d6731bae086..d7820a9af85 100644 --- a/src/apps/answers/fixtures/duplicate_activity_in_flow.json +++ b/src/apps/answers/fixtures/duplicate_activity_in_flow.json @@ -27,7 +27,7 @@ "id": "92917a56-d586-4613-b7aa-991f2c4b15b1", "encryption": { "public_key": "-----BEGIN PUBLIC KEY-----\\nMIGaMFMGCSqGSIb3DQEDATBGAkEA5PPjxBOfS6rv0AL2HtIV02JaqYTyjmbU26uu\\nmNFZ5prTeFFQFredCBQAga/QGH2HroqvpwZNUVSmzpWcYrenJwIBAgNDAAJAOiaT\\n/gCKcJCHACxuxHpJjDFda/F6U2O2JwXw3SFspUlM1ZmnlBf5Wnyb2xZXd72EFEhW\\n6FmRTZzQZoaPRXWP7w==\\n-----END PUBLIC KEY-----\\n", - "prime":"11991225580616300198418736930805539013218516911726243131907471503172221906921856236853325120086286157687782051503487707931996762114546244981907661201581863", + "prime": "11991225580616300198418736930805539013218516911726243131907471503172221906921856236853325120086286157687782051503487707931996762114546244981907661201581863", "base": "2", "account_id": "2" }, @@ -48,7 +48,10 @@ "version": "1.0.0", "report_server_ip": "https://report-server.l.trackmage.com", "report_public_key": "-----BEGIN PUBLIC KEY-----\nMIIIIjANBgkqhkiG9w0BAQEFAAOCCA8AMIIICgKCCAEA0Ooez1FjFM1kW8YG9k81\nLCZBEVg7fK6qAv6PN/VC5xfdafQfi704Yvv2FaLQ1blH531eb89QjjWksmUxTCrL\nRKy3QQAerQp7kegdWHo3q0co6WFFjIRtt7MFxGIT5y4ZBeT0Jm6fHy8SXJCheRwB\ni0BqBRt3k5pvlYHV4u3tnUen4Neyn8r5NgYMQc77ZDD6wcIw0L7XnPbNB7WHP5w9\n0VfnnUqqeGLoiy2mTx/3kv7EKalUBBUSCY5qcbsmNr0x91/hSsnSeICREuKcpW1c\nbAsarFSyiRLkZEBh4FrdKfp9+ZLUqrejWltSdlkqY9ih8DtiaXEvJLjQp6V2aYrQ\nMZBYZdrg8c5/UI3yhSioB0TYxkI9lgGyTSrhqRjRtzQ+MEJ7cV0BWqji5BOO3OJ9\nrGC2Mb+3T6EzhNB6HqdRDfThJqDeThPtT/zEu5OU0UuW9NiEqP4twz2IEYVOCo/B\nWK+H5wgHkkuDsI06Z9ngxp0KhmMjGxrfHNLZ4r8mr4LyPxcASqxJ4rGRBO5EIRfs\niyo9dcp/BSJrGhZyB9vlY87qciuswjtjcsAFgVHqvYZYSmm3I+vJXUgPHoPOmbfp\nyoLwjuwiouKnh1W5NvTfZfjuP3XDRag++ZjQknZU5obybn+XvNMsyu9JZ8UWrIPt\nnK1k+PUa6wdHC8ecE/RF/utSaFDE9W+5wwd92PXvJhRcPA2y5ujvqqeFtk8VPSse\n7RQswjORJdkLwLNkSNWq8vAIlhYjcrhWlvYv29LxoE1WmvtgDNQAJbhHUqh1sFTY\nktTxq2mLsoPMhAnvAla3PnTSUSgpiLlJRBU43TO7oj3IQSurGE3NprvjToOwV4Lp\nLJe9xhcZNNVqlN01y5F0ULFz3bk0yuT3oq/9uKjf+OsxkzdLmhA5lTdsCutPJakh\nQ2gTGl9c+h1i1u+RL8NaU5D6yrDuU1VuMApdbp1vW/vah/Qw3KdXky23akEcFG5d\n+ffS82AYlDvT+6uUFGLRc5Ui4i2WEtQjr6wzXTLVI1tRj6s0sNSJ7Bx3euVxspYN\n6B0vCndvXQBm2rQKf49bgitNvtiUhaNBX1H9GzneFF3x7MSYfhN3WqR7QcdkEon5\nESGOe0W0/Fh9yM1R8yOnWGsN9FgtHSD1vHAc1s3SEhGwz83bCsao1quG3dnR7DNs\nufEfy4jscOw6UKSCjywALOXL7cpmJdyQROSBt2dCBFs8JyHdUUVOQAxzFdUptSY1\n3jNI1b6nXkYLYCyB5Ms0ux8W6AEcmqt/H0g2hZ+hKhQDe5kn+9BYgGydZs5w2gfo\nGlw/EJbE/xZIQSUMWb3n5jxvbnIF7puZbWwFr0ScN/HHtRAvp7erTmqZklrhqcbQ\nfIqUeih+R3WeglHqJwu4dmR5rQkUHniwAVPU1jSe7xYj9nanfv5g84md/TafypLp\nKY3Bjp5NzJP/EgGuEfWyBn4ORaCFmeyJYNIg8tGBV+/H9yr/+fO/Wx0KUTPOEOuY\nTThDLvlU162oV7qOO0fxi84y+z1emC1YyAXfYI2waDBg+iX4mqQSmz1xC35xn4cs\ngt+5dPcO/O4+/aPcQQET9BS/n9Xvot1RzulCs8A3XJz1lrIFhqndn1U3f+z4b1lu\nOudeN7LXjTPRivpEgXnvId+EHyO72fqTb/A/nUFyKxEq0tdC8n3BCUsXlXlx4ZUf\nNx0iImsrZFKr8IFoDNEXvbs+y9++h2W44eJdJvRJZjrkMOJhAIA9DaK3XJl914ck\n1/0jebCxk5ldby+beNSLi4LoFa/+HvLlbwgf9pgpSPSqOFoXmVSRNtjRPXO7yPDN\nvkMjO5xLrjNBE+LD60eJECwauaf7kOoyjNS8+PfYtHJx+An8IJ0yOnc7JeJcl5sC\n7yFq4260B59aIU3ZbFcbQ7rxSK5+7MAxpXlt/oVxz+/lONBiUYJ0nW52LElm9Vhg\nuHF21dExrQ+8HnG1eJhpx1vjDJyWXbyhZVHx9ebrYKjpQIHA/6sGsZG0OLV+TP2D\n8l/slQg2HpfjmmghDD/HDyeW/gnT528uDnYDAx0Ob/hupwva6N9IvldLHQd3RVlJ\nfka4jX2GZvjdpUywGy1dW++kS59Ms7nrt5P0/y2chg029d3ciHSufGTHhcdPRK3y\n1RdAIJtL+VCNR8/4CT6LLUHj+9eCcwww5xHDVZ63QhMvIPNn/DIUS48UcKM+hoFn\nniQBYMcWlP0mOV6hIbokwiXot/KjExBS8NE79XIwcKiejW8j4BId+oGL9etFm6dG\nL1mCL1FAhwpZoeOhTmhMNERH4hFHnTlYYnaHqRm8B5noCj245qIT/VsqCKW0NICi\nlmER7MOqPKvr6aUnC+8F5pYw0GeuoKWTOmVTO13fmhmV79bumaIMkZD1klZUkhJl\nqGvLj9ISJWu9vttpFOB2MLijDV5Sa9LcQYDEKCw9dqdG5BHs4uy+xtVRwA/oeJKX\ngSvzaQGJoffpFGhi5Jj26nyF8R19EtqVOKzRoxWoc9EfKPoWNEToR7ejT6LyomMx\nKtFZJ0cGgmUby2QjUpkq7T5TW9P29b8fBNAQ8bSYiV7PKh1gmk/IjTesPjtt0jiv\nKgowXEvK5IkXkSFbGtbmGvbQn5CwtNyMkv/TPhUmiVu8LRdwfO0Rr7JbaTLvymIQ\nma6ZATMyhNcnkCNgqJqAj85IT78hqqKGYQ7nGyPw2H7utErWFcSwLUwBAok/qOeb\nW2EFUykoTbF2EHjXjAO5b60CAwEAAQ==\n-----END PUBLIC KEY-----", - "report_recipients": ["tom@mindlogger.com", "lucy@mindlogger.com"], + "report_recipients": [ + "tom@mindlogger.com", + "lucy@mindlogger.com" + ], "report_include_user_id": false, "report_include_case_id": false, "report_email_body": "", @@ -76,7 +79,9 @@ "user_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", "report_server_ip": "https://report-server.l.trackmage.com", "report_public_key": "-----BEGIN PUBLIC KEY-----\nMIIIIjANBgkqhkiG9w0BAQEFAAOCCA8AMIIICgKCCAEA0Ooez1FjFM1kW8YG9k81\nLCZBEVg7fK6qAv6PN/VC5xfdafQfi704Yvv2FaLQ1blH531eb89QjjWksmUxTCrL\nRKy3QQAerQp7kegdWHo3q0co6WFFjIRtt7MFxGIT5y4ZBeT0Jm6fHy8SXJCheRwB\ni0BqBRt3k5pvlYHV4u3tnUen4Neyn8r5NgYMQc77ZDD6wcIw0L7XnPbNB7WHP5w9\n0VfnnUqqeGLoiy2mTx/3kv7EKalUBBUSCY5qcbsmNr0x91/hSsnSeICREuKcpW1c\nbAsarFSyiRLkZEBh4FrdKfp9+ZLUqrejWltSdlkqY9ih8DtiaXEvJLjQp6V2aYrQ\nMZBYZdrg8c5/UI3yhSioB0TYxkI9lgGyTSrhqRjRtzQ+MEJ7cV0BWqji5BOO3OJ9\nrGC2Mb+3T6EzhNB6HqdRDfThJqDeThPtT/zEu5OU0UuW9NiEqP4twz2IEYVOCo/B\nWK+H5wgHkkuDsI06Z9ngxp0KhmMjGxrfHNLZ4r8mr4LyPxcASqxJ4rGRBO5EIRfs\niyo9dcp/BSJrGhZyB9vlY87qciuswjtjcsAFgVHqvYZYSmm3I+vJXUgPHoPOmbfp\nyoLwjuwiouKnh1W5NvTfZfjuP3XDRag++ZjQknZU5obybn+XvNMsyu9JZ8UWrIPt\nnK1k+PUa6wdHC8ecE/RF/utSaFDE9W+5wwd92PXvJhRcPA2y5ujvqqeFtk8VPSse\n7RQswjORJdkLwLNkSNWq8vAIlhYjcrhWlvYv29LxoE1WmvtgDNQAJbhHUqh1sFTY\nktTxq2mLsoPMhAnvAla3PnTSUSgpiLlJRBU43TO7oj3IQSurGE3NprvjToOwV4Lp\nLJe9xhcZNNVqlN01y5F0ULFz3bk0yuT3oq/9uKjf+OsxkzdLmhA5lTdsCutPJakh\nQ2gTGl9c+h1i1u+RL8NaU5D6yrDuU1VuMApdbp1vW/vah/Qw3KdXky23akEcFG5d\n+ffS82AYlDvT+6uUFGLRc5Ui4i2WEtQjr6wzXTLVI1tRj6s0sNSJ7Bx3euVxspYN\n6B0vCndvXQBm2rQKf49bgitNvtiUhaNBX1H9GzneFF3x7MSYfhN3WqR7QcdkEon5\nESGOe0W0/Fh9yM1R8yOnWGsN9FgtHSD1vHAc1s3SEhGwz83bCsao1quG3dnR7DNs\nufEfy4jscOw6UKSCjywALOXL7cpmJdyQROSBt2dCBFs8JyHdUUVOQAxzFdUptSY1\n3jNI1b6nXkYLYCyB5Ms0ux8W6AEcmqt/H0g2hZ+hKhQDe5kn+9BYgGydZs5w2gfo\nGlw/EJbE/xZIQSUMWb3n5jxvbnIF7puZbWwFr0ScN/HHtRAvp7erTmqZklrhqcbQ\nfIqUeih+R3WeglHqJwu4dmR5rQkUHniwAVPU1jSe7xYj9nanfv5g84md/TafypLp\nKY3Bjp5NzJP/EgGuEfWyBn4ORaCFmeyJYNIg8tGBV+/H9yr/+fO/Wx0KUTPOEOuY\nTThDLvlU162oV7qOO0fxi84y+z1emC1YyAXfYI2waDBg+iX4mqQSmz1xC35xn4cs\ngt+5dPcO/O4+/aPcQQET9BS/n9Xvot1RzulCs8A3XJz1lrIFhqndn1U3f+z4b1lu\nOudeN7LXjTPRivpEgXnvId+EHyO72fqTb/A/nUFyKxEq0tdC8n3BCUsXlXlx4ZUf\nNx0iImsrZFKr8IFoDNEXvbs+y9++h2W44eJdJvRJZjrkMOJhAIA9DaK3XJl914ck\n1/0jebCxk5ldby+beNSLi4LoFa/+HvLlbwgf9pgpSPSqOFoXmVSRNtjRPXO7yPDN\nvkMjO5xLrjNBE+LD60eJECwauaf7kOoyjNS8+PfYtHJx+An8IJ0yOnc7JeJcl5sC\n7yFq4260B59aIU3ZbFcbQ7rxSK5+7MAxpXlt/oVxz+/lONBiUYJ0nW52LElm9Vhg\nuHF21dExrQ+8HnG1eJhpx1vjDJyWXbyhZVHx9ebrYKjpQIHA/6sGsZG0OLV+TP2D\n8l/slQg2HpfjmmghDD/HDyeW/gnT528uDnYDAx0Ob/hupwva6N9IvldLHQd3RVlJ\nfka4jX2GZvjdpUywGy1dW++kS59Ms7nrt5P0/y2chg029d3ciHSufGTHhcdPRK3y\n1RdAIJtL+VCNR8/4CT6LLUHj+9eCcwww5xHDVZ63QhMvIPNn/DIUS48UcKM+hoFn\nniQBYMcWlP0mOV6hIbokwiXot/KjExBS8NE79XIwcKiejW8j4BId+oGL9etFm6dG\nL1mCL1FAhwpZoeOhTmhMNERH4hFHnTlYYnaHqRm8B5noCj245qIT/VsqCKW0NICi\nlmER7MOqPKvr6aUnC+8F5pYw0GeuoKWTOmVTO13fmhmV79bumaIMkZD1klZUkhJl\nqGvLj9ISJWu9vttpFOB2MLijDV5Sa9LcQYDEKCw9dqdG5BHs4uy+xtVRwA/oeJKX\ngSvzaQGJoffpFGhi5Jj26nyF8R19EtqVOKzRoxWoc9EfKPoWNEToR7ejT6LyomMx\nKtFZJ0cGgmUby2QjUpkq7T5TW9P29b8fBNAQ8bSYiV7PKh1gmk/IjTesPjtt0jiv\nKgowXEvK5IkXkSFbGtbmGvbQn5CwtNyMkv/TPhUmiVu8LRdwfO0Rr7JbaTLvymIQ\nma6ZATMyhNcnkCNgqJqAj85IT78hqqKGYQ7nGyPw2H7utErWFcSwLUwBAok/qOeb\nW2EFUykoTbF2EHjXjAO5b60CAwEAAQ==\n-----END PUBLIC KEY-----", - "report_recipients": ["tom@cmiml.net"], + "report_recipients": [ + "tom@cmiml.net" + ], "report_include_user_id": false, "report_include_case_id": false, "report_email_body": "", @@ -112,7 +117,7 @@ "reports": [ { "type": "score", - "name":"Score 1", + "name": "Score 1", "id": "score_1", "calculation_type": "sum" } @@ -177,7 +182,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d3", @@ -186,7 +192,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d4", @@ -195,7 +202,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d5", @@ -204,7 +212,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -249,7 +258,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 0 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d3", @@ -258,7 +268,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 1 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d4", @@ -267,7 +278,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 2 }, { "id": "2ba4bb83-ed1c-4140-a225-c2c9b4db66d5", @@ -276,7 +288,8 @@ "score": null, "tooltip": null, "is_hidden": false, - "color": null + "color": null, + "value": 3 } ] }, @@ -372,7 +385,8 @@ "meta": { "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a674781", "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674781" - } + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" } } ] diff --git a/src/apps/answers/router.py b/src/apps/answers/router.py index 2514ac90db8..97c450a1e0e 100644 --- a/src/apps/answers/router.py +++ b/src/apps/answers/router.py @@ -13,6 +13,7 @@ applet_answers_export, applet_completed_entities, applet_submit_date_list, + applets_completed_entities, create_anonymous_answer, create_answer, note_add, @@ -238,6 +239,16 @@ }, )(applet_completed_entities) +router.get( + "/applet/completions", + status_code=status.HTTP_200_OK, + responses={ + status.HTTP_200_OK: {"model": ResponseMulti[AppletCompletedEntities]}, + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(applets_completed_entities) + router.post( "/check-existence", status_code=status.HTTP_200_OK, diff --git a/src/apps/answers/service.py b/src/apps/answers/service.py index 6c8c7b7f0fb..c72ebd21f99 100644 --- a/src/apps/answers/service.py +++ b/src/apps/answers/service.py @@ -3,6 +3,7 @@ import datetime import json import os +import time import uuid from collections import defaultdict from json import JSONDecodeError @@ -21,6 +22,10 @@ ActivityItemHistoriesCRUD, ) from apps.activities.domain.activity_history import ActivityHistoryFull +from apps.activities.errors import ( + ActivityDoeNotExist, + ActivityHistoryDoeNotExist, +) from apps.activities.services import ActivityHistoryService from apps.activities.services.activity_item_history import ( ActivityItemHistoryService, @@ -60,6 +65,7 @@ ActivityIsNotAssessment, AnswerAccessDeniedError, AnswerNoteAccessDeniedError, + AnswerNotFoundError, NonPublicAppletError, ReportServerError, ReportServerIsNotConfigured, @@ -78,9 +84,11 @@ from apps.shared.exception import EncryptionError from apps.shared.query_params import QueryParams from apps.users import User, UserSchema, UsersCRUD +from apps.users.errors import UserNotFound from apps.workspaces.crud.applet_access import AppletAccessCRUD from apps.workspaces.crud.user_applet_access import UserAppletAccessCRUD from apps.workspaces.domain.constants import Role +from apps.workspaces.domain.workspace import WorkspaceRespondent from apps.workspaces.service.user_applet_access import UserAppletAccessService from infrastructure.logger import logger from infrastructure.utility import RedisCache @@ -153,6 +161,16 @@ async def _validate_answer(self, applet_answer: AppletAnswerCreate): elif existed_answer.respondent_id != self.user_id: raise WrongRespondentForAnswerGroup() + pk = self._generate_history_id(applet_answer.version) + activity_history = await ActivityHistoriesCRUD(self.session).get_by_id( + pk(applet_answer.activity_id) + ) + + if not activity_history.applet_id.startswith( + f"{applet_answer.applet_id}" + ): + raise ActivityHistoryDoeNotExist() + async def _validate_applet_for_anonymous_response( self, applet_id: uuid.UUID, version: str ): @@ -222,22 +240,30 @@ async def _create_answer(self, applet_answer: AppletAnswerCreate): return answer async def create_report_from_answer(self, answer: AnswerSchema): - service = ReportServerService(self.session) - is_reportable = await service.is_reportable(answer) - if not is_reportable: - return - + service = ReportServerService( + session=self.session, arbitrary_session=self.answer_session + ) + # First check is flow single report or not, flow single report has + # another rules to be reportable. is_flow_single = await service.is_flows_single_report(answer.id) - if not is_flow_single: - await create_report.kiq( - answer.applet_id, answer.submit_id, answer.id - ) - else: + if is_flow_single: is_flow_finished = await service.is_flow_finished( answer.submit_id, answer.id ) if is_flow_finished: - await create_report.kiq(answer.applet_id, answer.submit_id) + is_reportable = await service.is_reportable( + answer, is_flow_single + ) + if is_reportable: + await create_report.kiq(answer.applet_id, answer.submit_id) + else: + is_reportable = await service.is_reportable(answer) + if is_reportable: + await create_report.kiq( + answer.applet_id, + answer.submit_id, + answer.id, + ) async def get_review_activities( self, @@ -336,6 +362,8 @@ async def get_by_id( answer_items = await AnswerItemsCRUD( self.answer_session ).get_by_answer_and_activity(answer_id, [pk(activity_id)]) + if not answer_items: + raise AnswerNotFoundError() answer_item = answer_items[0] activity_items = await ActivityItemHistoryService( @@ -445,31 +473,64 @@ async def get_assessment_by_answer_id( assert self.user_id await self._validate_answer_access(applet_id, answer_id) - schema = await AnswersCRUD(self.answer_session).get_by_id(answer_id) - pk = self._generate_history_id(schema.version) - - activity_items = await ActivityItemHistoriesCRUD( - self.session - ).get_applets_assessments(pk(applet_id)) - if len(activity_items) == 0: - return AssessmentAnswer(items=activity_items) - assessment_answer = await AnswerItemsCRUD( self.answer_session ).get_assessment(answer_id, self.user_id) - answer = AssessmentAnswer( - reviewer_public_key=assessment_answer.user_public_key - if assessment_answer - else None, - answer=assessment_answer.answer if assessment_answer else None, - item_ids=assessment_answer.item_ids if assessment_answer else [], - items=activity_items, - is_edited=assessment_answer.created_at - != assessment_answer.updated_at # noqa - if assessment_answer - else False, - ) + items_crud = ActivityItemHistoriesCRUD(self.session) + last = items_crud.get_applets_assessments(applet_id) + if assessment_answer: + current = items_crud.get_assessment_activity_items( + assessment_answer.assessment_activity_id + ) + items_last, items_current = await asyncio.gather(last, current) + else: + items_last = await last + items_current = None + + if len(items_last) == 0: + return AssessmentAnswer(items=items_last) + + if items_last == items_current and assessment_answer: + answer = AssessmentAnswer( + reviewer_public_key=assessment_answer.user_public_key + if assessment_answer + else None, + answer=assessment_answer.answer if assessment_answer else None, + item_ids=assessment_answer.item_ids + if assessment_answer + else [], + items=items_last, + is_edited=assessment_answer.created_at + != assessment_answer.updated_at # noqa + if assessment_answer + else False, + versions=[assessment_answer.assessment_activity_id], + ) + else: + if assessment_answer: + versions = [ + assessment_answer.assessment_activity_id, + items_last[0].activity_id, + ] + else: + versions = [items_last[0].activity_id] + answer = AssessmentAnswer( + reviewer_public_key=assessment_answer.user_public_key + if assessment_answer + else None, + answer=assessment_answer.answer if assessment_answer else None, + item_ids=assessment_answer.item_ids + if assessment_answer + else [], + items=items_current if assessment_answer else items_last, + items_last=items_last if assessment_answer else None, + is_edited=assessment_answer.created_at + != assessment_answer.updated_at # noqa + if assessment_answer + else False, + versions=versions, + ) return answer async def get_reviews_by_answer_id( @@ -478,17 +539,48 @@ async def get_reviews_by_answer_id( assert self.user_id await self._validate_answer_access(applet_id, answer_id) - schema = await AnswersCRUD(self.answer_session).get_by_id(answer_id) - pk = self._generate_history_id(schema.version) + reviewer_activity_version = await AnswerItemsCRUD( + self.answer_session + ).get_assessment_activity_id(answer_id) + if not reviewer_activity_version: + return [] + activity_versions = [t[1] for t in reviewer_activity_version] activity_items = await ActivityItemHistoriesCRUD( self.session - ).get_applets_assessments(pk(applet_id)) + ).get_by_activity_id_versions(activity_versions) reviews = await AnswerItemsCRUD( self.answer_session ).get_reviews_by_answer_id(answer_id, activity_items) - return reviews + + user_ids = [rev.respondent_id for rev in reviews] + users = await UsersCRUD(self.session).get_by_ids(user_ids) + results = [] + for schema in reviews: + user = next( + filter(lambda u: u.id == schema.respondent_id, users), None + ) + current_activity_items = list( + filter( + lambda i: i.activity_id == schema.assessment_activity_id, + activity_items, + ) + ) + if not user: + continue + results.append( + AnswerReview( + reviewer_public_key=schema.user_public_key, + answer=schema.answer, + item_ids=schema.item_ids, + items=current_activity_items, + reviewer=dict( + first_name=user.first_name, last_name=user.last_name + ), + ) + ) + return results async def create_assessment_answer( self, @@ -516,6 +608,7 @@ async def create_assessment_answer( is_assessment=True, start_datetime=datetime.datetime.utcnow(), end_datetime=datetime.datetime.utcnow(), + assessment_activity_id=schema.assessment_version_id, ) ) else: @@ -532,6 +625,7 @@ async def create_assessment_answer( end_datetime=now, created_at=now, updated_at=now, + assessment_activity_id=schema.assessment_version_id, ) ) @@ -563,7 +657,13 @@ async def get_export_data( if not access: allowed_respondents = [self.user_id] elif access.role == Role.REVIEWER: - allowed_respondents = access.reviewer_respondents # type: ignore[assignment] # noqa: E501 + if ( + isinstance(access.reviewer_respondents, list) + and len(access.reviewer_respondents) > 0 + ): + allowed_respondents = access.reviewer_respondents # noqa: E501 + else: + allowed_respondents = [self.user_id] else: # [Role.OWNER, Role.MANAGER] assessments_allowed = True @@ -601,9 +701,6 @@ async def get_export_data( if answer.activity_history_id: activity_hist_ids.add(answer.activity_history_id) - reviewer_activities_coro = ActivityHistoriesCRUD( - self.session - ).get_reviewable_activities(list(applet_assessment_ids)) flows_coro = FlowsHistoryCRUD(self.session).get_by_id_versions( list(flow_hist_ids) ) @@ -612,7 +709,6 @@ async def get_export_data( ).get_respondent_export_data(applet_id, list(respondent_ids)) coros_result = await asyncio.gather( - reviewer_activities_coro, flows_coro, user_map_coro, return_exceptions=True, @@ -621,13 +717,7 @@ async def get_export_data( if isinstance(res, BaseException): raise res - reviewer_activities, flows, user_map = coros_result - - reviewer_activity_map = {} - for activity in reviewer_activities: # type: ignore - reviewer_activity_map[activity.applet_id] = activity - activity_hist_ids.add(activity.id_version) - + flows, user_map = coros_result flow_map = {flow.id_version: flow for flow in flows} # type: ignore for answer in answers: @@ -641,12 +731,6 @@ async def get_export_data( if flow_id := answer.flow_history_id: if flow := flow_map.get(flow_id): answer.flow_name = flow.name - # assessment data - if answer.reviewed_answer_id: - if activity := reviewer_activity_map.get( - answer.applet_history_id - ): - answer.activity_history_id = activity.id_version repo_local = AnswersCRUD(self.session) activities_result = [] @@ -688,19 +772,20 @@ async def get_activity_identifiers( ).get_identifiers_by_activity_id(ids, respondent_id) results = [] for identifier, key, migrated_data in identifiers: - if not migrated_data or not migrated_data.get( - "is_identifier_encrypted" + if ( + migrated_data + and migrated_data.get("is_identifier_encrypted") is False ): results.append( Identifier( identifier=identifier, - user_public_key=key, ) ) else: results.append( Identifier( identifier=identifier, + user_public_key=key, ) ) return results @@ -768,8 +853,23 @@ async def get_summary_latest_report( activity_id: uuid.UUID, respondent_id: uuid.UUID, ) -> ReportServerResponse | None: + respondent_exist = await UsersCRUD(self.session).exist_by_id( + id_=respondent_id + ) + if not respondent_exist: + raise UserNotFound(f"No such respondent with id={respondent_id}.") + + await self._is_report_server_configured(applet_id) + act_crud = ActivityHistoriesCRUD(self.session) activity_hsts = await act_crud.get_activities(activity_id, None) + if not activity_hsts: + activity_error_exception = ActivityDoeNotExist() + activity_error_exception.message = ( + f"No such activity with id=${activity_id}" + ) + raise activity_error_exception + act_versions = set( map(lambda act_hst: act_hst.id_version, activity_hsts) ) @@ -778,8 +878,10 @@ async def get_summary_latest_report( ) if not answer: return None - service = ReportServerService(self.session) - await self._is_report_server_configured(applet_id) + + service = ReportServerService( + self.session, arbitrary_session=self.answer_session + ) is_single_flow = await service.is_flows_single_report(answer.id) if is_single_flow: report = await service.create_report(answer.submit_id) @@ -905,11 +1007,26 @@ async def get_completed_answers_data( ) return result + async def get_completed_answers_data_list( + self, + applets_version_map: dict[uuid.UUID, str], + from_date: datetime.date, + ) -> list[AppletCompletedEntities]: + assert self.user_id + result = await AnswersCRUD( + self.answer_session + ).get_completed_answers_data_list( + applets_version_map, + self.user_id, + from_date, + ) + return result + async def is_answers_uploaded( self, applet_id: uuid.UUID, activity_id: str, created_at: int ) -> bool: answers = await AnswersCRUD( - self.session + self.answer_session ).get_by_applet_activity_created_at(applet_id, activity_id, created_at) if not answers: return False @@ -997,9 +1114,16 @@ async def reencrypt_user_answers( decryptor.decrypt(answer.events) ) if answer.identifier: - encrypted_identifier = encryptor.encrypt( - decryptor.decrypt(answer.identifier) - ) + if ( + answer.migrated_data + and answer.migrated_data.get("is_identifier_encrypted") + is False + ): + encrypted_identifier = encrypted_identifier + else: + encrypted_identifier = encryptor.encrypt( + decryptor.decrypt(answer.identifier) + ) data_to_update.append( AnswerItemDataEncrypted( @@ -1023,6 +1147,19 @@ async def reencrypt_user_answers( return count + async def fill_last_activity( + self, + respondents: list[WorkspaceRespondent], + applet_id: uuid.UUID | None = None, + ) -> list[WorkspaceRespondent]: + respondent_ids = [respondent.id for respondent in respondents] + result = await AnswersCRUD(self.answer_session).get_last_activity( + respondent_ids, applet_id + ) + for respondent in respondents: + respondent.last_seen = result.get(respondent.id) + return respondents + class ReportServerService: def __init__(self, session, arbitrary_session=None): @@ -1033,33 +1170,63 @@ def __init__(self, session, arbitrary_session=None): def answers_session(self): return self._answers_session if self._answers_session else self.session - async def is_reportable(self, answer: AnswerSchema): - applet, activity = await AnswersCRUD( - self.answers_session - ).get_applet_info_by_answer_id(answer) - if not applet.report_server_ip: - return False - elif not applet.report_public_key: - return False - elif not applet.report_recipients: - return False - elif not activity.scores_and_reports: - return False - elif not activity.scores_and_reports.get("generate_report", False): - return False + async def is_reportable( + self, answer: AnswerSchema, is_single_report_flow=False + ) -> bool: + """Check is report available for answer or not. - if not activity.scores_and_reports.get("reports"): - return False - return True + First check applet report related fields. All fields must be filled. + Second check activities report related fields. If it is flow single + report then one of activities must be reportable (have filled all + reportable fields). If it is not flow single report then answers + activity must have filled reportable fields. + """ + # It is simpler to use AppletHistoryService to get all required data. + # It allows to reduce repeatable logic for single report flow and + # for general case. + applet = await AppletHistoryService( + self.session, answer.applet_id, answer.version + ).get_full() + _is_reportable = False + if not ( + applet.report_server_ip + and applet.report_public_key + and applet.report_recipients + ): + return _is_reportable + + flow_activities = [] + if is_single_report_flow: + flow = next( + i + for i in applet.activity_flows + if i.id_version == answer.flow_history_id + ) + flow_activities = [i.activity_id for i in flow.items] + for activity in applet.activities: + if ( + activity.scores_and_reports is not None + and activity.scores_and_reports.generate_report + and activity.scores_and_reports.reports + and ( + answer.activity_history_id in flow_activities + or answer.activity_history_id == activity.id_version + ) + ): + _is_reportable = True + break + return _is_reportable async def is_flows_single_report(self, answer_id: uuid.UUID) -> bool: """ Whether check to send flow reports in a single or multiple request """ - result = await AnswersCRUD( - self.answers_session - ).get_activity_flow_by_answer_id(answer_id) - return result + answer = await AnswersCRUD(self.answers_session).get_by_id(answer_id) + # ActivityFlow a stored in local db + is_single_report = await AnswersCRUD( + self.session + ).is_single_report_flow(answer.flow_history_id) + return is_single_report async def is_flow_finished( self, submit_id: uuid.UUID, answer_id: uuid.UUID @@ -1077,10 +1244,10 @@ async def is_flow_finished( applet_full = await self._prepare_applet_data( initial_answer.applet_id, initial_answer.version, applet.encryption ) - activity_id, version = initial_answer.activity_history_id.split("_") - flow_id, version = "", "" + activity_id, _ = initial_answer.activity_history_id.split("_") + flow_id = "" if initial_answer.flow_history_id: - flow_id, version = initial_answer.flow_history_id.split("_") + flow_id, _ = initial_answer.flow_history_id.split("_") return self._is_activity_last_in_flow( applet_full, activity_id, flow_id @@ -1094,8 +1261,18 @@ async def create_report( ) if not answers: return None - answer_map = dict((answer.id, answer) for answer in answers) - initial_answer = answers[0] + applet_id_version: str = answers[0].applet_history_id + available_activities = await ActivityHistoriesCRUD( + self.session + ).get_activity_id_versions_for_report(applet_id_version) + answers_for_report = [ + i for i in answers if i.activity_history_id in available_activities + ] + # If answers only on performance tasks + if not answers_for_report: + return None + answer_map = dict((answer.id, answer) for answer in answers_for_report) + initial_answer = answers_for_report[0] applet = await AppletsCRUD(self.session).get_by_id( initial_answer.applet_id @@ -1104,7 +1281,10 @@ async def create_report( initial_answer.respondent_id, initial_answer.applet_id ) applet_full = await self._prepare_applet_data( - initial_answer.applet_id, initial_answer.version, applet.encryption + initial_answer.applet_id, + initial_answer.version, + applet.encryption, + non_performance=True, ) encryption = ReportServerEncryption(applet.report_public_key) @@ -1120,25 +1300,33 @@ async def create_report( ) encrypted_data = encryption.encrypt(data) - activity_id, version = initial_answer.activity_history_id.split("_") - flow_id, version = "", "" + activity_id, _ = initial_answer.activity_history_id.split("_") + flow_id = "" if initial_answer.flow_history_id: - flow_id, version = initial_answer.flow_history_id.split("_") + flow_id, _ = initial_answer.flow_history_id.split("_") url = "{}/send-pdf-report?activityId={}&activityFlowId={}".format( applet.report_server_ip.rstrip("/"), activity_id, flow_id ) async with aiohttp.ClientSession() as session: + logger.info(f"Sending request to the report server {url}.") + start = time.time() async with session.post( url, json=dict(payload=encrypted_data), ) as resp: - response_data = await resp.json() + duration = time.time() - start if resp.status == 200: + logger.info( + f"Successful request in {duration:.1f} seconds." + ) + response_data = await resp.json() return ReportServerResponse(**response_data) else: - raise ReportServerError(message=str(response_data)) + logger.error(f"Failed request in {duration:.1f} seconds.") + error_message = await resp.text() + raise ReportServerError(message=error_message) def _is_activity_last_in_flow( self, applet_full: dict, activity_id: str | None, flow_id: str | None @@ -1156,29 +1344,18 @@ def _is_activity_last_in_flow( if not flow or "items" not in flow or len(flow["items"]) == 0: return False - allowed_activities = [] - for a in applet_full["activities"]: - if "scoresAndReports" in a and isinstance( - a["scoresAndReports"], dict - ): - if a["scoresAndReports"].get("generateReport", False): - allowed_activities.append(a) - - activity = next( - (a for a in allowed_activities if str(a["id"]) == activity_id), - None, - ) - if not activity or "idVersion" not in activity: - return False - - return activity["idVersion"] == str(flow["items"][-1]["activityId"]) + return activity_id == flow["items"][-1]["activityId"].split("_")[0] async def _prepare_applet_data( - self, applet_id: uuid.UUID, version: str, encryption: dict + self, + applet_id: uuid.UUID, + version: str, + encryption: dict, + non_performance: bool = False, ): applet_full = await AppletHistoryService( self.session, applet_id, version - ).get_full() + ).get_full(non_performance) applet_full.encryption = Encryption(**encryption) return applet_full.dict(by_alias=True) @@ -1192,7 +1369,7 @@ async def _get_user_info( return dict( firstName=access.meta.get("firstName"), lastName=access.meta.get("lastName"), - nickname=access.meta.get("nickname"), + nickname=access.nickname, secretId=access.meta.get("secretUserId"), ) diff --git a/src/apps/answers/tests/test_answer_for_cases.py b/src/apps/answers/tests/test_answer_for_cases.py index 72ea7b4443e..2fcafa97a89 100644 --- a/src/apps/answers/tests/test_answer_for_cases.py +++ b/src/apps/answers/tests/test_answer_for_cases.py @@ -1,7 +1,5 @@ import json -import pytest - from apps.shared.test import BaseTest from infrastructure.database import rollback @@ -10,7 +8,6 @@ class TestAnswerCases(BaseTest): login_url = "/auth/login" answer_url = "/answers" - @pytest.mark.skip @rollback async def test_answer_activity_items_create_for_respondent(self): await self.load_data( diff --git a/src/apps/answers/tests/test_answers.py b/src/apps/answers/tests/test_answers.py index fa8f5448e11..f2b6cd18de2 100644 --- a/src/apps/answers/tests/test_answers.py +++ b/src/apps/answers/tests/test_answers.py @@ -26,6 +26,7 @@ class TestAnswerActivityItems(BaseTest): "activities/fixtures/activity_item_histories.json", "activity_flows/fixtures/activity_flow_histories.json", "activity_flows/fixtures/activity_flow_item_histories.json", + "workspaces/fixtures/workspaces.json", ] login_url = "/auth/login" @@ -43,6 +44,7 @@ class TestAnswerActivityItems(BaseTest): ) applet_answers_export_url = "/answers/applet/{applet_id}/data" applet_answers_completions_url = "/answers/applet/{applet_id}/completions" + applets_answers_completions_url = "/answers/applet/completions" applet_submit_dates_url = "/answers/applet/{applet_id}/dates" activity_answers_url = ( @@ -241,6 +243,10 @@ async def test_answer_skippable_activity_items_create_for_respondent(self): answer=dict( start_time=1690188679657, end_time=1690188731636, + itemIds=[ + "a18d3409-2c96-4a5e-a1f3-1c1c14be0011", + "a18d3409-2c96-4a5e-a1f3-1c1c14be0012", + ], ), client=dict( appId="mindlogger-mobile", @@ -376,6 +382,10 @@ async def test_answer_with_skipping_all(self): answer=dict( start_time=1690188679657, end_time=1690188731636, + itemIds=[ + "a18d3409-2c96-4a5e-a1f3-1c1c14be0011", + "a18d3409-2c96-4a5e-a1f3-1c1c14be0012", + ], ), client=dict( appId="mindlogger-mobile", @@ -435,7 +445,7 @@ async def test_answered_applet_activities(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -514,7 +524,7 @@ async def test_fail_answered_applet_not_existed_activities(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -629,7 +639,7 @@ async def test_applet_assessment_retrieve(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -692,7 +702,7 @@ async def test_applet_assessment_create(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -711,6 +721,9 @@ async def test_applet_assessment_create(self): answer="some answer", item_ids=["a18d3409-2c96-4a5e-a1f3-1c1c14be0021"], reviewer_public_key="some public key", + assessment_version_id=( + "09e3dbf0-aefb-4d0e-9177-bdb321bf3621_1.0.0" + ), ), ) @@ -741,6 +754,9 @@ async def test_applet_assessment_create(self): answer="some answer", item_ids=["a18d3409-2c96-4a5e-a1f3-1c1c14be0021"], reviewer_public_key="some public key", + assessment_version_id=( + "09e3dbf0-aefb-4d0e-9177-bdb321bf3621_1.0.0" + ), ), ) @@ -793,7 +809,7 @@ async def test_applet_activities(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -846,7 +862,7 @@ async def test_add_note(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) answer_id = response.json()["result"][0]["answerDates"][0]["answerId"] @@ -918,7 +934,7 @@ async def test_edit_note(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) answer_id = response.json()["result"][0]["answerDates"][0]["answerId"] @@ -1014,7 +1030,7 @@ async def test_delete_note(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) answer_id = response.json()["result"][0]["answerDates"][0]["answerId"] @@ -1146,7 +1162,7 @@ async def test_answers_export(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -1163,6 +1179,9 @@ async def test_answers_export(self): answer="some answer", item_ids=["a18d3409-2c96-4a5e-a1f3-1c1c14be0021"], reviewer_public_key="some public key", + assessment_version_id=( + "09e3dbf0-aefb-4d0e-9177-bdb321bf3621_1.0.0" + ), ), ) @@ -1308,9 +1327,9 @@ async def test_get_summary_activities(self): assert response.status_code == 200 assert response.json()["count"] == 1 - assert response.json()["result"][0]["name"] == "PHQ2 new" - assert response.json()["result"][0]["isPerformanceTask"] is True - assert response.json()["result"][0]["hasAnswer"] is False + assert response.json()["result"][0]["name"] == "Flanker" + assert response.json()["result"][0]["isPerformanceTask"] + assert not response.json()["result"][0]["hasAnswer"] @rollback async def test_get_summary_activities_after_submitted_answer(self): @@ -1321,7 +1340,7 @@ async def test_get_summary_activities_after_submitted_answer(self): create_data = dict( submit_id="270d86e0-2158-4d18-befd-86b3ce0122ae", applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1", - version="1.9.9", + version="1.0.0", activity_id="09e3dbf0-aefb-4d0e-9177-bdb321bf3611", answer=dict( user_public_key="user key", @@ -1359,9 +1378,9 @@ async def test_get_summary_activities_after_submitted_answer(self): assert response.status_code == 200 assert response.json()["count"] == 1 - assert response.json()["result"][0]["name"] == "PHQ2 new" - assert response.json()["result"][0]["isPerformanceTask"] is True - assert response.json()["result"][0]["hasAnswer"] is True + assert response.json()["result"][0]["name"] == "Flanker" + assert response.json()["result"][0]["isPerformanceTask"] + assert response.json()["result"][0]["hasAnswer"] @rollback_with_session async def test_store_client_meta(self, **kwargs): @@ -1518,7 +1537,7 @@ async def test_applet_completions(self): ), dict( respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -1554,6 +1573,109 @@ async def test_applet_completions(self): assert activity_answer_data["answerId"] == answer_id assert activity_answer_data["localEndTime"] == "12:35:00" + @rollback + async def test_applets_completions(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + + # create answer + create_data = dict( + submit_id="270d86e0-2158-4d18-befd-86b3ce0122ae", + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1", + version="1.0.0", + activity_id="09e3dbf0-aefb-4d0e-9177-bdb321bf3611", + answer=dict( + user_public_key="user key", + answer=json.dumps( + dict( + value="2ba4bb83-ed1c-4140-a225-c2c9b4db66d2", + additional_text=None, + ) + ), + item_ids=[ + "a18d3409-2c96-4a5e-a1f3-1c1c14be0011", + "a18d3409-2c96-4a5e-a1f3-1c1c14be0014", + ], + scheduled_time=1690188679657, + start_time=1690188679657, + end_time=1690188731636, + scheduledEventId="eventId", + localEndDate="2022-10-01", + localEndTime="12:35:00", + ), + client=dict( + appId="mindlogger-mobile", + appVersion="0.21.48", + width=819, + height=1080, + ), + ) + + response = await self.client.post(self.answer_url, data=create_data) + + assert response.status_code == 201 + + # get answer id + response = await self.client.get( + self.review_activities_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ), + dict( + respondentId="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + createdDate=datetime.datetime.utcnow().date(), + ), + ) + + assert response.status_code == 200, response.json() + answer_id = response.json()["result"][0]["answerDates"][0]["answerId"] + + # test completions + response = await self.client.get( + url=self.applets_answers_completions_url, + query={"fromDate": "2022-10-01"}, + ) + + assert response.status_code == 200, response.json() + data = sorted(response.json()["result"], key=lambda x: x["id"]) + + assert len(data) == 2 + apppet_0 = data[0] + apppet_1 = data[1] + + assert set(apppet_0.keys()) == { + "id", + "version", + "activities", + "activityFlows", + } + assert apppet_0["id"] == "92917a56-d586-4613-b7aa-991f2c4b15b1" + assert apppet_0["version"] == "1.0.0" + assert len(apppet_0["activities"]) == 1 + activity_answer_data = apppet_0["activities"][0] + assert set(activity_answer_data.keys()) == { + "id", + "answerId", + "submitId", + "scheduledEventId", + "localEndDate", + "localEndTime", + } + assert activity_answer_data["answerId"] == answer_id + assert activity_answer_data["scheduledEventId"] == "eventId" + assert activity_answer_data["localEndDate"] == "2022-10-01" + assert activity_answer_data["localEndTime"] == "12:35:00" + + assert set(apppet_1.keys()) == { + "id", + "version", + "activities", + "activityFlows", + } + assert apppet_1["id"] == "92917a56-d586-4613-b7aa-991f2c4b15b2" + assert apppet_1["version"] == "2.0.1" + assert len(apppet_1["activities"]) == 0 + @rollback async def test_summary_restricted_for_reviewer_if_external_respondent( self, @@ -1565,7 +1687,7 @@ async def test_summary_restricted_for_reviewer_if_external_respondent( create_data = dict( submit_id="270d86e0-2158-4d18-befd-86b3ce0122ae", applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1", - version="1.9.9", + version="1.0.0", activity_id="09e3dbf0-aefb-4d0e-9177-bdb321bf3611", answer=dict( user_public_key="user key", diff --git a/src/apps/answers/tests/test_answers_arbitrary.py b/src/apps/answers/tests/test_answers_arbitrary.py index de3bbc5dfe6..e5032cf5bcf 100644 --- a/src/apps/answers/tests/test_answers_arbitrary.py +++ b/src/apps/answers/tests/test_answers_arbitrary.py @@ -204,11 +204,20 @@ async def test_get_latest_summary(self, mock: CoroutineMock): self.latest_report_url.format( applet_id="92917a56-d586-4613-b7aa-991f2c4b15b8", activity_id="09e3dbf0-aefb-4d0e-9177-bdb321bf3618", - respondent_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + respondent_id="7484f34a-3acc-4ee6-8a94-fd7299502fa8", ), ) assert response.status_code == 200 + response = await self.client.post( + self.latest_report_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b8", + activity_id="09e3dbf0-aefb-4d0e-9177-bdb321bf3618", + respondent_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + ), + ) + assert response.status_code == 404 + @rollback async def test_public_answer_activity_items_create_for_respondent(self): create_data = dict( @@ -264,6 +273,13 @@ async def test_answer_skippable_activity_items_create_for_respondent(self): answer=dict( start_time=1690188679657, end_time=1690188731636, + itemIds=[ + "f0ccc10a-2388-48da-a5a1-35e9b19cde5d", + "c6fd4e75-c5c1-4a99-89db-4044526b6ad5", + "f698d5c6-3861-46a1-a6e7-3bdae7228bce", + "8e5ef149-ce10-4590-bc03-594e5200ecb9", + "2bcf1de2-aff8-494e-af28-d1ce2602585f", + ], ), client=dict( appId="mindlogger-mobile", @@ -408,6 +424,13 @@ async def test_answer_with_skipping_all(self): answer=dict( start_time=1690188679657, end_time=1690188731636, + itemIds=[ + "f0ccc10a-2388-48da-a5a1-35e9b19cde5d", + "c6fd4e75-c5c1-4a99-89db-4044526b6ad5", + "f698d5c6-3861-46a1-a6e7-3bdae7228bce", + "8e5ef149-ce10-4590-bc03-594e5200ecb9", + "2bcf1de2-aff8-494e-af28-d1ce2602585f", + ], ), client=dict( appId="mindlogger-mobile", @@ -471,7 +494,7 @@ async def test_answered_applet_activities(self): ), dict( respondentId="6cde911e-8a57-47c0-b6b2-685b3664f418", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -552,7 +575,7 @@ async def test_fail_answered_applet_not_existed_activities(self): ), dict( respondentId="6cde911e-8a57-47c0-b6b2-685b3664f418", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -670,7 +693,7 @@ async def test_applet_assessment_retrieve(self): ), dict( respondentId="6cde911e-8a57-47c0-b6b2-685b3664f418", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -734,7 +757,7 @@ async def test_applet_assessment_create(self): ), dict( respondentId="6cde911e-8a57-47c0-b6b2-685b3664f418", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -753,6 +776,9 @@ async def test_applet_assessment_create(self): answer="some answer", item_ids=["f0ccc10a-2388-48da-a5a1-35e9b19cde5d"], reviewer_public_key="some public key", + assessment_version_id=( + "09e3dbf0-aefb-4d0e-9177-bdb321bf3621_1.1.0" + ), ), ) @@ -783,6 +809,9 @@ async def test_applet_assessment_create(self): answer="some answer", item_ids=["a18d3409-2c96-4a5e-a1f3-1c1c14be0021"], reviewer_public_key="some public key", + assessment_version_id=( + "09e3dbf0-aefb-4d0e-9177-bdb321bf3621_1.1.0" + ), ), ) @@ -833,7 +862,7 @@ async def test_applet_activities(self): ), dict( respondentId="6cde911e-8a57-47c0-b6b2-685b3664f418", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -926,7 +955,7 @@ async def test_answers_export(self): ), dict( respondentId="6cde911e-8a57-47c0-b6b2-685b3664f418", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -943,6 +972,9 @@ async def test_answers_export(self): answer="some answer", item_ids=["a18d3409-2c96-4a5e-a1f3-1c1c14be0021"], reviewer_public_key="some public key", + assessment_version_id=( + "09e3dbf0-aefb-4d0e-9177-bdb321bf3621_1.1.0" + ), ), ) @@ -1136,7 +1168,7 @@ async def test_get_summary_activities_after_submitted_answer(self): assert response.status_code == 200 assert response.json()["count"] == 1 assert response.json()["result"][0]["name"] == "PHQ2" - assert response.json()["result"][0]["hasAnswer"] is True + assert response.json()["result"][0]["hasAnswer"] @rollback_with_session async def test_store_client_meta(self, **kwargs): @@ -1289,7 +1321,7 @@ async def test_answers_arbitrary_export(self): ), dict( respondentId="6cde911e-8a57-47c0-b6b2-685b3664f418", - createdDate=datetime.date.today(), + createdDate=datetime.datetime.utcnow().date(), ), ) @@ -1306,6 +1338,9 @@ async def test_answers_arbitrary_export(self): answer="some answer", item_ids=["a18d3409-2c96-4a5e-a1f3-1c1c14be0021"], reviewer_public_key="some public key", + assessment_version_id=( + "09e3dbf0-aefb-4d0e-9177-bdb321bf3621_1.1.0" + ), ), ) diff --git a/src/apps/applets/crud/applets.py b/src/apps/applets/crud/applets.py index 19d8742af56..545a9a8e765 100644 --- a/src/apps/applets/crud/applets.py +++ b/src/apps/applets/crud/applets.py @@ -152,6 +152,16 @@ async def exist_by_id(self, id_: uuid.UUID) -> bool: return db_result.scalars().first() is not None + async def exist_by_ids(self, ids: list[uuid.UUID]) -> bool: + query: Query = select(AppletSchema) + query = query.where(AppletSchema.id.in_(ids)) + query = query.where(AppletSchema.is_deleted == False) # noqa: E712 + + query = query.exists() + db_result = await self._execute(select(query)) + + return db_result.scalars().first() or False + async def get_by_key( self, key: uuid.UUID, require_login=False ) -> AppletSchema: @@ -274,6 +284,7 @@ async def get_name_duplicates( UserAppletAccessSchema.applet_id == AppletSchema.id, ) query = query.where(UserAppletAccessSchema.user_id == user_id) + query = query.where(AppletSchema.is_deleted == False) # noqa: E712 if exclude_applet_id: query = query.where(AppletSchema.id != exclude_applet_id) query = query.where( @@ -954,3 +965,17 @@ async def get_every_non_indefinitely_applet_retentions(self) -> Result: result: Result = await self._execute(query) return result + + async def clear_report_settings(self, applet_id: uuid.UUID): + query: Query = update(AppletSchema) + query = query.where(AppletSchema.id == applet_id) + query = query.values( + report_server_ip="", + report_public_key="", + report_recipients=text("'[]'"), + report_include_user_id=False, + report_include_case_id=False, + report_email_body="", + ) + + await self._execute(query) diff --git a/src/apps/applets/crud/applets_history.py b/src/apps/applets/crud/applets_history.py index f3c75f1c4b6..7695253f7b1 100644 --- a/src/apps/applets/crud/applets_history.py +++ b/src/apps/applets/crud/applets_history.py @@ -78,11 +78,12 @@ async def update_display_name(self, id_version: str, display_name: str): async def get_id_versions_by_applet_id( self, applet_id: uuid.UUID - ) -> list[uuid.UUID]: - query: Query = select(AppletHistorySchema.id_version) + ) -> list[str]: + query: Query = select(AppletHistorySchema.version) query = query.where(AppletHistorySchema.id == applet_id) + query = query.order_by(AppletHistorySchema.created_at.asc()) result = await self._execute(query) - return [id_version for id_version, in result] + return result.scalars().all() async def set_report_configuration( self, diff --git a/src/apps/applets/db/schemas/applet.py b/src/apps/applets/db/schemas/applet.py index 769396e00a9..358df0fa54b 100644 --- a/src/apps/applets/db/schemas/applet.py +++ b/src/apps/applets/db/schemas/applet.py @@ -50,6 +50,9 @@ class AppletSchema(_BaseAppletSchema, Base): retention_period = Column(Integer(), nullable=True) retention_type = Column(String(20), nullable=True) is_published = Column(Boolean(), default=False) + creator_id = Column( + ForeignKey("users.id", ondelete="RESTRICT"), nullable=True + ) class HistoryMixin: diff --git a/src/apps/applets/domain/__init__.py b/src/apps/applets/domain/__init__.py index ffe52b88bed..c6e702a2255 100644 --- a/src/apps/applets/domain/__init__.py +++ b/src/apps/applets/domain/__init__.py @@ -1,6 +1,7 @@ from apps.applets.domain.applet import ( # noqa: F401, F403 AppletName, AppletSingleLanguageDetail, + AppletSingleLanguageDetailMobilePublic, AppletSingleLanguageInfo, AppletUniqueName, ) diff --git a/src/apps/applets/domain/applet.py b/src/apps/applets/domain/applet.py index 75ca01e4bb5..34a751f6de0 100644 --- a/src/apps/applets/domain/applet.py +++ b/src/apps/applets/domain/applet.py @@ -4,13 +4,16 @@ from pydantic import Field, PositiveInt, root_validator from apps.activities.domain.activity import ( + ActivityLanguageWithItemsMobileDetailPublic, ActivitySingleLanguageDetail, ActivitySingleLanguageDetailPublic, + ActivitySingleLanguageMobileDetailPublic, ) from apps.activities.errors import PeriodIsRequiredError from apps.activity_flows.domain.flow import ( FlowSingleLanguageDetail, FlowSingleLanguageDetailPublic, + FlowSingleLanguageMobileDetailPublic, ) from apps.applets.domain.base import ( AppletBaseInfo, @@ -18,7 +21,7 @@ Encryption, ) from apps.shared.domain import InternalModel, PublicModel, Response -from apps.themes.domain import PublicTheme, Theme +from apps.themes.domain import PublicTheme, PublicThemeMobile, Theme from apps.workspaces.domain.constants import DataRetention @@ -60,6 +63,25 @@ class AppletSingleLanguageDetailPublic(AppletFetchBase, PublicModel): theme: PublicTheme | None = None +class AppletSingleLanguageDetailMobilePublic(PublicModel): + id: uuid.UUID + display_name: str + version: str + description: str + about: str + image: str = "" + watermark: str = "" + theme: PublicThemeMobile | None = None + activities: list[ActivitySingleLanguageMobileDetailPublic] = Field( + default_factory=list + ) + activity_flows: list[FlowSingleLanguageMobileDetailPublic] = Field( + default_factory=list + ) + encryption: Encryption | None + stream_enabled: bool | None + + class AppletSingleLanguageDetailForPublic(AppletBaseInfo, PublicModel): id: uuid.UUID version: str @@ -119,3 +141,11 @@ def validate_period(cls, values): class AppletRetrieveResponse(Response[AppletSingleLanguageDetailPublic]): respondent_meta: dict | None = None + + +class AppletActivitiesDetailsPublic(PublicModel): + activities_details: list[ + ActivityLanguageWithItemsMobileDetailPublic + ] = Field(default_factory=list) + applet_detail: AppletSingleLanguageDetailMobilePublic + respondent_meta: dict | None = None diff --git a/src/apps/applets/domain/applet_create_update.py b/src/apps/applets/domain/applet_create_update.py index c7cdc628bb5..5b420b95431 100644 --- a/src/apps/applets/domain/applet_create_update.py +++ b/src/apps/applets/domain/applet_create_update.py @@ -2,6 +2,9 @@ from apps.activities.domain.activity_create import ActivityCreate from apps.activities.domain.activity_update import ActivityUpdate +from apps.activities.domain.custom_validation import ( + validate_performance_task_type, +) from apps.activities.errors import ( AssessmentLimitExceed, DuplicateActivityFlowNameError, @@ -26,13 +29,13 @@ class AppletCreate(AppletReportConfigurationBase, AppletBase, InternalModel): @root_validator() def validate_existing_ids_for_duplicate(cls, values): - activities = values.get("activities", []) - flows = values.get("activity_flows", []) + activities: list[ActivityCreate] = values.get("activities", []) + flows: list[FlowCreate] = values.get("activity_flows", []) activity_names = set() flow_names = set() assessments_count = 0 - for activity in activities: # type:ActivityCreate + for activity in activities: if activity.name in activity_names: raise DuplicateActivityNameError() activity_names.add(activity.name) @@ -41,12 +44,16 @@ def validate_existing_ids_for_duplicate(cls, values): # if assessments_count > 1: # raise AssessmentLimitExceed() - for flow in flows: # type:FlowCreate + for flow in flows: if flow.name in flow_names: raise DuplicateActivityFlowNameError() flow_names.add(flow.name) return values + @root_validator + def validate_performance_task_type(cls, values): + return validate_performance_task_type(values) + class AppletUpdate(AppletBase, InternalModel): activities: list[ActivityUpdate] diff --git a/src/apps/applets/domain/applet_full.py b/src/apps/applets/domain/applet_full.py index 93f8743b324..c17445f16cb 100644 --- a/src/apps/applets/domain/applet_full.py +++ b/src/apps/applets/domain/applet_full.py @@ -17,7 +17,6 @@ class AppletFull(AppletFetchBase, InternalModel): activities: list[ActivityFull] = Field(default_factory=list) activity_flows: list[FlowFull] = Field(default_factory=list) - extra_fields: dict = Field(default_factory=dict) class PublicAppletFull(AppletFetchBase, PublicModel): @@ -28,4 +27,3 @@ class PublicAppletFull(AppletFetchBase, PublicModel): class AppletHistoryFull(AppletFetchBase, InternalModel): activities: list[ActivityHistoryFull] = Field(default_factory=list) activity_flows: list[FlowHistoryFull] = Field(default_factory=list) - extra_fields: dict = Field(default_factory=dict) diff --git a/src/apps/applets/domain/applets/public_detail.py b/src/apps/applets/domain/applets/public_detail.py index 80ba917431b..f995f6c2eb7 100644 --- a/src/apps/applets/domain/applets/public_detail.py +++ b/src/apps/applets/domain/applets/public_detail.py @@ -1,12 +1,8 @@ import uuid -from pydantic import Field, validator +from pydantic import Field from apps.activities.domain.conditional_logic import ConditionalLogic -from apps.activities.domain.custom_validation import ( - validate_is_performance_task, - validate_performance_task_type, -) from apps.activities.domain.response_type_config import ( PerformanceTaskType, ResponseTypeConfig, @@ -47,17 +43,9 @@ class Activity(PublicModel): items: list[ActivityItem] = Field(default_factory=list) scores_and_reports: ScoresAndReports | None = None subscale_setting: SubscaleSetting | None = None - is_performance_task: bool = False performance_task_type: PerformanceTaskType | None = None report_included_item_name: str | None = None - - @validator("is_performance_task", always=True) - def validate_is_performance_task(cls, value, values): - return validate_is_performance_task(value, values) - - @validator("performance_task_type", always=True) - def validate_performance_task_type(cls, value, values): - return validate_performance_task_type(value, values) + is_performance_task: bool = False class ActivityFlowItem(PublicModel): diff --git a/src/apps/applets/domain/history.py b/src/apps/applets/domain/history.py index 549388426aa..92e22d2a16f 100644 --- a/src/apps/applets/domain/history.py +++ b/src/apps/applets/domain/history.py @@ -6,6 +6,10 @@ ActivityHistoryChange, PublicActivityHistoryChange, ) +from apps.activity_flows.domain import ( + ActivityFlowHistoryChange, + PublicActivityFlowHistoryChange, +) from apps.shared.domain import InternalModel, PublicModel from apps.shared.enums import Language @@ -14,8 +18,12 @@ class AppletHistory(InternalModel): display_name: str - description: dict[Language, str] = Field(default_factory=dict) - about: dict[Language, str] = Field(default_factory=dict) + description: dict[Language, str] = Field( + default_factory=lambda: {Language.ENGLISH: ""} + ) + about: dict[Language, str] = Field( + default_factory=lambda: {Language.ENGLISH: ""} + ) image: str = "" watermark: str = "" theme_id: uuid.UUID | None = None @@ -25,6 +33,8 @@ class AppletHistory(InternalModel): report_include_user_id: bool = False report_include_case_id: bool = False report_email_body: str = "" + stream_enabled: bool | None = None + version: str class AppletHistoryChange(InternalModel): @@ -35,8 +45,11 @@ class AppletHistoryChange(InternalModel): """ display_name: str | None = None - changes: list[str] | None = Field(default_factory=list) + changes: list[str] = Field(default_factory=list) activities: list[ActivityHistoryChange] = Field(default_factory=list) + activity_flows: list[ActivityFlowHistoryChange] = Field( + default_factory=list + ) class PublicAppletHistoryChange(PublicModel): @@ -49,3 +62,6 @@ class PublicAppletHistoryChange(PublicModel): display_name: str | None = None changes: list[str] | None = Field(default_factory=list) activities: list[PublicActivityHistoryChange] = Field(default_factory=list) + activity_flows: list[PublicActivityFlowHistoryChange] = Field( + default_factory=list + ) diff --git a/src/apps/applets/errors.py b/src/apps/applets/errors.py index 7b2e3a608de..dac35772bc8 100644 --- a/src/apps/applets/errors.py +++ b/src/apps/applets/errors.py @@ -19,6 +19,7 @@ class AppletNotFoundError(NotFoundError): + message_is_template: bool = True message = _("No such applets with {key}={value}.") @@ -31,6 +32,7 @@ class NotValidAppletHistory(NotFoundError): class AppletLinkNotFoundError(NotFoundError): + message_is_template: bool = True message = _("No such applet link for id={applet_id}.") @@ -43,6 +45,7 @@ class AppletsFolderAccessDenied(AccessDeniedError): class AppletsError(ValidationError): + message_is_template: bool = True message = _("Can not make the looking up applets by {key} {value}.") diff --git a/src/apps/applets/fixtures/applet_user_accesses.json b/src/apps/applets/fixtures/applet_user_accesses.json index d0b339753a5..29cef1d1ed5 100644 --- a/src/apps/applets/fixtures/applet_user_accesses.json +++ b/src/apps/applets/fixtures/applet_user_accesses.json @@ -53,7 +53,8 @@ "meta": { "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a674782", "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674782" - } + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" } }, { @@ -129,7 +130,9 @@ "meta": { "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a674786", "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674786", - "respondents": ["7484f34a-3acc-4ee6-8a94-fd7299502fa1"] + "respondents": [ + "7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ] } } }, @@ -204,9 +207,9 @@ "invitor_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", "role": "respondent", "meta": { - "nickname": "respondent Jane Doe", "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674788" - } + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" } }, { @@ -225,7 +228,8 @@ "meta": { "nickname": "respondent John Doe", "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674789" - } + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" } }, { @@ -244,7 +248,8 @@ "meta": { "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a67478f", "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a67478f" - } + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" } }, { @@ -263,7 +268,8 @@ "meta": { "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a674781", "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674781" - } + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" } }, { @@ -285,7 +291,7 @@ } } }, - { + { "table": "user_applet_accesses", "fields": { "id": "92917a56-d586-4613-b7aa-991f2c4b15b8", @@ -299,8 +305,70 @@ "invitor_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", "role": "reviewer", "meta": { - "respondents": ["7484f34a-3acc-4ee6-8a94-fd7299502fa6"] + "respondents": [ + "7484f34a-3acc-4ee6-8a94-fd7299502fa6" + ] } } + }, + { + "table": "user_applet_accesses", + "fields": { + "id": "f0dd4996-e0eb-461f-b2f8-ba873a674799", + "created_at": "2023-01-05T15:49:51.752114", + "updated_at": "2023-01-05T15:49:51.752113", + "is_deleted": false, + "is_pinned": false, + "user_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa3", + "owner_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", + "invitor_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", + "applet_id": "92917a56-d586-4613-b7aa-991f2c4b15b2", + "role": "respondent", + "meta": { + "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a674782", + "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674782" + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" + } + }, + { + "table": "user_applet_accesses", + "fields": { + "id": "f0dd4996-e0eb-461f-b2f8-ba873a674700", + "created_at": "2023-01-05T15:49:51.752114", + "updated_at": "2023-01-05T15:49:51.752113", + "is_deleted": false, + "is_pinned": false, + "user_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa4", + "owner_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", + "invitor_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", + "applet_id": "92917a56-d586-4613-b7aa-991f2c4b15b2", + "role": "manager", + "meta": { + "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a674782", + "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674782" + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" + } + }, + { + "table": "user_applet_accesses", + "fields": { + "id": "f0dd4996-e0eb-461f-b2f8-ba873a674709", + "created_at": "2023-01-05T15:49:51.752114", + "updated_at": "2023-01-05T15:49:51.752113", + "is_deleted": false, + "is_pinned": false, + "user_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa4", + "owner_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", + "invitor_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", + "applet_id": "92917a56-d586-4613-b7aa-991f2c4b15b1", + "role": "respondent", + "meta": { + "nickname": "f0dd4996-e0eb-461f-b2f8-ba873a674782", + "secretUserId": "f0dd4996-e0eb-461f-b2f8-ba873a674782" + }, + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" + } } ] \ No newline at end of file diff --git a/src/apps/applets/fixtures/applets.json b/src/apps/applets/fixtures/applets.json index 70d85742651..e2a7b5d2d38 100644 --- a/src/apps/applets/fixtures/applets.json +++ b/src/apps/applets/fixtures/applets.json @@ -27,9 +27,9 @@ "report_server_ip": "https://report-server.l.trackmage.com", "report_public_key": "-----BEGIN PUBLIC KEY-----\nMIIIIjANBgkqhkiG9w0BAQEFAAOCCA8AMIIICgKCCAEA0Ooez1FjFM1kW8YG9k81\nLCZBEVg7fK6qAv6PN/VC5xfdafQfi704Yvv2FaLQ1blH531eb89QjjWksmUxTCrL\nRKy3QQAerQp7kegdWHo3q0co6WFFjIRtt7MFxGIT5y4ZBeT0Jm6fHy8SXJCheRwB\ni0BqBRt3k5pvlYHV4u3tnUen4Neyn8r5NgYMQc77ZDD6wcIw0L7XnPbNB7WHP5w9\n0VfnnUqqeGLoiy2mTx/3kv7EKalUBBUSCY5qcbsmNr0x91/hSsnSeICREuKcpW1c\nbAsarFSyiRLkZEBh4FrdKfp9+ZLUqrejWltSdlkqY9ih8DtiaXEvJLjQp6V2aYrQ\nMZBYZdrg8c5/UI3yhSioB0TYxkI9lgGyTSrhqRjRtzQ+MEJ7cV0BWqji5BOO3OJ9\nrGC2Mb+3T6EzhNB6HqdRDfThJqDeThPtT/zEu5OU0UuW9NiEqP4twz2IEYVOCo/B\nWK+H5wgHkkuDsI06Z9ngxp0KhmMjGxrfHNLZ4r8mr4LyPxcASqxJ4rGRBO5EIRfs\niyo9dcp/BSJrGhZyB9vlY87qciuswjtjcsAFgVHqvYZYSmm3I+vJXUgPHoPOmbfp\nyoLwjuwiouKnh1W5NvTfZfjuP3XDRag++ZjQknZU5obybn+XvNMsyu9JZ8UWrIPt\nnK1k+PUa6wdHC8ecE/RF/utSaFDE9W+5wwd92PXvJhRcPA2y5ujvqqeFtk8VPSse\n7RQswjORJdkLwLNkSNWq8vAIlhYjcrhWlvYv29LxoE1WmvtgDNQAJbhHUqh1sFTY\nktTxq2mLsoPMhAnvAla3PnTSUSgpiLlJRBU43TO7oj3IQSurGE3NprvjToOwV4Lp\nLJe9xhcZNNVqlN01y5F0ULFz3bk0yuT3oq/9uKjf+OsxkzdLmhA5lTdsCutPJakh\nQ2gTGl9c+h1i1u+RL8NaU5D6yrDuU1VuMApdbp1vW/vah/Qw3KdXky23akEcFG5d\n+ffS82AYlDvT+6uUFGLRc5Ui4i2WEtQjr6wzXTLVI1tRj6s0sNSJ7Bx3euVxspYN\n6B0vCndvXQBm2rQKf49bgitNvtiUhaNBX1H9GzneFF3x7MSYfhN3WqR7QcdkEon5\nESGOe0W0/Fh9yM1R8yOnWGsN9FgtHSD1vHAc1s3SEhGwz83bCsao1quG3dnR7DNs\nufEfy4jscOw6UKSCjywALOXL7cpmJdyQROSBt2dCBFs8JyHdUUVOQAxzFdUptSY1\n3jNI1b6nXkYLYCyB5Ms0ux8W6AEcmqt/H0g2hZ+hKhQDe5kn+9BYgGydZs5w2gfo\nGlw/EJbE/xZIQSUMWb3n5jxvbnIF7puZbWwFr0ScN/HHtRAvp7erTmqZklrhqcbQ\nfIqUeih+R3WeglHqJwu4dmR5rQkUHniwAVPU1jSe7xYj9nanfv5g84md/TafypLp\nKY3Bjp5NzJP/EgGuEfWyBn4ORaCFmeyJYNIg8tGBV+/H9yr/+fO/Wx0KUTPOEOuY\nTThDLvlU162oV7qOO0fxi84y+z1emC1YyAXfYI2waDBg+iX4mqQSmz1xC35xn4cs\ngt+5dPcO/O4+/aPcQQET9BS/n9Xvot1RzulCs8A3XJz1lrIFhqndn1U3f+z4b1lu\nOudeN7LXjTPRivpEgXnvId+EHyO72fqTb/A/nUFyKxEq0tdC8n3BCUsXlXlx4ZUf\nNx0iImsrZFKr8IFoDNEXvbs+y9++h2W44eJdJvRJZjrkMOJhAIA9DaK3XJl914ck\n1/0jebCxk5ldby+beNSLi4LoFa/+HvLlbwgf9pgpSPSqOFoXmVSRNtjRPXO7yPDN\nvkMjO5xLrjNBE+LD60eJECwauaf7kOoyjNS8+PfYtHJx+An8IJ0yOnc7JeJcl5sC\n7yFq4260B59aIU3ZbFcbQ7rxSK5+7MAxpXlt/oVxz+/lONBiUYJ0nW52LElm9Vhg\nuHF21dExrQ+8HnG1eJhpx1vjDJyWXbyhZVHx9ebrYKjpQIHA/6sGsZG0OLV+TP2D\n8l/slQg2HpfjmmghDD/HDyeW/gnT528uDnYDAx0Ob/hupwva6N9IvldLHQd3RVlJ\nfka4jX2GZvjdpUywGy1dW++kS59Ms7nrt5P0/y2chg029d3ciHSufGTHhcdPRK3y\n1RdAIJtL+VCNR8/4CT6LLUHj+9eCcwww5xHDVZ63QhMvIPNn/DIUS48UcKM+hoFn\nniQBYMcWlP0mOV6hIbokwiXot/KjExBS8NE79XIwcKiejW8j4BId+oGL9etFm6dG\nL1mCL1FAhwpZoeOhTmhMNERH4hFHnTlYYnaHqRm8B5noCj245qIT/VsqCKW0NICi\nlmER7MOqPKvr6aUnC+8F5pYw0GeuoKWTOmVTO13fmhmV79bumaIMkZD1klZUkhJl\nqGvLj9ISJWu9vttpFOB2MLijDV5Sa9LcQYDEKCw9dqdG5BHs4uy+xtVRwA/oeJKX\ngSvzaQGJoffpFGhi5Jj26nyF8R19EtqVOKzRoxWoc9EfKPoWNEToR7ejT6LyomMx\nKtFZJ0cGgmUby2QjUpkq7T5TW9P29b8fBNAQ8bSYiV7PKh1gmk/IjTesPjtt0jiv\nKgowXEvK5IkXkSFbGtbmGvbQn5CwtNyMkv/TPhUmiVu8LRdwfO0Rr7JbaTLvymIQ\nma6ZATMyhNcnkCNgqJqAj85IT78hqqKGYQ7nGyPw2H7utErWFcSwLUwBAok/qOeb\nW2EFUykoTbF2EHjXjAO5b60CAwEAAQ==\n-----END PUBLIC KEY-----", "report_recipients": ["tom@mindlogger.com", "lucy@mindlogger.com"], - "report_include_user_id": false, - "report_include_case_id": false, - "report_email_body": "", + "report_include_user_id": true, + "report_include_case_id": true, + "report_email_body": "EMAIL BODY", "link": "51857e10-6c05-4fa8-a2c8-725b8c1a0aa6", "require_login": false }, @@ -173,4 +173,4 @@ }, "private_key": "-----BEGIN ENCRYPTED PRIVATE KEY-----\\nMIH8MFcGCSqGSIb3DQEFDTBKMCkGCSqGSIb3DQEFDDAcBAiHlug6D4hOpAICCAAw\\nDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEELYI7lu36P3Jb1OOFyDRLoAEgaAp\\np1M1RqHcfdIevAJQKPWkC3+B/Z2V7WbTgty4YNKy+I2kSk31FmyT9FmknIbpSVMd\\nKEBVV23OgRNTIbK3G0uIX0meisWXkpged+fHoZMIQ54Ala78UgA947kUcZB70vza\\nOTTo+RO0RHWPjNAgKKWCpIq65tb1iSjFEYjnPNHYtjlMJS54zFI0njnX22Me/xLz\\n389gAJb3hWT4rxhpDMlY\\n-----END ENCRYPTED PRIVATE KEY-----\\n" } -] \ No newline at end of file +] diff --git a/src/apps/applets/service/applet.py b/src/apps/applets/service/applet.py index 8ace6ecdaeb..ce5e00c9b64 100644 --- a/src/apps/applets/service/applet.py +++ b/src/apps/applets/service/applet.py @@ -45,6 +45,7 @@ ) from apps.applets.service.applet_history_service import AppletHistoryService from apps.folders.crud import FolderAppletCRUD, FolderCRUD +from apps.schedule.service import ScheduleService from apps.shared.version import ( INITIAL_VERSION, VERSION_DIFFERENCE_ACTIVITY, @@ -86,6 +87,11 @@ async def exist_by_id(self, applet_id: uuid.UUID): if not exists: raise AppletNotFoundError(key="id", value=str(applet_id)) + async def exist_by_ids(self, applet_ids: list[uuid.UUID]): + exists = await AppletsCRUD(self.session).exist_by_ids(ids=applet_ids) + if not exists: + raise AppletNotFoundError(key="id", value=str(applet_ids)) + async def get(self, applet_id: uuid.UUID): applet = await AppletsCRUD(self.session).get_by_id(applet_id) if not applet: @@ -132,7 +138,7 @@ async def create( manager_id: uuid.UUID | None = None, manager_role: Role | None = None, ) -> AppletFull: - applet = await self._create(create_data) + applet = await self._create(create_data, manager_id or self.user_id) await self._create_applet_accesses(applet.id, manager_id, manager_role) @@ -152,7 +158,9 @@ async def create( return applet - async def _create(self, create_data: AppletCreate) -> AppletFull: + async def _create( + self, create_data: AppletCreate, creator_id: uuid.UUID + ) -> AppletFull: applet_id = uuid.uuid4() await self._validate_applet_name(create_data.display_name) if not create_data.theme_id: @@ -180,6 +188,7 @@ async def _create(self, create_data: AppletCreate) -> AppletFull: if create_data.encryption else None, extra_fields=create_data.extra_fields, + creator_id=creator_id, ) ) return AppletFull.from_orm(schema) @@ -200,13 +209,17 @@ async def update( self.session, self.user_id ).remove_applet_activities(applet_id) applet = await self._update(applet_id, update_data, next_version) - applet.activities = await ActivityService( self.session, self.user_id ).update_create(applet_id, update_data.activities) activity_key_id_map = dict() + activity_ids = [] + assessment_id = None for activity in applet.activities: activity_key_id_map[activity.key] = activity.id + activity_ids.append(activity.id) + if activity.is_reviewable: + assessment_id = activity.id applet.activity_flows = await FlowService(self.session).update_create( applet_id, update_data.activity_flows, activity_key_id_map ) @@ -215,6 +228,19 @@ async def update( self.session, applet.id, applet.version ).add_history(self.user_id, applet) + event_serv = ScheduleService(self.session) + to_await = [] + if assessment_id: + to_await.append( + event_serv.delete_by_activity_ids(applet_id, [assessment_id]) + ) + to_await.append( + event_serv.create_default_schedules_if_not_exist( + applet_id=applet.id, + activity_ids=activity_ids, + ) + ) + await asyncio.gather(*to_await) return applet async def update_encryption( @@ -253,7 +279,7 @@ async def duplicate( applet_exist, new_name, encryption ) - applet = await self._create(create_data) + applet = await self._create(create_data, self.user_id) # TODO: move to api level await UserAppletAccessService( self.session, applet_owner.user_id, applet.id diff --git a/src/apps/applets/service/applet_change.py b/src/apps/applets/service/applet_change.py new file mode 100644 index 00000000000..9b457ddec4d --- /dev/null +++ b/src/apps/applets/service/applet_change.py @@ -0,0 +1,72 @@ +from apps.applets.domain import AppletHistory, AppletHistoryChange +from apps.shared.changes_generator import EMPTY_VALUES, BaseChangeGenerator + + +class AppletChangeService(BaseChangeGenerator): + field_name_verbose_name_map = { + "display_name": "Applet Name", + "description": "Applet Description", + "about": "About Applet Page", + "image": "Applet Image", + "watermark": "Applet Watermark", + "report_server_ip": "Server URL", + "report_public_key": "Public encryption key", + # Ask better verbose name + "report_recipients": "Email recipients", + "report_include_user_id": "Include respondent in the Subject and Attachment", # noqa E501 + "report_email_body": "Email Body", + "stream_enabled": "Enable streaming of response data", + } + + def compare( + self, old_applet: AppletHistory, new_applet: AppletHistory + ) -> AppletHistoryChange: + change = AppletHistoryChange() + if old_applet.version == new_applet.version: + change.display_name = f"New applet {new_applet.display_name} added" + change.changes = self.get_changes(None, new_applet) + else: + change.display_name = f"Applet {new_applet.display_name} updated" + change.changes = self.get_changes(old_applet, new_applet) + return change + + def get_changes( + self, old_applet: AppletHistory | None, new_applet: AppletHistory + ) -> list[str]: + changes = [] + for ( + field_name, + verbose_name, + ) in self.field_name_verbose_name_map.items(): + old_value = getattr(old_applet, field_name) if old_applet else None + new_value = getattr(new_applet, field_name) + # First just check that something was changed + if ( + old_value in EMPTY_VALUES + and new_value in EMPTY_VALUES + or new_value == old_value + ): + continue + if isinstance(new_value, bool): + changes.append( + self._change_text_generator.set_bool( + verbose_name, new_value + ) + ) + elif self._change_text_generator.is_considered_empty(new_value): + changes.append( + self._change_text_generator.cleared_text(verbose_name), + ) + elif self._change_text_generator.is_considered_empty(old_value): + changes.append( + self._change_text_generator.changed_text( + verbose_name, new_value, is_initial=True + ), + ) + else: + changes.append( + self._change_text_generator.changed_text( + verbose_name, new_value + ) + ) + return changes diff --git a/src/apps/applets/service/applet_history_service.py b/src/apps/applets/service/applet_history_service.py index c0fea531de8..a3578de2d7d 100644 --- a/src/apps/applets/service/applet_history_service.py +++ b/src/apps/applets/service/applet_history_service.py @@ -7,7 +7,7 @@ from apps.applets.domain import AppletHistory, AppletHistoryChange from apps.applets.domain.applet_full import AppletFull, AppletHistoryFull from apps.applets.errors import InvalidVersionError, NotValidAppletHistory -from apps.shared.changes_generator import ChangeGenerator +from apps.applets.service.applet_change import AppletChangeService from apps.shared.version import INITIAL_VERSION __all__ = ["AppletHistoryService"] @@ -39,7 +39,7 @@ async def add_history(self, performer_id: uuid.UUID, applet: AppletFull): report_include_user_id=applet.report_include_user_id, report_include_case_id=applet.report_include_case_id, report_email_body=applet.report_email_body, - extra_fields=applet.extra_fields, + stream_enabled=applet.stream_enabled, ) ) await ActivityHistoryService( @@ -59,12 +59,14 @@ async def get_changes(self) -> AppletHistoryChange: changes.activities = await ActivityHistoryService( self.session, self._applet_id, self._version ).get_changes(prev_version) + changes.activity_flows = await FlowHistoryService( + self.session, self._applet_id, self._version + ).get_changes(prev_version) return changes async def _get_applet_changes( self, old_id_version: str ) -> AppletHistoryChange: - changes = AppletHistoryChange() new_schema = await AppletHistoriesCRUD( self.session ).fetch_by_id_version(self._id_version) @@ -74,18 +76,8 @@ async def _get_applet_changes( new_history: AppletHistory = AppletHistory.from_orm(new_schema) old_history: AppletHistory = AppletHistory.from_orm(old_schema) - if old_id_version == self._id_version: - changes.display_name = ( - f"New applet {new_history.display_name} added" - ) - - return changes - changes.display_name = f"Applet {new_history.display_name} updated " - changes.changes = ChangeGenerator().generate_applet_changes( - new_history, old_history - ) - - return changes + change_service = AppletChangeService() + return change_service.compare(old_history, new_history) async def get(self) -> AppletHistory: schema = await AppletHistoriesCRUD(self.session).get_by_id_version( @@ -96,27 +88,23 @@ async def get(self) -> AppletHistory: return AppletHistory.from_orm(schema) async def get_prev_version(self): - id_versions = await AppletHistoriesCRUD( + versions = await AppletHistoriesCRUD( self.session ).get_id_versions_by_applet_id(self._applet_id) - versions = [v.replace(f"{self._applet_id}_", "") for v in id_versions] - versions.sort() - prev_version = "" + prev_version = INITIAL_VERSION if self._version in versions: prev_version = versions[max(versions.index(self._version) - 1, 0)] - else: - prev_version = INITIAL_VERSION return prev_version - async def get_full(self) -> AppletHistoryFull: + async def get_full(self, non_performance=False) -> AppletHistoryFull: schema = await AppletHistoriesCRUD(self.session).get_by_id_version( self._id_version ) applet = AppletHistoryFull.from_orm(schema) applet.activities = await ActivityHistoryService( self.session, self._applet_id, self._version - ).get_full() + ).get_full(non_performance) applet.activity_flows = await FlowHistoryService( self.session, self._applet_id, self._version ).get_full() diff --git a/src/apps/applets/tests/test_applet.py b/src/apps/applets/tests/test_applet.py index da3951bd860..464663d10f5 100644 --- a/src/apps/applets/tests/test_applet.py +++ b/src/apps/applets/tests/test_applet.py @@ -87,6 +87,7 @@ async def test_creating_applet(self): "image": "image.jpg", "tooltip": "backing", "color": "#123", + "value": 0, }, { "text": "bad", @@ -95,6 +96,7 @@ async def test_creating_applet(self): "image": "image.jpg", "tooltip": "Generic", "color": "#456", + "value": 1, }, { "text": "normally", @@ -103,6 +105,7 @@ async def test_creating_applet(self): "image": "image.jpg", "tooltip": "Gasoline", "color": "#789", + "value": 2, }, { "text": "perfect", @@ -111,6 +114,7 @@ async def test_creating_applet(self): "image": "image.jpg", "tooltip": "payment", "color": "#234", + "value": 3, }, ] }, @@ -141,6 +145,7 @@ async def test_creating_applet(self): "image": "image.jpg", "tooltip": "Music", "color": "#567", + "value": 0, }, { "text": "bad", @@ -149,6 +154,7 @@ async def test_creating_applet(self): "image": "image.jpg", "tooltip": "East", "color": "#876", + "value": 1, }, { "text": "normally", @@ -157,6 +163,7 @@ async def test_creating_applet(self): "image": "image.jpg", "tooltip": "Sodium", "color": "#923", + "value": 2, }, { "text": "perfect", @@ -165,6 +172,7 @@ async def test_creating_applet(self): "image": "image.jpg", "tooltip": "Electronics", "color": "#567", + "value": 3, }, ] }, @@ -1184,6 +1192,10 @@ async def test_applet_delete_by_coordinator(self): async def test_applet_list_with_invalid_token(self): from config import settings + current_access_token_expiration = ( + settings.authentication.access_token.expiration + ) + settings.authentication.access_token.expiration = 0.05 await self.client.login( self.login_url, "tom@mindlogger.com", "Test1234!" @@ -1191,6 +1203,9 @@ async def test_applet_list_with_invalid_token(self): await asyncio.sleep(4) response = await self.client.get(self.applet_list_url) + settings.authentication.access_token.expiration = ( + current_access_token_expiration + ) assert response.status_code == 401, response.json() @rollback @@ -1727,7 +1742,7 @@ async def test_history_changes(self): max_value=5, min_image=None, max_image=None, - scores=None, + scores=[0, 1, 2, 3, 4], ), config=dict( add_scores=True, @@ -1876,7 +1891,7 @@ async def test_history_changes(self): assert response.status_code == 200 assert ( response.json()["result"]["displayName"] - == "Applet User daily behave updated updated " + == "Applet User daily behave updated updated" ) assert len(response.json()["result"]["activities"]) == 4 diff --git a/src/apps/applets/tests/test_applet_activity_items.py b/src/apps/applets/tests/test_applet_activity_items.py index a365a70b0bd..af711a4fc34 100644 --- a/src/apps/applets/tests/test_applet_activity_items.py +++ b/src/apps/applets/tests/test_applet_activity_items.py @@ -1,9 +1,378 @@ +import copy import uuid +import pytest + +from apps.activities import errors as activity_errors +from apps.activities.domain.response_type_config import ( + ResponseType, + SingleSelectionConfig, +) +from apps.activities.domain.response_values import SingleSelectionValues from apps.shared.test import BaseTest from infrastructure.database import rollback +@pytest.fixture +def activity_flanker_data(): + return dict( + name="Activity_flanker", + key="577dbbda-3afc-4962-842b-8d8d11588bfe", + description=dict( + en="Description Activity flanker.", + fr="Description Activity flanker.", + ), + items=[ + dict( + name="Flanker_VSR_instructionsn", + # Nobody knows for what we need so big description + question=dict( + en="## General Instructions\n\n\n You will " + "see arrows presented at the center of the " + "screen that point either to the left ‘<’ " + "or right ‘>’.\n Press the left button " + "if the arrow is pointing to the left ‘<’ " + "or press the right button if the arrow is " + "pointing to the right ‘>’.\n These arrows " + "will appear in the center of a line of " + "other items. Sometimes, these other items " + "will be arrows pointing in the same " + "direction, e.g.. ‘> > > > >’, or in the " + "opposite direction, e.g. ‘< < > < <’.\n " + "Your job is to respond to the central " + "arrow, no matter what direction the other " + "arrows are pointing.\n For example, you " + "would press the left button for both " + "‘< < < < <’, and ‘> > < > >’ because the " + "middle arrow points to the left.\n " + "Finally, in some trials dashes ‘ - ’ " + "will appear beside the central arrow.\n " + "Again, respond only to the direction " + "of the central arrow. Please respond " + "as quickly and accurately as possible.", + fr="Flanker General instruction text.", + ), + response_type="message", + response_values=None, + config=dict( + remove_back_button=False, + timer=None, + ), + ), + dict( + name="Flanker_Practice_instructions_1", + question=dict( + en="## Instructions\n\nNow you will have a " + "chance to practice the task before moving " + "on to the test phase.\nRemember to " + "respond only to the central arrow\n", + fr="Flanker Сalibration/Practice " "instruction 1 text.", + ), + response_type="message", + response_values=None, + config=dict( + remove_back_button=False, + timer=None, + ), + ), + dict( + name="Flanker_Practise_1", + question=dict( + en="Flanker_Practise_1", + fr="Flanker_Practise_1", + ), + response_type="flanker", + response_values=None, + config=dict( + stimulusTrials=[ + { + "id": "1", + "image": "https://600.jpg", + "text": "left-con", + "value": 0, + "weight": 10, + }, + { + "id": "2", + "image": "https://600.jpg", + "text": "right-inc", + "value": 1, + "weight": 10, + }, + { + "id": "3", + "image": "https://600.jpg", + "text": "left-inc", + "value": 0, + "weight": 10, + }, + { + "id": "4", + "image": "https://600.jpg", + "text": "right-con", + "value": 1, + "weight": 10, + }, + { + "id": "5", + "image": "https://600.jpg", + "text": "left-neut", + "value": 0, + "weight": 10, + }, + { + "id": "6", + "image": "https://600.jpg", + "text": "right-neut", + "value": 1, + "weight": 10, + }, + ], + blocks=[ + { + "name": "Block 1", + "order": [ + "left-con", + "right-con", + "left-inc", + "right-inc", + "left-neut", + "right-neut", + ], + }, + { + "name": "Block 2", + "order": [ + "left-con", + "right-con", + "left-inc", + "right-inc", + "left-neut", + "right-neut", + ], + }, + ], + buttons=[ + { + "text": "Button_1_name_<", + "image": "https://1.jpg", + "value": 0, + }, + { + "text": "Button_2_name_>", + "image": "https://2.jpg", + "value": 1, + }, + ], + nextButton="OK", + fixationDuration=500, + fixationScreen={ + "value": "FixationScreen_value", + "image": "https://fixation-screen.jpg", + }, + minimumAccuracy=75, + sampleSize=1, + samplingMethod="randomize-order", + showFeedback=True, + showFixation=True, + showResults=False, + trialDuration=3000, + isLastPractice=False, + isFirstPractice=True, + isLastTest=False, + blockType="practice", + ), + ), + ], + ) + + +@pytest.fixture +def single_select_response_values(): + return dict( + options=[ + dict( + id=uuid.uuid4(), + text="text", + image=None, + score=None, + tooltip=None, + is_hidden=False, + color=None, + value=0, + ) + ] + ) + + +@pytest.fixture +def single_select_config(): + return dict( + randomize_options=False, + timer=0, + add_scores=False, + set_alerts=False, + add_tooltip=False, + set_palette=False, + remove_back_button=False, + skippable_item=False, + additional_response_option=dict( + text_input_option=False, + text_input_required=False, + ), + ) + + +@pytest.fixture +def applet_minimal_data(single_select_response_values, single_select_config): + return dict( + display_name="minimal required data to create applet", + encryption=dict( + public_key=uuid.uuid4().hex, + prime=uuid.uuid4().hex, + base=uuid.uuid4().hex, + account_id=str(uuid.uuid4()), + ), + description=dict(en="description"), + activities=[ + dict( + name="name", + key=uuid.uuid4(), + description=dict(en="description"), + items=[ + dict( + name="item1", + question=dict(en="question"), + response_type=ResponseType.SINGLESELECT, + response_values=single_select_response_values, + config=single_select_config, + ), + ], + ) + ], + # Empty, but required + activity_flows=[], + ) + + +@pytest.fixture +def slider_response_values(): + return dict( + min_value=0, + max_value=10, + min_label="min_label", + max_label="max_label", + min_image=None, + max_image=None, + scores=None, + alerts=None, + ) + + +@pytest.fixture +def slider_config(): + return dict( + remove_back_button=False, + skippable_item=False, + add_scores=False, + set_alerts=False, + timer=1, + show_tick_labels=False, + show_tick_marks=False, + continuous_slider=False, + additional_response_option={ + "text_input_option": False, + "text_input_required": False, + }, + ) + + +@pytest.fixture +def slider_rows_response_values(): + return dict( + rows=[ + { + "label": "label1", + "min_label": "min_label1", + "max_label": "max_label1", + "min_value": 0, + "max_value": 10, + "min_image": None, + "max_image": None, + "scores": None, + "alerts": None, + } + ] + ) + + +@pytest.fixture +def slider_rows_config(): + return dict( + remove_back_button=False, + skippable_item=False, + add_scores=False, + set_alerts=False, + timer=1, + ) + + +@pytest.fixture +def single_select_rows_response_values(): + return dict( + rows=[ + { + "id": "17e69155-22cd-4484-8a49-364779ea9df1", + "row_name": "row1", + "row_image": None, + "tooltip": None, + }, + ], + options=[ + { + "id": "17e69155-22cd-4484-8a49-364779ea9de1", + "text": "option1", + "image": None, + "tooltip": None, + } + ], + data_matrix=[ + { + "row_id": "17e69155-22cd-4484-8a49-364779ea9df1", + "options": [ + { + "option_id": "17e69155-22cd-4484-8a49-364779ea9de1", + "score": 1, + "alert": "alert1", + }, + ], + }, + { + "row_id": "17e69155-22cd-4484-8a49-364779ea9df2", + "options": [ + { + "option_id": "17e69155-22cd-4484-8a49-364779ea9de1", + "score": 3, + "alert": None, + }, + ], + }, + ], + ) + + +@pytest.fixture +def single_select_rows_config(): + return dict( + remove_back_button=False, + skippable_item=False, + add_scores=False, + set_alerts=False, + timer=1, + add_tooltip=False, + ) + + class TestActivityItems(BaseTest): fixtures = [ "users/fixtures/users.json", @@ -463,8 +832,8 @@ async def test_creating_applet_with_activity_items(self): response_values=dict( palette_name="palette1", options=[ - {"text": "option1"}, - {"text": "option2"}, + {"text": "option1", "value": 0}, + {"text": "option2", "value": 1}, ], ), config=dict( @@ -1145,228 +1514,6 @@ async def test_creating_applet_with_touch_activity_items(self): ) assert response.status_code == 200 - @rollback - async def test_creating_applet_with_flanker_activity_items(self): - await self.client.login( - self.login_url, "tom@mindlogger.com", "Test1234!" - ) - create_data = dict( - display_name="flanker_activity_applet", - encryption=dict( - public_key=uuid.uuid4().hex, - prime=uuid.uuid4().hex, - base=uuid.uuid4().hex, - account_id=str(uuid.uuid4()), - ), - description=dict( - en="Performance Tasks flanker Applet", - fr="Performance Tasks flanker Applet", - ), - about=dict( - en="Applet flanker Task Builder Activity", - fr="Applet flanker Task Builder Activity", - ), - activities=[ - dict( - name="Activity_flanker", - key="577dbbda-3afc-4962-842b-8d8d11588bfe", - description=dict( - en="Description Activity flanker.", - fr="Description Activity flanker.", - ), - items=[ - dict( - name="Flanker_VSR_instructionsn", - question=dict( - en="## General Instructions\n\n\n You will " - "see arrows presented at the center of the " - "screen that point either to the left ‘<’ " - "or right ‘>’.\n Press the left button " - "if the arrow is pointing to the left ‘<’ " - "or press the right button if the arrow is " - "pointing to the right ‘>’.\n These arrows " - "will appear in the center of a line of " - "other items. Sometimes, these other items " - "will be arrows pointing in the same " - "direction, e.g.. ‘> > > > >’, or in the " - "opposite direction, e.g. ‘< < > < <’.\n " - "Your job is to respond to the central " - "arrow, no matter what direction the other " - "arrows are pointing.\n For example, you " - "would press the left button for both " - "‘< < < < <’, and ‘> > < > >’ because the " - "middle arrow points to the left.\n " - "Finally, in some trials dashes ‘ - ’ " - "will appear beside the central arrow.\n " - "Again, respond only to the direction " - "of the central arrow. Please respond " - "as quickly and accurately as possible.", - fr="Flanker General instruction text.", - ), - response_type="message", - response_values=None, - config=dict( - remove_back_button=False, - timer=None, - ), - ), - dict( - name="Flanker_Practice_instructions_1", - question=dict( - en="## Instructions\n\nNow you will have a " - "chance to practice the task before moving " - "on to the test phase.\nRemember to " - "respond only to the central arrow\n", - fr="Flanker Сalibration/Practice " - "instruction 1 text.", - ), - response_type="message", - response_values=None, - config=dict( - remove_back_button=False, - timer=None, - ), - ), - dict( - name="Flanker_Practise_1", - question=dict( - en="Flanker_Practise_1", - fr="Flanker_Practise_1", - ), - response_type="flanker", - response_values=None, - config=dict( - stimulusTrials=[ - { - "id": "1", - "image": "https://600.jpg", - "text": "left-con", - "value": 0, - "weight": 10, - }, - { - "id": "2", - "image": "https://600.jpg", - "text": "right-inc", - "value": 1, - "weight": 10, - }, - { - "id": "3", - "image": "https://600.jpg", - "text": "left-inc", - "value": 0, - "weight": 10, - }, - { - "id": "4", - "image": "https://600.jpg", - "text": "right-con", - "value": 1, - "weight": 10, - }, - { - "id": "5", - "image": "https://600.jpg", - "text": "left-neut", - "value": 0, - "weight": 10, - }, - { - "id": "6", - "image": "https://600.jpg", - "text": "right-neut", - "value": 1, - "weight": 10, - }, - ], - blocks=[ - { - "name": "Block 1", - "order": [ - "left-con", - "right-con", - "left-inc", - "right-inc", - "left-neut", - "right-neut", - ], - }, - { - "name": "Block 2", - "order": [ - "left-con", - "right-con", - "left-inc", - "right-inc", - "left-neut", - "right-neut", - ], - }, - ], - buttons=[ - { - "text": "Button_1_name_<", - "image": "https://1.jpg", - "value": 0, - }, - { - "text": "Button_2_name_>", - "image": "https://2.jpg", - "value": 1, - }, - ], - nextButton="OK", - fixationDuration=500, - fixationScreen={ - "value": "FixationScreen_value", - "image": "https://fixation-screen.jpg", - }, - minimumAccuracy=75, - sampleSize=1, - samplingMethod="randomize-order", - showFeedback=True, - showFixation=True, - showResults=False, - trialDuration=3000, - isLastPractice=False, - isFirstPractice=True, - isLastTest=False, - blockType="practice", - ), - ), - ], - ), - ], - activity_flows=[ - dict( - name="name_activityFlow", - description=dict( - en="description activityFlow", - fr="description activityFlow", - ), - items=[ - dict( - activity_key="577dbbda-3afc-" - "4962-842b-8d8d11588bfe" - ) - ], - ) - ], - ) - response = await self.client.post( - self.applet_create_url.format( - owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" - ), - data=create_data, - ) - assert response.status_code == 201, response.json() - - response = await self.client.get( - self.applet_detail_url.format(pk=response.json()["result"]["id"]) - ) - assert response.status_code == 200 - @rollback async def test_creating_applet_with_activity_items_condition(self): await self.client.login( @@ -1487,9 +1634,9 @@ async def test_creating_applet_with_activity_items_condition(self): showScoreSummary=True, reports=[ dict( - name="activity_item_singleselect", + name="activity_item_singleselect_score", type="score", - id="activity_item_singleselect", + id="activity_item_singleselect_score", calculationType="sum", minScore=0, maxScore=3, @@ -1504,7 +1651,7 @@ async def test_creating_applet_with_activity_items_condition(self): conditionalLogic=[ dict( name="score1_condition1", - id="activity_item_singleselect", + id="activity_item_singleselect_score", flagScore=True, message="Hello2", match="any", @@ -1512,7 +1659,7 @@ async def test_creating_applet_with_activity_items_condition(self): dict( item_name=( "activity_item_" - "singleselect" + "singleselect_score" ), type="GREATER_THAN", payload=dict( @@ -1522,11 +1669,11 @@ async def test_creating_applet_with_activity_items_condition(self): dict( item_name=( "activity_item_" - "singleselect" + "singleselect_score" ), type="GREATER_THAN", payload=dict( - value=2, + value=1, ), ), ], @@ -1548,7 +1695,7 @@ async def test_creating_applet_with_activity_items_condition(self): conditions=[ dict( item_name=( - "activity_item_singleselect" + "activity_item_singleselect_score" # noqa E501 ), type="GREATER_THAN", payload=dict( @@ -1557,13 +1704,31 @@ async def test_creating_applet_with_activity_items_condition(self): ), dict( item_name=( - "activity_item_singleselect" # noqa E501 + "activity_item_singleselect_score" # noqa E501 ), type="EQUAL_TO_OPTION", payload=dict( option_value="1", # noqa E501 ), ), + dict( + item_name=( + "activity_item_singleselect_score" # noqa E501 + ), + type="NOT_EQUAL_TO_OPTION", + payload=dict( + option_value="2", # noqa E501 + ), + ), + dict( + item_name=( + "activity_item_multiselect" # noqa E501 + ), + type="NOT_INCLUDES_OPTION", + payload=dict( + option_value="1", # noqa E501 + ), + ), ], ), ), @@ -1606,68 +1771,6 @@ async def test_creating_applet_with_activity_items_condition(self): }, ), ), - dict( - name="activity_item_multiselect", - question={"en": "What is your name?"}, - response_type="multiSelect", - response_values=dict( - palette_name="palette1", - options=[ - { - "text": "option1", - "id": "27e69155-22cd-4484-8a49-364779ea9de1", # noqa E501 - "value": "1", - }, - { - "text": "option2", - "id": "28e69155-22cd-4484-8a49-364779ea9de1", # noqa E501 - "value": "2", - }, - ], - ), - config=dict( - remove_back_button=False, - skippable_item=False, - add_scores=False, - set_alerts=False, - timer=1, - add_tooltip=False, - set_palette=False, - randomize_options=False, - additional_response_option={ - "text_input_option": False, - "text_input_required": False, - }, - ), - ), - dict( - name="activity_item_slideritem", - question={"en": "What is your name?"}, - response_type="slider", - response_values=dict( - min_value=0, - max_value=10, - min_label="min_label", - max_label="max_label", - min_image=None, - max_image=None, - scores=None, - ), - config=dict( - remove_back_button=False, - skippable_item=False, - add_scores=False, - set_alerts=False, - timer=1, - show_tick_labels=False, - show_tick_marks=False, - continuous_slider=False, - additional_response_option={ - "text_input_option": False, - "text_input_required": False, - }, - ), - ), dict( name="activity_item_text", question=dict( @@ -1696,6 +1799,13 @@ async def test_creating_applet_with_activity_items_condition(self): option_value="1" # noqa E501 ), ), + dict( + item_name="activity_item_singleselect_2", # noqa: E501 + type="NOT_EQUAL_TO_OPTION", + payload=dict( + option_value="2" # noqa E501 + ), + ), dict( item_name="activity_item_multiselect", type="INCLUDES_OPTION", @@ -1703,6 +1813,13 @@ async def test_creating_applet_with_activity_items_condition(self): option_value="1" # noqa E501 ), ), + dict( + item_name="activity_item_multiselect_2", # noqa: E501 + type="NOT_INCLUDES_OPTION", + payload=dict( + option_value="2" # noqa E501 + ), + ), dict( item_name="activity_item_slideritem", type="GREATER_THAN", @@ -1710,13 +1827,181 @@ async def test_creating_applet_with_activity_items_condition(self): value=5, ), ), + dict( + item_name="activity_item_slideritem_2", + type="OUTSIDE_OF", + payload=dict( + min_value=5, + max_value=10, + ), + ), ], ), ), dict( - name="activity_item_time_range", + name="activity_item_singleselect_2", question={"en": "What is your name?"}, - response_type="timeRange", + response_type="singleSelect", + response_values=dict( + palette_name="palette1", + options=[ + { + "text": "option1", + "score": 1, + "id": "25e69155-22cd-4484-8a49-364779fa9de1", # noqa E501 + "value": "1", + }, + { + "text": "option2", + "score": 2, + "id": "26e69155-22cd-4484-8a49-364779fa9de1", # noqa E501 + "value": "2", + }, + ], + ), + config=dict( + remove_back_button=False, + skippable_item=False, + add_scores=True, + set_alerts=False, + timer=1, + add_tooltip=False, + set_palette=False, + randomize_options=False, + additional_response_option={ + "text_input_option": False, + "text_input_required": False, + }, + ), + ), + dict( + name="activity_item_multiselect", + question={"en": "What is your name?"}, + response_type="multiSelect", + response_values=dict( + palette_name="palette1", + options=[ + { + "text": "option1", + "id": "27e69155-22cd-4484-8a49-364779ea9de1", # noqa E501 + "value": "1", + }, + { + "text": "option2", + "id": "28e69155-22cd-4484-8a49-364779ea9de1", # noqa E501 + "value": "2", + }, + ], + ), + config=dict( + remove_back_button=False, + skippable_item=False, + add_scores=False, + set_alerts=False, + timer=1, + add_tooltip=False, + set_palette=False, + randomize_options=False, + additional_response_option={ + "text_input_option": False, + "text_input_required": False, + }, + ), + ), + dict( + name="activity_item_multiselect_2", + question={"en": "Option 2?"}, + response_type="multiSelect", + response_values=dict( + palette_name="palette1", + options=[ + { + "text": "option1", + "id": "27e69155-22cd-4484-8a49-364779eb9de1", # noqa E501 + "value": "1", + }, + { + "text": "option2", + "id": "28e69155-22cd-4484-8a49-364779eb9de1", # noqa E501 + "value": "2", + }, + ], + ), + config=dict( + remove_back_button=False, + skippable_item=False, + add_scores=False, + set_alerts=False, + timer=1, + add_tooltip=False, + set_palette=False, + randomize_options=False, + additional_response_option={ + "text_input_option": False, + "text_input_required": False, + }, + ), + ), + dict( + name="activity_item_slideritem", + question={"en": "What is your name?"}, + response_type="slider", + response_values=dict( + min_value=0, + max_value=10, + min_label="min_label", + max_label="max_label", + min_image=None, + max_image=None, + scores=None, + ), + config=dict( + remove_back_button=False, + skippable_item=False, + add_scores=False, + set_alerts=False, + timer=1, + show_tick_labels=False, + show_tick_marks=False, + continuous_slider=False, + additional_response_option={ + "text_input_option": False, + "text_input_required": False, + }, + ), + ), + dict( + name="activity_item_slideritem_2", + question={"en": "What is your name?"}, + response_type="slider", + response_values=dict( + min_value=0, + max_value=10, + min_label="min_label", + max_label="max_label", + min_image=None, + max_image=None, + scores=None, + ), + config=dict( + remove_back_button=False, + skippable_item=False, + add_scores=False, + set_alerts=False, + timer=1, + show_tick_labels=False, + show_tick_marks=False, + continuous_slider=False, + additional_response_option={ + "text_input_option": False, + "text_input_required": False, + }, + ), + ), + dict( + name="activity_item_time_range", + question={"en": "What is your name?"}, + response_type="timeRange", response_values=None, config=dict( additional_response_option={ @@ -1807,6 +2092,23 @@ async def test_creating_applet_with_activity_items_condition(self): ) ], ) + response = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=create_data, + ) + assert response.status_code == 400 + assert ( + response.json()["result"][0]["message"] + == activity_errors.IncorrectConditionItemIndexError.message + ) + + text_item = create_data["activities"][0]["items"][1] + slider_item_2 = create_data["activities"][0]["items"][6] + create_data["activities"][0]["items"][1] = slider_item_2 + create_data["activities"][0]["items"][6] = text_item + response = await self.client.post( self.applet_create_url.format( owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" @@ -1816,7 +2118,7 @@ async def test_creating_applet_with_activity_items_condition(self): assert response.status_code == 201, response.json() assert ( type( - response.json()["result"]["activities"][0]["items"][3][ + response.json()["result"]["activities"][0]["items"][6][ "conditionalLogic" ] ) @@ -1843,7 +2145,7 @@ async def test_creating_applet_with_activity_items_condition(self): ) assert response.status_code == 200 assert ( - type(response.json()["result"]["items"][3]["conditionalLogic"]) + type(response.json()["result"]["items"][6]["conditionalLogic"]) == dict ) @@ -2030,10 +2332,12 @@ async def test_creating_activity_items_without_option_value(self): { "text": "option1", "alert": "alert1", + "value": 0, }, { "text": "option2", "alert": "alert2", + "value": 1, }, ], ), @@ -2115,7 +2419,7 @@ async def test_creating_activity_items_without_option_value(self): "max_value": 5, "min_image": None, "max_image": None, - "score": [1, 2, 3, 4, 5], + "scores": [1, 2, 3, 4, 5], } ] ), @@ -2135,41 +2439,86 @@ async def test_creating_activity_items_without_option_value(self): assert response.status_code == 200 @rollback - async def test_create_applet_with_preformance_activity_item(self): + async def test_create_applet_with_flanker_preformance_task( + self, activity_flanker_data + ): await self.client.login( self.login_url, "tom@mindlogger.com", "Test1234!" ) create_data = dict( - display_name="User daily behave", + display_name="Flanker", encryption=dict( public_key=uuid.uuid4().hex, prime=uuid.uuid4().hex, base=uuid.uuid4().hex, account_id=str(uuid.uuid4()), ), - description=dict( - en="Understand users behave", - fr="Comprendre le comportement des utilisateurs", + description=dict(en="Flanker", fr="Flanker"), + about=dict(en="Flanker", fr="Flanker"), + activities=[activity_flanker_data], + # Empty, but required + activity_flows=[], + ) + + response = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" ), - about=dict( - en="Understand users behave", - fr="Comprendre le comportement des utilisateurs", + data=create_data, + ) + assert response.status_code == 201, response.json() + + assert response.json()["result"]["activities"][0]["isPerformanceTask"] + assert ( + response.json()["result"]["activities"][0]["performanceTaskType"] + == "flanker" + ) + + # Check that the 'get' after creating new applet returns correct data + response = await self.client.get( + self.applet_workspace_detail_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + pk=response.json()["result"]["id"], + ) + ) + assert response.status_code == 200 + assert response.json()["result"]["activities"][0]["isPerformanceTask"] + assert ( + response.json()["result"]["activities"][0]["performanceTaskType"] + == "flanker" + ) + + @rollback + async def test_applet_add_performance_task_to_the_applet( + self, activity_flanker_data + ): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + + create_data = dict( + display_name="Add flanker to existing applet", + encryption=dict( + public_key=uuid.uuid4().hex, + prime=uuid.uuid4().hex, + base=uuid.uuid4().hex, + account_id=str(uuid.uuid4()), ), + description=dict(en="Add flanker to existing applet"), + about=dict(en="Add flanker to existing applet"), activities=[ dict( name="Morning activity", key="577dbbda-3afc-4962-842b-8d8d11588bfe", description=dict( en="Understand morning feelings.", - fr="Understand morning feelings.", ), items=[ dict( name="activity_item_text", question=dict( en="How had you slept?", - fr="How had you slept?", ), response_type="text", response_values=None, @@ -2187,226 +2536,47 @@ async def test_create_applet_with_preformance_activity_item(self): ], ), ], - activity_flows=[ - dict( - name="Morning questionnaire", - description=dict( - en="Understand how was the morning", - fr="Understand how was the morning", - ), - items=[ - dict( - activity_key="577dbbda-3afc-" - "4962-842b-8d8d11588bfe" - ) - ], - ) - ], + # Empty, but required + activity_flows=[], ) + response = await self.client.post( self.applet_create_url.format( owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" ), data=create_data, ) - assert response.status_code == 201, response.json() - assert ( - response.json()["result"]["activities"][0]["isPerformanceTask"] - is False - ) - assert ( - response.json()["result"]["activities"][0]["performanceTaskType"] - is None + assert response.status_code == 201 + activity = response.json()["result"]["activities"][0] + assert not activity["isPerformanceTask"] + assert not activity["performanceTaskType"] + # Test that get after creating new applet returns correct data + # Generaly we don't need to test, tested data, but for now let leave + # it here + response = await self.client.get( + self.applet_workspace_detail_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + pk=response.json()["result"]["id"], + ) ) + assert response.status_code == 200 + activity = response.json()["result"]["activities"][0] + assert not activity["isPerformanceTask"] + assert not activity["performanceTaskType"] - create_data["activities"] = [ - dict( - name="Activity_flanker", - key="577dbbda-3afc-4962-842b-8d8d11588bfe", - description=dict( - en="Description Activity flanker.", - fr="Description Activity flanker.", - ), - items=[ - dict( - name="Flanker_VSR_instructionsn", - question=dict( - en="## General Instructions\n\n\n You will " - "see arrows presented at the center of the " - "screen that point either to the left ‘<’ " - "or right ‘>’.\n Press the left button " - "if the arrow is pointing to the left ‘<’ " - "or press the right button if the arrow is " - "pointing to the right ‘>’.\n These arrows " - "will appear in the center of a line of " - "other items. Sometimes, these other items " - "will be arrows pointing in the same " - "direction, e.g.. ‘> > > > >’, or in the " - "opposite direction, e.g. ‘< < > < <’.\n " - "Your job is to respond to the central " - "arrow, no matter what direction the other " - "arrows are pointing.\n For example, you " - "would press the left button for both " - "‘< < < < <’, and ‘> > < > >’ because the " - "middle arrow points to the left.\n " - "Finally, in some trials dashes ‘ - ’ " - "will appear beside the central arrow.\n " - "Again, respond only to the direction " - "of the central arrow. Please respond " - "as quickly and accurately as possible.", - fr="Flanker General instruction text.", - ), - response_type="message", - response_values=None, - config=dict( - remove_back_button=False, - timer=None, - ), - ), - dict( - name="Flanker_Practice_instructions_1", - question=dict( - en="## Instructions\n\nNow you will have a " - "chance to practice the task before moving " - "on to the test phase.\nRemember to " - "respond only to the central arrow\n", - fr="Flanker Сalibration/Practice " - "instruction 1 text.", - ), - response_type="message", - response_values=None, - config=dict( - remove_back_button=False, - timer=None, - ), - ), - dict( - name="Flanker_Practise_1", - question=dict( - en="Flanker_Practise_1", - fr="Flanker_Practise_1", - ), - response_type="flanker", - response_values=None, - config=dict( - stimulusTrials=[ - { - "id": "1", - "image": "https://600.jpg", - "text": "left-con", - "value": 0, - "weight": 10, - }, - { - "id": "2", - "image": "https://600.jpg", - "text": "right-inc", - "value": 1, - "weight": 10, - }, - { - "id": "3", - "image": "https://600.jpg", - "text": "left-inc", - "value": 0, - "weight": 10, - }, - { - "id": "4", - "image": "https://600.jpg", - "text": "right-con", - "value": 1, - "weight": 10, - }, - { - "id": "5", - "image": "https://600.jpg", - "text": "left-neut", - "value": 0, - "weight": 10, - }, - { - "id": "6", - "image": "https://600.jpg", - "text": "right-neut", - "value": 1, - "weight": 10, - }, - ], - blocks=[ - { - "name": "Block 1", - "order": [ - "left-con", - "right-con", - "left-inc", - "right-inc", - "left-neut", - "right-neut", - ], - }, - { - "name": "Block 2", - "order": [ - "left-con", - "right-con", - "left-inc", - "right-inc", - "left-neut", - "right-neut", - ], - }, - ], - buttons=[ - { - "text": "Button_1_name_<", - "image": "https://1.jpg", - "value": 0, - }, - { - "text": "Button_2_name_>", - "image": "https://2.jpg", - "value": 1, - }, - ], - nextButton="OK", - fixationDuration=500, - fixationScreen={ - "value": "FixationScreen_value", - "image": "https://fixation-screen.jpg", - }, - minimumAccuracy=75, - sampleSize=1, - samplingMethod="randomize-order", - showFeedback=True, - showFixation=True, - showResults=False, - trialDuration=3000, - isLastPractice=False, - isFirstPractice=True, - isLastTest=False, - blockType="practice", - ), - ), - ], - ), - ] + # Add flanker performance task + create_data["activities"].append(activity_flanker_data) response = await self.client.put( self.applet_detail_url.format(pk=response.json()["result"]["id"]), data=create_data, ) assert response.status_code == 200 + flanker = response.json()["result"]["activities"][1] + assert flanker["isPerformanceTask"] + assert flanker["performanceTaskType"] == "flanker" - assert ( - response.json()["result"]["activities"][0]["isPerformanceTask"] - is True - ) - assert ( - response.json()["result"]["activities"][0]["performanceTaskType"] - == "flanker" - ) - + # Check the 'get' method response = await self.client.get( self.applet_workspace_detail_url.format( owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", @@ -2414,11 +2584,352 @@ async def test_create_applet_with_preformance_activity_item(self): ) ) assert response.status_code == 200 + flanker = response.json()["result"]["activities"][1] + assert flanker["isPerformanceTask"] + assert flanker["performanceTaskType"] == "flanker" + + @rollback + async def test_create_applet_item_name_is_not_valid( + self, applet_minimal_data + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + applet_minimal_data["activities"][0]["items"][0]["name"] = "%name" + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 + assert ( + errors[0]["message"] + == activity_errors.IncorrectNameCharactersError.message + ) + + @rollback + async def test_create_applet_item_config_not_valid( + self, applet_minimal_data + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + del applet_minimal_data["activities"][0]["items"][0]["config"][ + "add_scores" + ] + del applet_minimal_data["activities"][0]["items"][0]["config"][ + "set_alerts" + ] + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 + assert errors[0][ + "message" + ] == activity_errors.IncorrectConfigError.message.format( + type=SingleSelectionConfig + ) + + @rollback + async def test_create_applet_not_valid_response_type( + self, applet_minimal_data + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + applet_minimal_data["activities"][0]["items"][0][ + "response_type" + ] = "NotValid" + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 + assert errors[0][ + "message" + ] == activity_errors.IncorrectResponseValueError.message.format( + type=ResponseType + ) + + @rollback + @pytest.mark.parametrize( + "value,error_msg", + ( + ( + {}, + activity_errors.IncorrectResponseValueError.message.format( + type=SingleSelectionValues + ), + ), + ( + None, + activity_errors.IncorrectResponseValueError.message.format( + type=SingleSelectionValues + ), + ), + ), + ) + async def test_create_applet_not_valid_response_values( # noqa: E501 + self, applet_minimal_data, value, error_msg + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + applet_minimal_data["activities"][0]["items"][0][ + "response_values" + ] = value + applet_minimal_data["activities"][0]["items"][0][ + "response_type" + ] = ResponseType.SINGLESELECT + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 + assert errors[0]["message"] == error_msg + + @rollback + async def test_create_applet_without_item_response_type( + self, applet_minimal_data + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + del applet_minimal_data["activities"][0]["items"][0]["response_type"] + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 + assert errors[0]["message"] == "field required" + + @rollback + async def test_create_applet_single_select_add_scores_not_scores_in_response_values( # noqa: E501 + self, applet_minimal_data + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + applet_minimal_data["activities"][0]["items"][0]["config"][ + "add_scores" + ] = True + applet_minimal_data["activities"][0]["items"][0][ + "response_type" + ] = ResponseType.SINGLESELECT + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 assert ( - response.json()["result"]["activities"][0]["isPerformanceTask"] - is True + errors[0]["message"] + == activity_errors.ScoreRequiredForResponseValueError.message + ) + + @rollback + async def test_create_applet_slider_response_values_add_scores_not_scores_in_response_values( # noqa: E501 + self, applet_minimal_data, slider_response_values, slider_config + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" ) + slider_config["add_scores"] = True + applet_minimal_data["activities"][0]["items"][0][ + "config" + ] = slider_config + applet_minimal_data["activities"][0]["items"][0][ + "response_type" + ] = ResponseType.SLIDER + applet_minimal_data["activities"][0]["items"][0][ + "response_values" + ] = slider_response_values + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 + assert errors[0]["message"] == activity_errors.NullScoreError.message + + @rollback + async def test_create_applet_slider_response_values_add_scores_scores_not_for_all_values( # noqa: E501 + self, applet_minimal_data, slider_response_values, slider_config + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + slider_config["add_scores"] = True + min_val = slider_response_values["min_value"] + max_val = slider_response_values["max_value"] + scores = [i for i in range(max_val - min_val)] + slider_response_values["scores"] = scores + applet_minimal_data["activities"][0]["items"][0][ + "config" + ] = slider_config + applet_minimal_data["activities"][0]["items"][0][ + "response_type" + ] = ResponseType.SLIDER + applet_minimal_data["activities"][0]["items"][0][ + "response_values" + ] = slider_response_values + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 assert ( - response.json()["result"]["activities"][0]["performanceTaskType"] - == "flanker" + errors[0]["message"] + == activity_errors.InvalidScoreLengthError.message + ) + + @rollback + async def test_create_applet_slider_rows_response_values_add_scores_true_no_scores( # noqa: E501 + self, + applet_minimal_data, + slider_rows_response_values, + slider_rows_config, + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + slider_rows_config["add_scores"] = True + slider_rows_response_values["rows"][0]["scores"] = None + item = applet_minimal_data["activities"][0]["items"][0] + item["config"] = slider_rows_config + item["response_type"] = ResponseType.SLIDERROWS + item["response_values"] = slider_rows_response_values + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 + assert errors[0]["message"] == activity_errors.NullScoreError.message + + @rollback + async def test_create_applet_slider_rows_response_values_add_scores_true_scores_not_for_all_values( # noqa: E501 + self, + applet_minimal_data, + slider_rows_response_values, + slider_rows_config, + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + slider_rows_config["add_scores"] = True + min_val = slider_rows_response_values["rows"][0]["min_value"] + max_val = slider_rows_response_values["rows"][0]["max_value"] + slider_rows_response_values["rows"][0]["scores"] = [ + i for i in range(max_val - min_val) + ] + item = applet_minimal_data["activities"][0]["items"][0] + item["config"] = slider_rows_config + item["response_type"] = ResponseType.SLIDERROWS + item["response_values"] = slider_rows_response_values + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 + assert ( + errors[0]["message"] + == activity_errors.InvalidScoreLengthError.message + ) + + @rollback + @pytest.mark.parametrize( + "response_type", (ResponseType.SINGLESELECT, ResponseType.MULTISELECT) + ) + async def test_create_applet_single_multi_select_response_values_value_null_auto_set_value( # noqa: E501 + self, applet_minimal_data, response_type + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + item = applet_minimal_data["activities"][0]["items"][0] + option = item["response_values"]["options"][0] + del option["value"] + option2 = copy.deepcopy(option) + option2["value"] = None + item["response_values"]["options"].append(option2) + item["response_type"] = response_type + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 201 + item = resp.json()["result"]["activities"][0]["items"][0] + # We can use enumerate because we have 2 options and values should be + # 0 and 1 + for i, o in enumerate(item["responseValues"]["options"]): + assert o["value"] == i + + @rollback + async def test_create_applet_single_select_rows_response_values_add_alerts_no_datamatrix( # noqa: E501 + self, + applet_minimal_data, + single_select_rows_response_values, + single_select_rows_config, + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + single_select_rows_config["set_alerts"] = True + single_select_rows_response_values["data_matrix"] = None + item = applet_minimal_data["activities"][0]["items"][0] + item["config"] = single_select_rows_config + item["response_type"] = ResponseType.SINGLESELECTROWS + item["response_values"] = single_select_rows_response_values + resp = await self.client.post( + self.applet_create_url.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1" + ), + data=applet_minimal_data, + ) + assert resp.status_code == 422 + errors = resp.json()["result"] + assert len(errors) == 1 + assert ( + errors[0]["message"] + == activity_errors.DataMatrixRequiredError.message ) diff --git a/src/apps/applets/tests/unit/test_applet_changes.py b/src/apps/applets/tests/unit/test_applet_changes.py new file mode 100644 index 00000000000..a916887be25 --- /dev/null +++ b/src/apps/applets/tests/unit/test_applet_changes.py @@ -0,0 +1,248 @@ +import uuid + +import pytest + +from apps.applets.domain import AppletHistory +from apps.applets.service.applet_change import AppletChangeService +from apps.shared.enums import Language +from apps.shared.version import INITIAL_VERSION + +# Just change minor +NEW_VERSION = INITIAL_VERSION.replace("0", "1") + + +@pytest.fixture +def applet() -> AppletHistory: + return AppletHistory(display_name="Applet", version=INITIAL_VERSION) + + +@pytest.fixture +def new_applet() -> AppletHistory: + return AppletHistory(display_name="Applet", version=NEW_VERSION) + + +@pytest.fixture +def applet_change_service(scope="module") -> AppletChangeService: + return AppletChangeService() + + +def test_get_changes_theme_id_is_not_tracked( + applet: AppletHistory, + new_applet: AppletHistory, + applet_change_service: AppletChangeService, +) -> None: + new_applet.theme_id = uuid.uuid4() + changes = applet_change_service.get_changes(applet, new_applet) + assert not changes + + +def test_get_changes_not_set_fields_are_not_tracked( + applet: AppletHistory, + new_applet: AppletHistory, + applet_change_service: AppletChangeService, +) -> None: + changes = applet_change_service.get_changes(applet, new_applet) + assert not changes + + +def test_get_changes_the_same_values_are_not_tracked( + applet: AppletHistory, + new_applet: AppletHistory, + applet_change_service: AppletChangeService, +): + applet.image = "test" + new_applet.image = "test" + changes = applet_change_service.get_changes(applet, new_applet) + assert not changes + + +def test_get_changes_is_initial_applet_only_with_name( + new_applet: AppletHistory, + applet_change_service: AppletChangeService, +): + changes = applet_change_service.get_changes(None, new_applet) + assert len(changes) == 1 + assert changes[0] == f"Applet Name was set to {new_applet.display_name}" + + +@pytest.mark.parametrize( + "field_name, value, change", + ( + ( + "description", + {"en": "NEW"}, + "Applet Description was set to NEW", + ), + ( + "about", + {"en": "NEW"}, + "About Applet Page was set to NEW", + ), + ("image", "image", "Applet Image was set to image"), + ( + "watermark", + "watermark", + "Applet Watermark was set to watermark", + ), + ( + "report_server_ip", + "http://localhost", + "Server URL was set to http://localhost", + ), + ( + "report_public_key", + "public key", + "Public encryption key was set to public key", + ), + ( + "report_recipients", + ["test@example.com"], + "Email recipients was set to test@example.com", + ), + ( + "report_include_user_id", + True, + "Include respondent in the Subject and Attachment was enabled", + ), + ( + "report_email_body", + "email body", + "Email Body was set to email body", + ), + ( + "stream_enabled", + True, + "Enable streaming of response data was enabled", + ), + ), +) +def test_get_changes_is_initial_version( + new_applet: AppletHistory, + applet_change_service: AppletChangeService, + field_name: str, + value: str | dict | bool, + change: str, +): + setattr(new_applet, field_name, value) + changes = applet_change_service.get_changes(None, new_applet) + assert len(changes) == 2 + assert changes[1] == change + + +@pytest.mark.parametrize( + "field_name, value, change", + ( + ( + "description", + {"en": "NEW"}, + "Applet Description was set to NEW", + ), + ( + "about", + {"en": "NEW"}, + "About Applet Page was set to NEW", + ), + ("image", "image", "Applet Image was set to image"), + ( + "watermark", + "watermark", + "Applet Watermark was set to watermark", + ), + ( + "report_server_ip", + "http://localhost", + "Server URL was set to http://localhost", + ), + ( + "report_public_key", + "public key", + "Public encryption key was set to public key", + ), + ( + "report_recipients", + ["test@example.com"], + "Email recipients was set to test@example.com", + ), + ( + "report_include_user_id", + True, + "Include respondent in the Subject and Attachment was enabled", + ), + ( + "report_email_body", + "email body", + "Email Body was set to email body", + ), + ( + "stream_enabled", + True, + "Enable streaming of response data was enabled", + ), + ), +) +def test_get_changes_applet_updated( + applet: AppletHistory, + new_applet: AppletHistory, + applet_change_service: AppletChangeService, + field_name: str, + value: str | dict | bool, + change: str, +): + setattr(new_applet, field_name, value) + changes = applet_change_service.get_changes(applet, new_applet) + assert len(changes) == 1 + assert changes[0] == change + + +def test_get_changes_new_applet_bool_fields_disabled( + applet: AppletHistory, + new_applet: AppletHistory, + applet_change_service: AppletChangeService, +): + applet.report_include_user_id = True + applet.stream_enabled = True + new_applet.report_include_user_id = False + new_applet.stream_enabled = False + changes = applet_change_service.get_changes(applet, new_applet) + assert len(changes) == 2 + exp_changes = [ + "Enable streaming of response data was disabled", + "Include respondent in the Subject and Attachment was disabled", + ] + for change in exp_changes: + assert change in changes + + +def test_get_changes_new_applet_text_field_is_cleared( + applet: AppletHistory, + new_applet: AppletHistory, + applet_change_service: AppletChangeService, +): + applet.description = {Language.ENGLISH: "Description"} + new_applet.description = {Language.ENGLISH: ""} + changes = applet_change_service.get_changes(applet, new_applet) + assert len(changes) == 1 + assert changes[0] == "Applet Description was cleared" + + +def test_get_changes_new_applet_text_field_was_changed( + applet: AppletHistory, + new_applet: AppletHistory, + applet_change_service: AppletChangeService, +): + applet.description = {Language.ENGLISH: "Description"} + new_applet.description = {Language.ENGLISH: "New"} + changes = applet_change_service.get_changes(applet, new_applet) + assert len(changes) == 1 + assert changes[0] == "Applet Description was changed to New" + + +def test_compare_two_applets( + applet: AppletHistory, applet_change_service: AppletChangeService +): + change = applet_change_service.compare(applet, applet) + assert change.display_name == f"New applet {applet.display_name} added" + assert change.changes == [f"Applet Name was set to {applet.display_name}"] + # For this test we can avoid business rule that activities are required + assert not change.activities + assert not change.activity_flows diff --git a/src/apps/file/api/file.py b/src/apps/file/api/file.py index e19ab1eb4b0..7a2f177eb0a 100644 --- a/src/apps/file/api/file.py +++ b/src/apps/file/api/file.py @@ -1,14 +1,18 @@ import asyncio import datetime +import mimetypes +import os import uuid from functools import partial from urllib.parse import quote +import aiofiles import pytz from botocore.exceptions import ClientError from fastapi import Body, Depends, File, Query, UploadFile from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession +from taskiq import TaskiqResult, TaskiqResultTimeoutError from apps.authentication.deps import get_current_user from apps.file.domain import ( @@ -24,6 +28,7 @@ from apps.file.errors import FileNotFoundError from apps.file.services import LogFileService from apps.file.storage import select_storage +from apps.file.tasks import convert_audio_file, convert_image from apps.shared.domain.response import Response, ResponseMulti from apps.shared.exception import NotFoundError from apps.users.domain import User @@ -44,16 +49,108 @@ async def upload( user: User = Depends(get_current_user), cdn_client: CDNClient = Depends(get_media_bucket), ) -> Response[ContentUploadedFile]: - key = cdn_client.generate_key( - FileScopeEnum.CONTENT, user.id, f"{uuid.uuid4()}/{file.filename}" - ) - await cdn_client.upload(key, file.file) + converters = [convert_not_supported_audio] + + to_close = [] + to_delete = [] + try: + res = None + for converter in converters: + if (res := await converter(file)) is not None: + break + + if res is not None: + filename, fout = res + to_delete.append(fout) + + reader = open(fout, "rb") + to_close.append(reader) + else: + filename = file.filename + reader = file.file # type: ignore[assignment] + + key = cdn_client.generate_key( + FileScopeEnum.CONTENT, user.id, f"{uuid.uuid4()}/{filename}" + ) + await cdn_client.upload(key, reader) + finally: + for f in to_close: + f.close() + + for path in to_delete: + os.remove(path) result = ContentUploadedFile( key=key, url=quote(settings.cdn.url.format(key=key), "/:") ) return Response(result=result) +async def _copy(file: UploadFile, path: str): + async with aiofiles.open(path, "wb") as fout: + _size = 1024 * 1024 + while content := await file.read(_size): + await fout.write(content) + + +async def convert_not_supported_audio(file: UploadFile): + + type_ = mimetypes.guess_type(file.filename)[0] or "" + if type_.lower() == "video/webm": + # store file, create task to convert + convert_filename = f"{uuid.uuid4()}_{file.filename}" + path = settings.uploads_dir / convert_filename + await _copy(file, path) + task = await convert_audio_file.kiq(convert_filename) + task_result: TaskiqResult[str] = await task.wait_result( + timeout=settings.task_audio_file_convert.task_wait_timeout + ) + success = not task_result.is_err + if not success: + if task_result.error: + raise task_result.error + raise Exception("File convertion error") + + out_filename = task_result.return_value + + fout = settings.uploads_dir / out_filename + + return out_filename, fout + + return None + + +async def convert_not_supported_image(file: UploadFile): + + type_ = mimetypes.guess_type(file.filename)[0] or "" + if type_.lower() == "image/heic": + # store file, create task to convert + convert_filename = f"{uuid.uuid4()}_{file.filename}" + path = settings.uploads_dir / convert_filename + await _copy(file, path) + task = await convert_image.kiq(convert_filename) + try: + task_result: TaskiqResult[str] = await task.wait_result( + timeout=settings.task_image_convert.task_wait_timeout + ) + except TaskiqResultTimeoutError: + raise + + success = not task_result.is_err + + if not success: + if task_result.error: + raise task_result.error + raise Exception("File convertion error") + + out_filename = task_result.return_value + + fout = settings.uploads_dir / out_filename + + return out_filename, fout + + return None + + async def download( request: FileDownloadRequest = Body(...), user: User = Depends(get_current_user), @@ -84,15 +181,42 @@ async def answer_upload( ): raise AnswerViewAccessDenied() - cdn_client = await select_storage(applet_id, session) - unique = f"{user.id}/{applet_id}" - cleaned_file_id = ( - file_id.strip() if file_id else f"{uuid.uuid4()}/{file.filename}" - ) - key = cdn_client.generate_key( - FileScopeEnum.ANSWER, unique, cleaned_file_id - ) - await cdn_client.upload(key, file.file) + converters = [convert_not_supported_image] + + to_close = [] + to_delete = [] + try: + res = None + for converter in converters: + if (res := await converter(file)) is not None: + break + + if res is not None: + filename, fout = res + to_delete.append(fout) + + reader = open(fout, "rb") + to_close.append(reader) + else: + filename = file.filename + reader = file.file # type: ignore[assignment] + + cdn_client = await select_storage(applet_id, session) + unique = f"{user.id}/{applet_id}" + cleaned_file_id = ( + file_id.strip() if file_id else f"{uuid.uuid4()}/{filename}" + ) + key = cdn_client.generate_key( + FileScopeEnum.ANSWER, unique, cleaned_file_id + ) + await cdn_client.upload(key, reader) + finally: + for f in to_close: + f.close() + + for path in to_delete: + os.remove(path) + result = AnswerUploadedFile( key=key, url=cdn_client.generate_private_url(key), @@ -177,7 +301,7 @@ async def presign( ): service = await get_presign_service(applet_id, user.id, session) links = await service.presign(request.private_urls) - return ResponseMulti[str](result=links, count=len(links)) + return ResponseMulti[str | None](result=links, count=len(links)) # noqa async def logs_upload( diff --git a/src/apps/file/domain.py b/src/apps/file/domain.py index b81f15f89b5..ab92f1a20ad 100644 --- a/src/apps/file/domain.py +++ b/src/apps/file/domain.py @@ -26,7 +26,7 @@ class FileExistenceResponse(PublicModel): class FilePresignRequest(PublicModel): - private_urls: list[str] + private_urls: list[str | None] class LogFileExistenceResponse(FileExistenceResponse): diff --git a/src/apps/file/services.py b/src/apps/file/services.py index 8782c9dd3dc..058b8693146 100644 --- a/src/apps/file/services.py +++ b/src/apps/file/services.py @@ -63,7 +63,7 @@ async def get_legacy_client( else: return await self.get_regular_client() - async def _presign(self, url: str): + async def _presign(self, url: str | None): if not url: return @@ -88,7 +88,7 @@ async def _presign(self, url: str): else: return url - async def presign(self, urls: List[str]) -> List[str]: + async def presign(self, urls: List[str | None]) -> List[str]: c_list = [] for url in urls: c_list.append(self._presign(url)) @@ -143,7 +143,7 @@ class GCPPresignService(S3PresignService): legacy_file_url_pattern = r"gs:\/\/[a-zA-Z0-9-]+\/[0-9a-fA-F]+\/[0-9a-fA-F]+\/[0-9a-fA-F]+(\/[a-zA-Z0-9.-]*)?" # noqa regular_file_url_pattern = r"gs:\/\/[a-zA-Z0-9.-]+\/[a-zA-Z0-9-]+\/[a-zA-Z0-9-]+\/[a-f0-9-]+\/[a-f0-9-]+\/[a-zA-Z0-9-]+" # noqa - async def _presign(self, url: str): + async def _presign(self, url: str | None): regular_cdn_client = await select_storage( applet_id=self.applet_id, session=self.session ) diff --git a/src/apps/file/storage.py b/src/apps/file/storage.py index 0e80d09790d..2533c6676f5 100644 --- a/src/apps/file/storage.py +++ b/src/apps/file/storage.py @@ -22,6 +22,7 @@ async def select_storage( info = await service.get_arbitrary_info(applet_id) if not info: config_cdn = CdnConfig( + endpoint_url=settings.cdn.endpoint_url, region=settings.cdn.region, bucket=settings.cdn.bucket_answer, ttl_signed_urls=settings.cdn.ttl_signed_urls, diff --git a/src/apps/file/tasks.py b/src/apps/file/tasks.py new file mode 100644 index 00000000000..adad19e8934 --- /dev/null +++ b/src/apps/file/tasks.py @@ -0,0 +1,77 @@ +import os +import subprocess + +from broker import broker +from config import settings +from infrastructure.logger import logger + + +def generate_command(fin: str, fout: str, command: str): + + return command.format(fin=fin, fout=fout) + + +@broker.task() +async def convert_audio_file(filename: str, remove_src: bool = True) -> str: + LOG_PREFIX = "convert_audio_file: " + + out_filename = filename + ".mp3" + fin = settings.uploads_dir / filename + fout = settings.uploads_dir / out_filename + + logger.info(f"{LOG_PREFIX}In: {fin}") + + try: + cmd = generate_command( + fin, fout, settings.task_audio_file_convert.command + ) + + logger.info(f"{LOG_PREFIX}Run `{cmd}`") + subprocess.check_output( + cmd, + stderr=subprocess.STDOUT, + shell=True, + timeout=settings.task_audio_file_convert.subprocess_timeout, + ) + except subprocess.CalledProcessError as e: + logger.error(f"{LOG_PREFIX}Convertion error: {fin} => {fout}") + raise Exception(f"{LOG_PREFIX}Error message: {e.output.decode()}") + finally: + if remove_src: + os.remove(fin) + + logger.info(f"{LOG_PREFIX}Out: {fout}") + + return out_filename + + +@broker.task() +async def convert_image(filename: str, remove_src: bool = True) -> str: + LOG_PREFIX = "convert_image: " + + out_filename = filename + ".jpg" + fin = settings.uploads_dir / filename + fout = settings.uploads_dir / out_filename + + logger.info(f"{LOG_PREFIX}In: {fin}") + + try: + cmd = generate_command(fin, fout, settings.task_image_convert.command) + + logger.info(f"{LOG_PREFIX}Run `{cmd}`") + subprocess.check_output( + cmd, + stderr=subprocess.STDOUT, + shell=True, + timeout=settings.task_image_convert.subprocess_timeout, + ) + except subprocess.CalledProcessError as e: + logger.error(f"{LOG_PREFIX}Convertion error: {fin} => {fout}") + raise Exception(f"{LOG_PREFIX}Error message: {e.output.decode()}") + finally: + if remove_src: + os.remove(fin) + + logger.info(f"{LOG_PREFIX}Out: {fout}") + + return out_filename diff --git a/src/apps/healthcheck/api.py b/src/apps/healthcheck/api.py index 151fd443a68..1f421110cb7 100644 --- a/src/apps/healthcheck/api.py +++ b/src/apps/healthcheck/api.py @@ -1,4 +1,8 @@ +import asyncio + +from fastapi import Query from fastapi.responses import Response +from starlette import status def readiness(): @@ -7,3 +11,19 @@ def readiness(): def liveness(): return Response("Liveness - OK!") + + +statuses = { + code for var, code in vars(status).items() if var.startswith("HTTP_") +} +exclude = {301, 302} +supported_statuses = statuses - exclude + + +async def statuscode( + code: int = 200, timeout: float = Query(0.0, ge=0.0, le=60.0) +): + if code not in supported_statuses: + return Response("Wrong status code", status_code=400) + await asyncio.sleep(timeout) + return Response(status_code=code) diff --git a/src/apps/healthcheck/router.py b/src/apps/healthcheck/router.py index a543a3a2035..3f54710b30d 100644 --- a/src/apps/healthcheck/router.py +++ b/src/apps/healthcheck/router.py @@ -1,9 +1,11 @@ from fastapi import status from fastapi.routing import APIRouter -from apps.healthcheck.api import liveness, readiness +from apps.healthcheck.api import liveness, readiness, statuscode router = APIRouter(tags=["Health check"]) router.get("/readiness", status_code=status.HTTP_200_OK)(readiness) router.get("/liveness", status_code=status.HTTP_200_OK)(liveness) +router.get("/statuscode")(statuscode) +router.post("/statuscode")(statuscode) diff --git a/src/apps/invitations/api.py b/src/apps/invitations/api.py index c048039f07a..7fe3ab9f759 100644 --- a/src/apps/invitations/api.py +++ b/src/apps/invitations/api.py @@ -16,6 +16,10 @@ InvitationReviewerResponse, PrivateInvitationResponse, ) +from apps.invitations.errors import ( + ManagerInvitationExist, + RespondentInvitationExist, +) from apps.invitations.filters import InvitationQueryParams from apps.invitations.services import ( InvitationsService, @@ -23,9 +27,12 @@ ) from apps.shared.domain import Response, ResponseMulti from apps.shared.query_params import QueryParams, parse_query_params +from apps.users import UserNotFound from apps.users.domain import User +from apps.users.services.user import UserService from apps.workspaces.domain.constants import Role from apps.workspaces.service.check_access import CheckAccessService +from apps.workspaces.service.user_applet_access import UserAppletAccessService from infrastructure.database import atomic from infrastructure.database.deps import get_session @@ -132,9 +139,18 @@ async def invitation_respondent_send( applet_id ) invitation_srv = InvitationsService(session, user) - await invitation_srv.check_for_duplicates( - applet_id, invitation_schema.email, Role.RESPONDENT - ) + try: + invited_user = await UserService(session).get_by_email( + invitation_schema.email + ) + is_role_exist = await UserAppletAccessService( + session, invited_user.id, applet_id + ).has_role(Role.RESPONDENT) + if is_role_exist: + raise RespondentInvitationExist() + except UserNotFound: + pass + invitation = await invitation_srv.send_respondent_invitation( applet_id, invitation_schema ) @@ -161,9 +177,18 @@ async def invitation_reviewer_send( applet_id ) invitation_srv = InvitationsService(session, user) - await invitation_srv.check_for_duplicates( - applet_id, invitation_schema.email, Role.REVIEWER - ) + try: + invited_user = await UserService(session).get_by_email( + invitation_schema.email + ) + is_role_exist = await UserAppletAccessService( + session, invited_user.id, applet_id + ).has_role(Role.REVIEWER) + if is_role_exist: + raise ManagerInvitationExist() + except UserNotFound: + pass + invitation: InvitationDetailForReviewer = await ( invitation_srv.send_reviewer_invitation( applet_id, invitation_schema @@ -193,9 +218,18 @@ async def invitation_managers_send( applet_id ) invitation_srv = InvitationsService(session, user) - await invitation_srv.check_for_duplicates( - applet_id, invitation_schema.email, invitation_schema.role - ) + try: + invited_user = await UserService(session).get_by_email( + invitation_schema.email + ) + is_role_exist = await UserAppletAccessService( + session, invited_user.id, applet_id + ).has_role(invitation_schema.role) + if is_role_exist: + raise ManagerInvitationExist() + except UserNotFound: + pass + invitation = await invitation_srv.send_managers_invitation( applet_id, invitation_schema ) diff --git a/src/apps/invitations/crud.py b/src/apps/invitations/crud.py index 9f3a81ddc7e..bfacc157bc1 100644 --- a/src/apps/invitations/crud.py +++ b/src/apps/invitations/crud.py @@ -104,6 +104,7 @@ async def get_pending_by_invited_email( first_name=invitation.first_name, last_name=invitation.last_name, created_at=invitation.created_at, + nickname=invitation.nickname, ) ) return results @@ -191,6 +192,7 @@ async def get_pending_by_invitor_id( first_name=invitation.first_name, last_name=invitation.last_name, created_at=invitation.created_at, + nickname=invitation.nickname, ) ) return results @@ -266,10 +268,12 @@ async def get_by_email_and_key( first_name=invitation.first_name, last_name=invitation.last_name, created_at=invitation.created_at, + user_id=invitation.user_id, ) if invitation.role == Role.RESPONDENT: return InvitationDetailRespondent( meta=invitation.meta, + nickname=invitation.nickname, **invitation_detail_base.dict(), ) elif invitation.role == Role.REVIEWER: @@ -325,17 +329,17 @@ async def get_by_email_applet_role_managers( InvitationManagers.from_orm(invitation) for invitation in results ] - async def approve_by_id(self, id_: uuid.UUID): + async def approve_by_id(self, id_: uuid.UUID, user_id: uuid.UUID): query = update(InvitationSchema) query = query.where(InvitationSchema.id == id_) - query = query.values(status=InvitationStatus.APPROVED) + query = query.values(status=InvitationStatus.APPROVED, user_id=user_id) await self._execute(query) - async def decline_by_id(self, id_: uuid.UUID): + async def decline_by_id(self, id_: uuid.UUID, user_id: uuid.UUID): query = update(InvitationSchema) query = query.where(InvitationSchema.id == id_) - query = query.values(status=InvitationStatus.DECLINED) + query = query.values(status=InvitationStatus.DECLINED, user_id=user_id) await self._execute(query) @@ -382,7 +386,8 @@ async def duplicate_exist( InvitationSchema.email == email, InvitationSchema.applet_id == applet_id, InvitationSchema.role == role, - InvitationSchema.status == InvitationStatus.APPROVED, + InvitationSchema.status == InvitationStatus.PENDING, + InvitationSchema.soft_exists(), ) db_result: Result = await self._execute(query) return bool(db_result.scalars().first()) @@ -394,8 +399,25 @@ async def manager_invitation_exist( query = query.where( InvitationSchema.email == email, InvitationSchema.applet_id == applet_id, - InvitationSchema.status == InvitationStatus.APPROVED, + InvitationSchema.status == InvitationStatus.PENDING, InvitationSchema.role.in_(Role.managers()), + InvitationSchema.soft_exists(), ) db_result: Result = await self._execute(query) return bool(db_result.scalars().first()) + + async def delete_by_applet_ids( + self, + email: str | None, + applet_ids: list[uuid.UUID], + roles: list[Role], + ): + query: Query = delete(InvitationSchema) + query = query.where( + InvitationSchema.email == email, + InvitationSchema.applet_id.in_(applet_ids), + InvitationSchema.status == InvitationStatus.APPROVED, + InvitationSchema.role.in_(roles), + InvitationSchema.soft_exists(), + ) + await self._execute(query) diff --git a/src/apps/invitations/db/schemas.py b/src/apps/invitations/db/schemas.py index 27964db95bc..afb8791f2ec 100644 --- a/src/apps/invitations/db/schemas.py +++ b/src/apps/invitations/db/schemas.py @@ -22,3 +22,7 @@ class InvitationSchema(Base): first_name = Column(StringEncryptedType(Unicode, get_key)) last_name = Column(StringEncryptedType(Unicode, get_key)) meta = Column(JSONB()) + nickname = Column(StringEncryptedType(Unicode, get_key)) + user_id = Column( + ForeignKey("users.id", ondelete="RESTRICT"), nullable=True + ) diff --git a/src/apps/invitations/domain.py b/src/apps/invitations/domain.py index 43a4be61768..27c6a3541fb 100644 --- a/src/apps/invitations/domain.py +++ b/src/apps/invitations/domain.py @@ -112,7 +112,12 @@ class RespondentMeta(InternalModel): """ secret_user_id: str - nickname: str + # nickname: str + + +class RespondentInfo(InternalModel): + nickname: str | None + meta: RespondentMeta class ReviewerMeta(InternalModel): @@ -136,12 +141,15 @@ class Invitation(InternalModel): first_name: str last_name: str created_at: datetime + user_id: uuid.UUID | None + is_deleted: bool class InvitationRespondent(Invitation): """This is an invitation representation for internal needs.""" meta: RespondentMeta + nickname: str | None class InvitationReviewer(Invitation): @@ -170,6 +178,7 @@ class InvitationDetailBase(InternalModel): first_name: str last_name: str created_at: datetime + user_id: uuid.UUID | None class InvitationDetail(InvitationDetailBase): @@ -178,6 +187,7 @@ class InvitationDetail(InvitationDetailBase): """ meta: dict + nickname: str | None class InvitationDetailRespondent(InvitationDetailBase): @@ -186,6 +196,7 @@ class InvitationDetailRespondent(InvitationDetailBase): """ meta: RespondentMeta + nickname: str | None class InvitationDetailReviewer(InvitationDetailBase): @@ -206,6 +217,7 @@ class _InvitationDetail(InternalModel): applet_name: str status: str key: uuid.UUID + user_id: uuid.UUID | None class InvitationDetailForRespondent(_InvitationDetail): @@ -259,6 +271,7 @@ class InvitationResponse(PublicModel): last_name: str created_at: datetime meta: dict + nickname: str | None class _InvitationResponse(PublicModel): @@ -282,6 +295,11 @@ class _InvitationResponse(PublicModel): status: InvitationStatus = Field( description="This field represents the status for invitation", ) + user_id: uuid.UUID | None = Field( + None, + description="This field respresents registered user or not. " + "Used for tests", + ) class InvitationRespondentResponse(_InvitationResponse): diff --git a/src/apps/invitations/fixtures/invitations.json b/src/apps/invitations/fixtures/invitations.json index 4c2765a9888..b637c2a16fd 100644 --- a/src/apps/invitations/fixtures/invitations.json +++ b/src/apps/invitations/fixtures/invitations.json @@ -82,7 +82,7 @@ "role": "respondent", "key": "6a3ab8e6-f2fa-49ae-b2db-197136677da1", "invitor_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa1", - "status": "approved", + "status": "pending", "meta": {} }, "note": { diff --git a/src/apps/invitations/services.py b/src/apps/invitations/services.py index 68536ecaa07..6bf470ab9e5 100644 --- a/src/apps/invitations/services.py +++ b/src/apps/invitations/services.py @@ -26,6 +26,7 @@ InvitationReviewer, InvitationReviewerRequest, PrivateInvitationDetail, + RespondentInfo, RespondentMeta, ReviewerMeta, _InvitationRequest, @@ -43,7 +44,7 @@ from apps.mailing.domain import MessageSchema from apps.mailing.services import MailingService from apps.shared.query_params import QueryParams -from apps.users import UserNotFound, UsersCRUD +from apps.users import UsersCRUD from apps.users.domain import User from apps.workspaces.service.workspace import WorkspaceService from config import settings @@ -99,7 +100,6 @@ def _get_invitation_subject(self, applet: AppletSchema): async def send_respondent_invitation( self, applet_id: uuid.UUID, schema: InvitationRespondentRequest ) -> InvitationDetailForRespondent: - await self._is_validated_role_for_invitation( applet_id, Role.RESPONDENT, schema ) @@ -123,7 +123,12 @@ async def send_respondent_invitation( email_=schema.email, applet_id_=applet_id ) ) - + # Get invited user if he exists. User will be linked with invitaion + # by user_id in this case + invited_user = await UsersCRUD(self.session).get_user_or_none_by_email( + email=schema.email + ) + invited_user_id = invited_user.id if invited_user is not None else None success_invitation_schema = { "email": schema.email, "applet_id": applet_id, @@ -133,16 +138,25 @@ async def send_respondent_invitation( "status": InvitationStatus.PENDING, "first_name": schema.first_name, "last_name": schema.last_name, + "user_id": invited_user_id, } payload = None invitation_schema = None for invitation in invitations: - meta = RespondentMeta.from_orm(invitation.meta) + respondent_info = RespondentInfo( + meta=RespondentMeta( + secret_user_id=invitation.meta.secret_user_id + ), + nickname=invitation.nickname, + ) if invitation.status == InvitationStatus.PENDING and ( - meta.secret_user_id == schema.secret_user_id + respondent_info.meta.secret_user_id == schema.secret_user_id ): - payload = success_invitation_schema | {"meta": meta.dict()} + payload = success_invitation_schema | { + "meta": respondent_info.meta.dict(), + "nickname": invitation.nickname, + } invitation_schema = await self.invitations_crud.update( lookup="id", value=invitation.id, @@ -150,17 +164,23 @@ async def send_respondent_invitation( ) break elif invitation.status == InvitationStatus.APPROVED and ( - meta.secret_user_id == schema.secret_user_id + respondent_info.meta.secret_user_id == schema.secret_user_id ): raise InvitationAlreadyProcesses if not payload: - meta = RespondentMeta( - secret_user_id=schema.secret_user_id, + respondent_info = RespondentInfo( + meta=RespondentMeta( + secret_user_id=schema.secret_user_id, + # nickname=schema.nickname, + ), nickname=schema.nickname, ) - payload = success_invitation_schema | {"meta": meta.dict()} + payload = success_invitation_schema | { + "meta": respondent_info.meta.dict(), + "nickname": respondent_info.nickname, + } invitation_schema = await self.invitations_crud.save( InvitationSchema(**payload) ) @@ -176,12 +196,10 @@ async def send_respondent_invitation( invitation_internal.applet_id ) - try: - await UsersCRUD(self.session).get_by_email(schema.email) - except UserNotFound: - path = "invitation_new_user_en" + if not invited_user_id: + path = f"invitation_new_user_{schema.language or 'en'}" else: - path = "invitation_registered_user_en" + path = f"invitation_registered_user_{schema.language or 'en'}" # Send email to the user service = MailingService() @@ -211,12 +229,12 @@ async def send_respondent_invitation( role=invitation_internal.role, status=invitation_internal.status, key=invitation_internal.key, + user_id=invitation_internal.user_id, ) async def send_reviewer_invitation( self, applet_id: uuid.UUID, schema: InvitationReviewerRequest ) -> InvitationDetailForReviewer: - await self._is_validated_role_for_invitation( applet_id, Role.REVIEWER, schema ) @@ -234,7 +252,12 @@ async def send_reviewer_invitation( respondents = [ str(respondent_id) for respondent_id in schema.respondents ] - + # Get invited user if he exists. User will be linked with invitaion + # by user_id in this case + invited_user = await UsersCRUD(self.session).get_user_or_none_by_email( + email=schema.email + ) + invited_user_id = invited_user.id if invited_user is not None else None success_invitation_schema = { "email": schema.email, "applet_id": applet_id, @@ -244,6 +267,7 @@ async def send_reviewer_invitation( "status": InvitationStatus.PENDING, "first_name": schema.first_name, "last_name": schema.last_name, + "user_id": invited_user_id, } payload = None @@ -284,12 +308,10 @@ async def send_reviewer_invitation( invitation_internal.applet_id ) - try: - await UsersCRUD(self.session).get_by_email(schema.email) - except UserNotFound: - path = "invitation_new_user_en" + if not invited_user_id: + path = f"invitation_new_user_{schema.language or 'en'}" else: - path = "invitation_registered_user_en" + path = f"invitation_registered_user_{schema.language or 'en'}" # Send email to the user service = MailingService() @@ -324,12 +346,12 @@ async def send_reviewer_invitation( status=invitation_internal.status, key=invitation_internal.key, respondents=schema.respondents, + user_id=invitation_internal.user_id, ) async def send_managers_invitation( self, applet_id: uuid.UUID, schema: InvitationManagersRequest ) -> InvitationDetailForManagers: - await self._is_validated_role_for_invitation( applet_id, schema.role, schema ) @@ -343,7 +365,12 @@ async def send_managers_invitation( ] = await self.invitations_crud.get_by_email_applet_role_managers( email_=schema.email, applet_id_=applet_id, role_=schema.role ) - + # Get invited user if he exists. User will be linked with invitaion + # by user_id in this case + invited_user = await UsersCRUD(self.session).get_user_or_none_by_email( + email=schema.email + ) + invited_user_id = invited_user.id if invited_user is not None else None success_invitation_schema = { "email": schema.email, "applet_id": applet_id, @@ -353,13 +380,15 @@ async def send_managers_invitation( "status": InvitationStatus.PENDING, "first_name": schema.first_name, "last_name": schema.last_name, + "user_id": invited_user_id, + "meta": {}, } payload = None invitation_schema = None for invitation in invitations: if invitation.status == InvitationStatus.PENDING: - payload = success_invitation_schema | {"meta": {}} + payload = success_invitation_schema invitation_schema = await self.invitations_crud.update( lookup="id", value=invitation.id, @@ -370,7 +399,7 @@ async def send_managers_invitation( raise InvitationAlreadyProcesses if not payload: - payload = success_invitation_schema | {"meta": {}} + payload = success_invitation_schema invitation_schema = await self.invitations_crud.save( InvitationSchema(**payload) ) @@ -386,12 +415,10 @@ async def send_managers_invitation( invitation_internal.applet_id ) - try: - await UsersCRUD(self.session).get_by_email(schema.email) - except UserNotFound: - path = "invitation_new_user_en" + if not invited_user_id: + path = f"invitation_new_user_{schema.language}" else: - path = "invitation_registered_user_en" + path = f"invitation_registered_user_{schema.language}" # Send email to the user service = MailingService() @@ -425,6 +452,7 @@ async def send_managers_invitation( role=invitation_internal.role, status=invitation_internal.status, key=invitation_internal.key, + user_id=invitation_internal.user_id, ) def _get_invitation_url_by_role(self, role: Role): @@ -569,7 +597,9 @@ async def accept(self, key: uuid.UUID): self.session, self._user.id, invitation.applet_id ).add_role_by_invitation(invitation) - await InvitationCRUD(self.session).approve_by_id(invitation.id) + await InvitationCRUD(self.session).approve_by_id( + invitation.id, self._user.id + ) async def decline(self, key: uuid.UUID): invitation = await InvitationCRUD(self.session).get_by_email_and_key( @@ -581,7 +611,9 @@ async def decline(self, key: uuid.UUID): if invitation.status != InvitationStatus.PENDING: raise InvitationAlreadyProcesses() - await InvitationCRUD(self.session).decline_by_id(invitation.id) + await InvitationCRUD(self.session).decline_by_id( + invitation.id, self._user.id + ) async def clear_applets_invitations(self, applet_id: uuid.UUID): await InvitationCRUD(self.session).delete_by_applet_id(applet_id) @@ -605,6 +637,25 @@ async def check_for_duplicates( if is_exist: raise RespondentInvitationExist() + async def delete_for_managers(self, applet_ids: list[uuid.UUID]): + roles = [ + Role.MANAGER, + Role.COORDINATOR, + Role.EDITOR, + Role.REVIEWER, + ] + await InvitationCRUD(self.session).delete_by_applet_ids( + self._user.email_encrypted, applet_ids, roles + ) + + async def delete_for_respondents(self, applet_ids: list[uuid.UUID]): + roles = [ + Role.RESPONDENT, + ] + await InvitationCRUD(self.session).delete_by_applet_ids( + self._user.email, applet_ids, roles + ) + class PrivateInvitationService: def __init__(self, session): diff --git a/src/apps/invitations/test_invite.py b/src/apps/invitations/test_invite.py index 43a6dc109bf..013a5c82373 100644 --- a/src/apps/invitations/test_invite.py +++ b/src/apps/invitations/test_invite.py @@ -5,15 +5,28 @@ from apps.applets.crud import UserAppletAccessCRUD from apps.applets.domain import Role +from apps.invitations.crud import InvitationCRUD +from apps.invitations.domain import InvitationStatus from apps.invitations.errors import ( ManagerInvitationExist, RespondentInvitationExist, ) from apps.mailing.services import TestMail from apps.shared.test import BaseTest +from apps.users.domain import UserCreateRequest from infrastructure.database import rollback, session_manager +@pytest.fixture +def user_create_data() -> UserCreateRequest: + return UserCreateRequest( + email="tom2@mindlogger.com", + first_name="Tom", + last_name="Isaak", + password="Test1234!", + ) + + class TestInvite(BaseTest): fixtures = [ "users/fixtures/users.json", @@ -44,7 +57,7 @@ async def test_invitation_list(self): response = await self.client.get(self.invitation_list) assert response.status_code == 200 - assert len(response.json()["result"]) == 3 + assert len(response.json()["result"]) == 4 @rollback async def test_applets_invitation_list(self): @@ -58,7 +71,7 @@ async def test_applets_invitation_list(self): ) assert response.status_code == 200 - assert len(response.json()["result"]) == 2 + assert len(response.json()["result"]) == 3 @rollback async def test_invitation_retrieve(self): @@ -113,7 +126,10 @@ async def test_admin_invite_manager_success(self): request_data, ) assert response.status_code == 200 - + assert ( + response.json()["result"]["userId"] + == "7484f34a-3acc-4ee6-8a94-fd7299502fa5" + ) assert len(TestMail.mails) == 1 assert TestMail.mails[0].recipients == [request_data["email"]] assert TestMail.mails[0].subject == "Applet 1 invitation" @@ -137,7 +153,10 @@ async def test_admin_invite_coordinator_success(self): request_data, ) assert response.status_code == 200 - + assert ( + response.json()["result"]["userId"] + == "7484f34a-3acc-4ee6-8a94-fd7299502fa5" + ) assert len(TestMail.mails) == 1 assert TestMail.mails[0].recipients == [request_data["email"]] @@ -160,7 +179,10 @@ async def test_admin_invite_editor_success(self): request_data, ) assert response.status_code == 200 - + assert ( + response.json()["result"]["userId"] + == "7484f34a-3acc-4ee6-8a94-fd7299502fa5" + ) assert len(TestMail.mails) == 1 assert TestMail.mails[0].recipients == [request_data["email"]] @@ -184,7 +206,10 @@ async def test_admin_invite_reviewer_success(self): request_data, ) assert response.status_code == 200, response.json() - + assert ( + response.json()["result"]["userId"] + == "7484f34a-3acc-4ee6-8a94-fd7299502fa5" + ) assert len(TestMail.mails) == 1 assert TestMail.mails[0].recipients == [request_data["email"]] assert TestMail.mails[0].subject == "Applet 1 invitation" @@ -210,7 +235,10 @@ async def test_admin_invite_respondent_success(self): request_data, ) assert response.status_code == 200 - + assert ( + response.json()["result"]["userId"] + == "7484f34a-3acc-4ee6-8a94-fd7299502fa5" + ) assert len(TestMail.mails) == 1 assert TestMail.mails[0].recipients == [request_data["email"]] assert TestMail.mails[0].subject == "Applet 1 invitation" @@ -455,7 +483,7 @@ async def test_invitation_accept_and_absorb_roles(self): uuid.UUID("7484f34a-3acc-4ee6-8a94-fd7299502fa4"), uuid.UUID("92917a56-d586-4613-b7aa-991f2c4b15b1"), ) - assert len(roles) == 2 + assert len(roles) == 3 assert Role.COORDINATOR in roles assert Role.EDITOR in roles @@ -608,3 +636,173 @@ async def test_fail_if_invite_manager_on_editor_role(self): res = res["result"][0] assert res["message"] == ManagerInvitationExist.message assert len(TestMail.mails) == 0 + + @rollback + async def test_invite_not_registered_user_manager(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + request_data = dict( + email="patricnewuser@example.com", + first_name="Patric", + last_name="Daniel", + role=Role.MANAGER, + language="en", + ) + response = await self.client.post( + self.invite_manager_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ), + request_data, + ) + assert response.status_code == 200 + assert not response.json()["result"]["userId"] + assert len(TestMail.mails) == 1 + + @rollback + async def test_invite_not_registered_user_reviewer(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + request_data = dict( + email="patricnewuser@example.com", + first_name="Patric", + last_name="Daniel", + role=Role.REVIEWER, + language="en", + respondents=["7484f34a-3acc-4ee6-8a94-fd7299502fa1"], + ) + response = await self.client.post( + self.invite_reviewer_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ), + request_data, + ) + assert response.status_code == 200, response.json() + assert not response.json()["result"]["userId"] + assert len(TestMail.mails) == 1 + + @rollback + async def test_invite_not_registered_user_respondent(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + request_data = dict( + email="patricnewuser@example.com", + first_name="Patric", + last_name="Daniel", + role=Role.RESPONDENT, + language="en", + secret_user_id=str(uuid.uuid4()), + nickname=str(uuid.uuid4()), + ) + response = await self.client.post( + self.invite_respondent_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ), + request_data, + ) + assert response.status_code == 200 + assert not response.json()["result"]["userId"] + assert len(TestMail.mails) == 1 + + @rollback + @pytest.mark.parametrize( + "status,url,method", + ( + (InvitationStatus.APPROVED, "accept_url", "post"), + (InvitationStatus.DECLINED, "decline_url", "delete"), + ), + ) + async def test_new_user_accept_decline_invitation( + self, user_create_data, status, url, method + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + request_data = dict( + email="patricnewuser@example.com", + first_name="Patric", + last_name="Daniel", + role=Role.MANAGER, + language="en", + ) + email = request_data["email"] + # Send an invite + response = await self.client.post( + self.invite_manager_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ), + request_data, + ) + assert response.status_code == 200 + assert not response.json()["result"]["userId"] + + invitation_key = response.json()["result"]["key"] + user_create_data.email = email + data = user_create_data.dict() + # An invited user creates an account + resp = await self.client.post("/users", data=data) + assert resp.status_code == 201 + resp = await self.client.login(self.login_url, email, data["password"]) + exp_user_id = resp.json()["result"]["user"]["id"] + # Accept invite + client_method = getattr(self.client, method) + resp = await client_method( + getattr(self, url).format(key=invitation_key) + ) + assert resp.status_code == 200 + session = session_manager.get_session() + # Because we don't return anything after accepting/declining + # invitation, check in database that user_id has already been updated + inv = await InvitationCRUD(session).get_by_email_and_key( + email, uuid.UUID(invitation_key) + ) + assert str(inv.user_id) == exp_user_id # type: ignore[union-attr] + assert inv.status == status # type: ignore[union-attr] + + @rollback + async def test_update_invitation_for_new_user_who_registered_after_first_invitation( # noqa: E501 + self, user_create_data + ) -> None: + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + request_data = dict( + email="patricnewuser@example.com", + first_name="Patric", + last_name="DanielUpdated", + role=Role.MANAGER, + language="en", + ) + email = request_data["email"] + # Send an invite + response = await self.client.post( + self.invite_manager_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ), + request_data, + ) + assert response.status_code == 200 + assert not response.json()["result"]["userId"] + + user_create_data.email = email + data = user_create_data.dict() + # An invited user creates an account + resp = await self.client.post("/users", data=data) + assert resp.status_code == 201 + resp = await self.client.login(self.login_url, email, data["password"]) + exp_user_id = resp.json()["result"]["user"]["id"] + + # Update an invite + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + response = await self.client.post( + self.invite_manager_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ), + request_data, + ) + assert response.status_code == 200 + assert response.json()["result"]["userId"] == exp_user_id diff --git a/src/apps/jsonld_converter/dependencies.py b/src/apps/jsonld_converter/dependencies.py index be291800bf4..fb67e45bffb 100644 --- a/src/apps/jsonld_converter/dependencies.py +++ b/src/apps/jsonld_converter/dependencies.py @@ -1,6 +1,6 @@ from typing import Callable -from cachetools import LRUCache # type: ignore[import] +from cachetools import LRUCache from fastapi import Depends from pyld import ContextResolver from pyld.jsonld import requests_document_loader @@ -19,7 +19,7 @@ def get_document_loader() -> Callable: def get_context_resolver( document_loader: Callable = Depends(get_document_loader), ) -> ContextResolver: - _resolved_context_cache = LRUCache(maxsize=100) + _resolved_context_cache: LRUCache = LRUCache(maxsize=100) return ContextResolver(_resolved_context_cache, document_loader) diff --git a/src/apps/jsonld_converter/service/document/protocol.py b/src/apps/jsonld_converter/service/document/protocol.py index 7c069f9e8c7..120614644cd 100644 --- a/src/apps/jsonld_converter/service/document/protocol.py +++ b/src/apps/jsonld_converter/service/document/protocol.py @@ -20,10 +20,10 @@ LdKeyword, ) from apps.jsonld_converter.service.domain import NotEncryptedApplet +from apps.workspaces.domain.constants import DataRetention class ReproProtocol(LdDocumentBase, ContainsNestedMixin, CommonFieldsMixin): - ld_version: str | None = None ld_schema_version: str | None = None ld_pref_label: str | None = None @@ -111,6 +111,20 @@ async def load(self, doc: dict, base_url: str | None = None): if rs.get("enabled", False): self.ld_retention_period = rs.get("period") self.ld_retention_type = rs.get("retention") + if self.ld_retention_period is not None: + if self.ld_retention_period == 0: + self.ld_retention_period = None + if self.ld_retention_type is not None: + if self.ld_retention_type == "year": + self.ld_retention_type = DataRetention.YEARS + elif self.ld_retention_type == "month": + self.ld_retention_type = DataRetention.MONTHS + elif self.ld_retention_type == "day": + self.ld_retention_type = DataRetention.DAYS + elif self.ld_retention_type == "week": + self.ld_retention_type = DataRetention.WEEKS + elif self.ld_retention_type == "indefinitely": + self.ld_retention_type = DataRetention.INDEFINITELY def _get_report_configuration( self, processed_doc: dict, *, drop=False diff --git a/src/apps/library/service.py b/src/apps/library/service.py index ba08a74d41f..f6db6d2a242 100644 --- a/src/apps/library/service.py +++ b/src/apps/library/service.py @@ -9,10 +9,6 @@ ActivityHistoriesCRUD, ActivityItemHistoriesCRUD, ) -from apps.activities.domain.custom_validation import ( - validate_is_performance_task, - validate_performance_task_type, -) from apps.activity_flows.crud import FlowItemHistoriesCRUD, FlowsHistoryCRUD from apps.applets.crud import AppletHistoriesCRUD, AppletsCRUD from apps.library.crud import CartCRUD, LibraryCRUD @@ -196,12 +192,8 @@ async def _get_full_library_item( show_all_at_once=activity.show_all_at_once, is_skippable=activity.is_skippable, is_reviewable=activity.is_reviewable, - is_performance_task=validate_is_performance_task( - False, {"items": items} - ), - performance_task_type=validate_performance_task_type( - None, {"items": items} - ), + is_performance_task=activity.is_performance_task, + performance_task_type=activity.performance_task_type, response_is_editable=activity.response_is_editable, is_hidden=activity.is_hidden, scores_and_reports=activity.scores_and_reports, diff --git a/src/apps/logs/api/notification.py b/src/apps/logs/api/notification.py index 5b6fc749f3c..faca7c80e86 100644 --- a/src/apps/logs/api/notification.py +++ b/src/apps/logs/api/notification.py @@ -7,6 +7,7 @@ PublicNotificationLog, ) from apps.shared.domain import Response, ResponseMulti +from apps.users.services.user import UserService from infrastructure.database import atomic from infrastructure.database.deps import get_session @@ -17,8 +18,13 @@ async def notification_log_create( ) -> Response[PublicNotificationLog]: """Creates a new NotificationLog.""" async with atomic(session): + # TODO: when mobile is ready for authentication, we will have to get + # user info from get_current_user dependency. For now keep user_id + # as email + email = schema.user_id + user = await UserService(session).get_by_email(email) notification_log = await NotificationLogCRUD(session).save( - schema=schema + schema=schema, user_id=str(user.id) ) return Response(result=notification_log) @@ -30,6 +36,11 @@ async def notification_log_retrieve( ) -> ResponseMulti[PublicNotificationLog]: """Returns NotificationLogs of user and device""" async with atomic(session): - notification_logs = await NotificationLogCRUD(session).filter(query) + user = await UserService(session).get_by_email(query.email) + notification_logs = await NotificationLogCRUD(session).filter( + query, user_id=str(user.id) + ) - return ResponseMulti(result=notification_logs) + return ResponseMulti( + result=notification_logs, count=len(notification_logs) + ) diff --git a/src/apps/logs/crud/notification.py b/src/apps/logs/crud/notification.py index 7cacf475920..2e2f1d6d1e3 100644 --- a/src/apps/logs/crud/notification.py +++ b/src/apps/logs/crud/notification.py @@ -1,7 +1,8 @@ import json from sqlalchemy import select -from sqlalchemy.orm import Query +from sqlalchemy.orm import InstrumentedAttribute, Query +from sqlalchemy.sql.operators import ColumnOperators from apps.logs.db.schemas import NotificationLogSchema from apps.logs.domain import ( @@ -19,14 +20,14 @@ class NotificationLogCRUD(BaseCRUD[NotificationLogSchema]): schema_class = NotificationLogSchema async def filter( - self, query_set: NotificationLogQuery + self, query_set: NotificationLogQuery, user_id: str ) -> list[PublicNotificationLog]: """Return all NotificationLogs where the user and device exists.""" query: Query = ( select(self.schema_class) .where( - self.schema_class.user_id == query_set.user_id - and self.schema_class.device_id == query_set.device_id + self.schema_class.device_id == query_set.device_id, + self.schema_class.user_id == user_id, ) .order_by(self.schema_class.created_at.desc()) .limit(query_set.limit) @@ -38,7 +39,7 @@ async def filter( return [PublicNotificationLog.from_orm(log) for log in logs] async def save( - self, schema: NotificationLogCreate + self, schema: NotificationLogCreate, user_id: str ) -> PublicNotificationLog: """Return NotificationLog instance.""" @@ -46,33 +47,26 @@ async def save( notif_in_queue_upd = True sched_notif_upd = True - logs = await self.filter( - NotificationLogQuery( - user_id=schema.user_id, - device_id=schema.device_id, - limit=1, - ) - ) - - previous = dict() - if logs: - previous = logs[0].dict() - if not schema.notification_descriptions: - schema.notification_descriptions = previous.get( - "notification_descriptions", json.dumps(None) + description = await self.get_previous_description(user_id, schema) + schema.notification_descriptions = ( + json.dumps(description) if description else json.dumps(None) ) notif_desc_upd = False if not schema.notification_in_queue: - schema.notification_in_queue = previous.get( - "notifications_in_queue", json.dumps(None) + in_queue = await self.get_previous_in_queue(user_id, schema) + schema.notification_in_queue = ( + json.dumps(in_queue) if in_queue else json.dumps(None) ) notif_in_queue_upd = False if not schema.scheduled_notifications: - schema.scheduled_notifications = previous.get( - "scheduled_notifications", json.dumps(None) + scheduled = await self.get_previous_scheduled_notifications( + user_id, schema + ) + schema.scheduled_notifications = ( + json.dumps(scheduled) if scheduled else json.dumps(None) ) sched_notif_upd = False @@ -80,7 +74,12 @@ async def save( try: instance: NotificationLogSchema = await self._create( NotificationLogSchema( - **schema.dict(), + action_type=schema.action_type, + user_id=user_id, + device_id=schema.device_id, + notification_descriptions=schema.notification_descriptions, + notification_in_queue=schema.notification_in_queue, + scheduled_notifications=schema.scheduled_notifications, notification_descriptions_updated=notif_desc_upd, notifications_in_queue_updated=notif_in_queue_upd, scheduled_notifications_updated=sched_notif_upd, @@ -91,3 +90,51 @@ async def save( return notification_log except Exception: raise NotificationLogError() + + async def _get_previous( + self, + user_id: str, + device_id: str, + field: list[InstrumentedAttribute], + flt: list[ColumnOperators], + ) -> NotificationLogSchema | None: + query: Query = select(field) + query = query.where( + NotificationLogSchema.user_id == user_id, + NotificationLogSchema.device_id == device_id, + *flt, + ) + query = query.order_by(NotificationLogSchema.created_at.desc()) + query = query.limit(1) + res = await self._execute(query) + return res.scalars().one_or_none() + + async def get_previous_description( + self, user_id: str, schema: NotificationLogCreate + ) -> NotificationLogSchema | None: + return await self._get_previous( + user_id, + schema.device_id, + [NotificationLogSchema.notification_descriptions], + [NotificationLogSchema.notification_descriptions.isnot(None)], + ) + + async def get_previous_in_queue( + self, user_id: str, schema: NotificationLogCreate + ) -> NotificationLogSchema | None: + return await self._get_previous( + user_id, + schema.device_id, + [NotificationLogSchema.notification_in_queue], + [NotificationLogSchema.notification_in_queue.isnot(None)], + ) + + async def get_previous_scheduled_notifications( + self, user_id: str, schema: NotificationLogCreate + ) -> NotificationLogSchema | None: + return await self._get_previous( + user_id, + schema.device_id, + [NotificationLogSchema.scheduled_notifications], + [NotificationLogSchema.scheduled_notifications.isnot(None)], + ) diff --git a/src/apps/logs/domain/notification.py b/src/apps/logs/domain/notification.py index 75d1ffdf49f..6e0730e120f 100644 --- a/src/apps/logs/domain/notification.py +++ b/src/apps/logs/domain/notification.py @@ -1,3 +1,4 @@ +import datetime import json import uuid @@ -20,7 +21,7 @@ class _NotificationLogBase(BaseModel): class NotificationLogQuery(BaseModel): - user_id: str + email: str device_id: str limit: PositiveInt = 1 @@ -37,7 +38,8 @@ class NotificationLogCreate(_NotificationLogBase, InternalModel): ) def validate_json(cls, v): try: - return json.loads(v) + if v is not None: + return json.loads(v) except json.JSONDecodeError: raise ValueError("Invalid JSON") @@ -63,9 +65,10 @@ class PublicNotificationLog(_NotificationLogBase, PublicModel): """Public NotificationLog model.""" id: uuid.UUID - notification_descriptions: list - notification_in_queue: list - scheduled_notifications: list + notification_descriptions: list | None + notification_in_queue: list | None + scheduled_notifications: list | None + created_at: datetime.datetime @validator( "notification_descriptions", diff --git a/src/apps/logs/tests.py b/src/apps/logs/tests.py index 56097a58f13..58a7b79be21 100644 --- a/src/apps/logs/tests.py +++ b/src/apps/logs/tests.py @@ -1,15 +1,40 @@ +import json + +from pytest import fixture, mark + from apps.shared.test import BaseTest from infrastructure.database import rollback +@fixture(scope="function") +def dummy_logs_payload() -> list[dict]: + return [ + dict( + user_id="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + action_type=f"test{i}", + notification_descriptions=json.dumps( + [{"sample": f"descriptions{i}"}] + ), + notification_in_queue=json.dumps([{"sample": f"queue{i}"}]), + scheduled_notifications=json.dumps([{"sample": f"scheduled{i}"}]), + ) + for i in range(2) + ] + + class TestNotificationLogs(BaseTest): logs_url = "/logs/notification" + fixtures = [ + "users/fixtures/users.json", + "users/fixtures/user_devices.json", + ] @rollback async def test_create_log(self): create_data = dict( - user_id="test@test.com", - device_id="test_device_id", + user_id="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", action_type="test", notification_descriptions='[{"sample":"json"}]', notification_in_queue='[{"sample":"json"}]', @@ -22,7 +47,10 @@ async def test_create_log(self): @rollback async def test_retrieve_log(self): - query = dict(user_id="test@test.com", device_id="test_device_id") + query = dict( + email="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + ) response = await self.client.get(self.logs_url, query=query) @@ -30,8 +58,8 @@ async def test_retrieve_log(self): assert type(response.json()["result"]) == list create_data = dict( - user_id="test@test.com", - device_id="test_device_id", + user_id="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", action_type="test", notification_descriptions='[{"sample":"json"}]', notification_in_queue='[{"sample":"json"}]', @@ -43,10 +71,119 @@ async def test_retrieve_log(self): assert response.json()["result"]["id"] query = dict( - user_id="test@test.com", device_id="test_device_id", limit=10 + email="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + limit=10, ) response = await self.client.get(self.logs_url, query=query) assert response.status_code == 200, response.json() assert len(response.json()["result"]) == 1 + + @mark.parametrize( + "description,queue,scheduled", + ( + ('[{"sample":"json"}]', json.dumps(None), json.dumps(None)), + (json.dumps(None), '[{"sample":"json"}]', json.dumps(None)), + (json.dumps(None), json.dumps(None), '[{"sample":"json"}]'), + ( + '[{"sample":"json0"}]', + '[{"sample":"json1"}]', + '[{"sample":"json2"}]', + ), + ), + ) + @rollback + async def test_create_log_use_previous_value_if_attribute_null( + self, dummy_logs_payload, description, queue, scheduled + ): + for payload in dummy_logs_payload: + response = await self.client.post(self.logs_url, data=payload) + assert response.status_code == 201 + + create_data = dict( + user_id="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + action_type="test", + notification_descriptions=description, + notification_in_queue=queue, + scheduled_notifications=scheduled, + ) + + response = await self.client.post(self.logs_url, data=create_data) + assert response.status_code == 201, response.json() + response = response.json()["result"] + assert response["id"] + assert response["notificationDescriptions"] + assert response["notificationInQueue"] + assert response["scheduledNotifications"] + + @rollback + async def test_create_log_use_none_value_if_attribute_null_at_first_log( + self, + ): + response = await self.client.post( + self.logs_url, + data=dict( + user_id="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + action_type="test", + notification_descriptions=None, + notification_in_queue='[{"name":"notification_in_queue"}]', + scheduled_notifications='[{"name":"scheduled_notifications"}]', + ), + ) + assert response.status_code == 201, response.json() + response = response.json()["result"] + assert response["id"] + assert response["notificationDescriptions"] is None + assert response["notificationInQueue"] + assert response["scheduledNotifications"] + + @rollback + async def test_create_log_use_previous_non_null_if_attribute_null(self): + payloads = [ + dict( + user_id="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + action_type="test", + notification_descriptions='[{"name":"descriptions1"}]', + notification_in_queue='[{"name":"in_queue1"}]', + scheduled_notifications='[{"name":"notifications1"}]', + ), + dict( + user_id="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + action_type="test", + notification_descriptions=None, + notification_in_queue='[{"name":"in_queue2"}]', + scheduled_notifications='[{"name":"notifications2"}]', + ), + ] + for payload in payloads: + response = await self.client.post(self.logs_url, data=payload) + assert response.status_code == 201 + + create_data = dict( + user_id="tom@mindlogger.com", + device_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + action_type="test", + notification_descriptions=None, + notification_in_queue='[{"name":"in_queue3"}]', + scheduled_notifications='[{"name":"notifications3"}]', + ) + + response = await self.client.post(self.logs_url, data=create_data) + assert response.status_code == 201, response.json() + response = response.json()["result"] + assert response["id"] + assert response["notificationDescriptions"] == json.loads( + '[{"name":"descriptions1"}]' + ) + assert response["notificationInQueue"] == json.loads( + '[{"name":"in_queue3"}]' + ) + assert response["scheduledNotifications"] == json.loads( + '[{"name":"notifications3"}]' + ) diff --git a/src/apps/mailing/static/templates/applet_create_success_en.html b/src/apps/mailing/static/templates/applet_create_success_en.html index e5700399f4e..de57de08be3 100644 --- a/src/apps/mailing/static/templates/applet_create_success_en.html +++ b/src/apps/mailing/static/templates/applet_create_success_en.html @@ -14,12 +14,12 @@ - You can now send invitations and create schedules. + It is ready for you to send out invitations, set schedules, and configure in other ways. - - Important: Please ensure your applet password is stored securely outside of MindLogger. This password can never be changed or retrieved if forgotten due to security reasons. You will lose all data previously collected if this password is lost. + + Please ensure the Applet password is stored securely outside of MindLogger. Due to security reasons, this password can never be changed or retrieved if forgotten. If you forget this password, you will lose all previously collected data. {% include 'blocks/write_us_en.html' %} diff --git a/src/apps/mailing/static/templates/blocks/write_us_en.html b/src/apps/mailing/static/templates/blocks/write_us_en.html index 03cb2016481..abd3c9ad9b5 100644 --- a/src/apps/mailing/static/templates/blocks/write_us_en.html +++ b/src/apps/mailing/static/templates/blocks/write_us_en.html @@ -1,7 +1,7 @@ - If you have had any issues joining an applet or creating an account, please + If you have had any issues creating an account or joining someone else’s applet, please write - us and we can help you. + to us for help. diff --git a/src/apps/mailing/static/templates/footers/footer_info_fr.html b/src/apps/mailing/static/templates/footers/footer_info_fr.html new file mode 100644 index 00000000000..17ee5f1ae3c --- /dev/null +++ b/src/apps/mailing/static/templates/footers/footer_info_fr.html @@ -0,0 +1,17 @@ +
+ + + + + + + +
+ Child Mind Institute +
+ MindLogger de Child Mind Institute n'est pas responsable du contenu créé par des tiers. +
+
\ No newline at end of file diff --git a/src/apps/mailing/static/templates/invitation_new_user_en.html b/src/apps/mailing/static/templates/invitation_new_user_en.html index a0a95b53279..85c804d9dcb 100644 --- a/src/apps/mailing/static/templates/invitation_new_user_en.html +++ b/src/apps/mailing/static/templates/invitation_new_user_en.html @@ -25,10 +25,10 @@ - + Go to "{{ applet_name }}" invitation page @@ -40,7 +40,7 @@ "{{ applet_name }}" in the free MindLogger app on your mobile device, if you follow three simple steps (see the user guide for greater detail): diff --git a/src/apps/mailing/static/templates/invitation_new_user_fr.html b/src/apps/mailing/static/templates/invitation_new_user_fr.html new file mode 100644 index 00000000000..1808c7f8c55 --- /dev/null +++ b/src/apps/mailing/static/templates/invitation_new_user_fr.html @@ -0,0 +1,90 @@ + + +{% include 'header.html' %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ first_name }}! +
+ Bienvenue dans "{{ applet_name }}"! +
+ Vous avez été invité à devenir {{ role }} de "{{ applet_name + }}", dans l'application MindLogger (voir ci-dessous). +
+ Pour accepter cette invitation, cliquez ci-dessous et votre + navigateur Internet s'ouvrira sur la page d'invitation + de "{{ applet_name }}": +
+ + Aller à la page d'invitation "{{ applet_name }}". + +
+ Après avoir accepté l'invitation, vous pourrez accéder à + "{{ applet_name }}" dans l'application gratuite MindLogger sur votre + appareil mobile, en suivant trois étapes simples (voir + + le guide de l'utilisateur + pour plus de détails): +
+
    +
  1. + Installez l'application MindLogger sur votre appareil + mobile, si elle n'est pas déjà installée. +
  2. +
  3. + Ouvrez l'application MindLogger sur votre appareil mobile et + connectez-vous (si vous avez un compte MindLogger) ou + inscrivez-vous (si vous êtes nouveau sur MindLogger). + Pour vous inscrire, appuyez sur "Nouvel utilisateur" sur + l'écran de connexion et entrez l'adresse email à laquelle + vous avez reçu l'invitation. +
  4. +
  5. + Appuyez sur "{{ applet_name }}" sur l'écran d'accueil de + MindLogger et vous êtes prêt ! Si "{{ applet_name }}" + n'apparaît pas, rafraîchissez l'écran en faisant glisser + votre doigt vers le bas à partir du haut, et une roue + devrait apparaître pendant le chargement + de "{{ applet_name }}". +
  6. +
+
+ Merci d'avoir accepté l'invitation à utiliser "{{ applet_name }}"! +
+ -L'équipe MindLogger +
+{% include 'footers/footer_info_fr.html' %} + + + \ No newline at end of file diff --git a/src/apps/mailing/static/templates/invitation_registered_user_en.html b/src/apps/mailing/static/templates/invitation_registered_user_en.html index 4e0eda5e87b..3c38a292c9a 100644 --- a/src/apps/mailing/static/templates/invitation_registered_user_en.html +++ b/src/apps/mailing/static/templates/invitation_registered_user_en.html @@ -25,10 +25,10 @@ - + Go to "{{ applet_name }}" invitation page @@ -40,7 +40,7 @@ "{{ applet_name }}" in the free MindLogger app on your mobile device, if you follow three simple steps (see the user guide for greater detail): diff --git a/src/apps/mailing/static/templates/invitation_registered_user_fr.html b/src/apps/mailing/static/templates/invitation_registered_user_fr.html new file mode 100644 index 00000000000..2c9c56aef5f --- /dev/null +++ b/src/apps/mailing/static/templates/invitation_registered_user_fr.html @@ -0,0 +1,86 @@ + + +{% include 'header.html' %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ first_name }}! +
+ Bienvenue dans "{{ applet_name }}"! +
+ Vous avez été invité à devenir {{ role }} de "{{ applet_name + }}", dans l'application MindLogger (voir ci-dessous). +
+ Pour accepter cette invitation, cliquez ci-dessous et votre + navigateur Internet s'ouvrira sur la page d'invitation + de "{{ applet_name }}": +
+ + Aller à la page d'invitation "{{ applet_name }}". + +
+ Après avoir accepté l'invitation, vous pourrez accéder à + "{{ applet_name }}" dans l'application gratuite MindLogger sur votre + appareil mobile, en suivant trois étapes simples (voir + + le guide de l'utilisateur + pour plus de détails): +
+
    +
  1. + Installez l'application MindLogger sur votre appareil + mobile, si elle n'est pas déjà installée. +
  2. +
  3. + Ouvrez l'application MindLogger sur votre appareil mobile et + connectez-vous. +
  4. +
  5. + Appuyez sur "{{ applet_name }}" sur l'écran d'accueil de + MindLogger et c'est tout ! Si "{{ applet_name }}" + n'apparaît pas, rafraîchissez l'écran en faisant glisser + votre doigt vers le bas à partir du haut et une roue + devrait apparaître pendant le chargement + de "{{ applet_name }}". +
  6. +
+
+ Merci d'avoir accepté l'invitation à utiliser "{{ applet_name }}"! +
+ -L'équipe MindLogger +
+{% include 'footers/footer_info_fr.html' %} + + + \ No newline at end of file diff --git a/src/apps/migrate/answers/answer_item_service.py b/src/apps/migrate/answers/answer_item_service.py index 27b5600825c..6de9c0dc49d 100644 --- a/src/apps/migrate/answers/answer_item_service.py +++ b/src/apps/migrate/answers/answer_item_service.py @@ -9,26 +9,30 @@ class AnswerItemMigrationService: - async def create_item( - self, - *, - regular_session, - regular_or_arbitary_session, - mongo_answer: dict, - **kwargs, - ): - identifier = mongo_answer["meta"]["subject"].get("identifier", "") + async def get_respondent_id(self, regular_session, mongo_answer): respondent_mongo_id = Profile().findOne( {"_id": mongo_answer["meta"]["subject"].get("@id")} )["userId"] if respondent_mongo_id: - respondent_id = mongoid_to_uuid(respondent_mongo_id) + return mongoid_to_uuid(respondent_mongo_id) else: anon_respondent = await MigrateUsersMCRUD( regular_session ).get_anonymous_respondent() - respondent_id = anon_respondent.id + return anon_respondent.id + async def create_item( + self, + *, + regular_session, + regular_or_arbitary_session, + mongo_answer: dict, + **kwargs, + ): + identifier = mongo_answer["meta"]["subject"].get("identifier", "") + respondent_id = await self.get_respondent_id( + regular_session, mongo_answer + ) answer_item = await AnswerItemsCRUD( regular_or_arbitary_session ).create( @@ -63,7 +67,7 @@ async def create_item( def _get_migrated_data(self, identifier): if not identifier: return None - return {"is_identifier_encrypted": True} + return {"is_identifier_encrypted": False} def _get_item_ids(self, mongo_answer): responses_keys = list(mongo_answer["meta"]["responses"]) @@ -74,12 +78,19 @@ def _get_item_ids(self, mongo_answer): for k in list(mongo_answer["meta"]["responses"]) ] + item_ids_from_url = [url.split("/")[-1] for url in responses_keys] return [ str(mongoid_to_uuid(i["_id"])) for i in Item().find( query={ "meta.activityId": mongo_answer["meta"]["activity"]["@id"], - "meta.screen.schema:url": {"$in": responses_keys}, + # If meta.screen.schema:url exists then try to find by url, + # because meta.screen.@id will start with '/'. + # In other case try to find by last word in url (screen.@id) + "$or": [ + {"meta.screen.@id": {"$in": item_ids_from_url}}, + {"meta.screen.schema:url": {"$in": responses_keys}}, + ], } ) ] @@ -88,3 +99,52 @@ def _fromtimestamp(self, timestamp: int | None): if timestamp is None: return None return datetime.utcfromtimestamp((float(timestamp) / 1000)) + + async def create_or_update_assessment( + self, + regular_session, + regular_or_arbitary_session, + mongo_answer: dict, + **kwargs, + ): + respondent_id = await self.get_respondent_id( + regular_session, mongo_answer + ) + crud = AnswerItemsCRUD(regular_or_arbitary_session) + assessment = await crud.get_assessment( + answer_id=kwargs["answer_id"], user_id=respondent_id + ) + identifier = mongo_answer["meta"]["subject"].get("identifier", "") + data = dict( + created_at=mongo_answer["created"], + updated_at=mongo_answer["updated"], + answer_id=kwargs["answer_id"], + answer=mongo_answer["meta"]["dataSource"], + item_ids=self._get_item_ids(mongo_answer), + events=mongo_answer["meta"].get("events", ""), + respondent_id=respondent_id, + identifier=mongo_answer["meta"]["subject"].get("identifier", None), + user_public_key=str(mongo_answer["meta"]["userPublicKey"]), + scheduled_datetime=self._fromtimestamp( + mongo_answer["meta"].get("scheduledTime") + ), + start_datetime=self._fromtimestamp( + mongo_answer["meta"].get("responseStarted") + ), + end_datetime=self._fromtimestamp( + mongo_answer["meta"].get("responseCompleted") + ), + is_assessment=kwargs["is_assessment"], + migrated_data=self._get_migrated_data(identifier), + assessment_activity_id=mongo_answer["activity_id_version"], + ) + if not assessment: + data["id"] = mongoid_to_uuid(mongo_answer["_id"]) + data["migrated_date"] = datetime.utcnow() + await crud.create(AnswerItemSchema(**data)) + + else: + data["id"] = assessment.id + data["migrated_date"] = assessment.migrated_date + data["migrated_updated"] = datetime.utcnow() + await crud.update(AnswerItemSchema(**data)) diff --git a/src/apps/migrate/answers/answer_note_service.py b/src/apps/migrate/answers/answer_note_service.py index e151badf321..6487c7a2066 100644 --- a/src/apps/migrate/answers/answer_note_service.py +++ b/src/apps/migrate/answers/answer_note_service.py @@ -2,7 +2,6 @@ from apps.answers.crud.notes import AnswerNotesCRUD from apps.answers.db.schemas import AnswerNoteSchema from apps.migrate.utilities import mongoid_to_uuid -from apps.shared.encryption import encrypt from infrastructure.database import atomic diff --git a/src/apps/migrate/answers/run.py b/src/apps/migrate/answers/run.py index 0915dd540e4..d994716c7eb 100644 --- a/src/apps/migrate/answers/run.py +++ b/src/apps/migrate/answers/run.py @@ -16,6 +16,7 @@ from apps.migrate.answers.user_applet_access import ( MigrateUserAppletAccessService, ) +from apps.migrate.answers.utills import get_arguments from apps.migrate.run import get_applets_ids from apps.migrate.services.mongo import Mongo @@ -23,7 +24,6 @@ configure_report, migration_log, mongoid_to_uuid, - get_arguments, intersection, ) from apps.workspaces.crud.user_applet_access import UserAppletAccessCRUD @@ -32,6 +32,7 @@ from apps.activities.crud import ( ActivityHistoriesCRUD, ActivityItemHistoriesCRUD, + ActivitiesCRUD, ) from apps.activities.db.schemas import ( ActivityHistorySchema, @@ -39,6 +40,31 @@ ) +APPLETS_WITH_ISSUES_DONT_MIGRATE_ANSWERS = { + "623cd7ee5197b9338bdaf218", + "6116c49e66f506a576da4f03", + "5fd28283c47c585b7c73354b", + "5f0e35523477de8b4a528dd0", + "61f3415f62485608c74c1f0b", + "61f3423962485608c74c1f45", + "623cb24d5197b9338bdaed65", + "623ce1695197b9338bdaf388", + "61f3419a62485608c74c1f25", + "63d3d579b71996780cdf409a", + "636533965cb70043112200a9", + "636936b352ea02101467640d", + "631aba1db7ee970ffa9009e3", + "623ce52a5197b9338bdaf4b6", + "623dfaf95197b9338bdaf8c5", + "62f16366acd35a39e99b57ec", + "636425cf5cb700431121fe46", + "636532fd5cb700431121ff93", + "636936ca52ea021014676437", + "636936e652ea02101467645b", + "636e942c52ea0234e1f4ec25", +} + + class AnswersMigrateFacade: anonymous_respondent_answers = 0 total_answers = 0 @@ -53,16 +79,23 @@ def __init__(self): self.answer_item_migrate_service = AnswerItemMigrationService() self.answer_note_migrate_service = AnswerNoteMigrateService() - async def migrate(self, workspace, applets): + async def migrate(self, workspace, applets, assessments_only, update_data): regular_session = session_manager.get_session() applets_ids = await self._get_allowed_applets_ids(workspace, applets) - applets_ids = [mongoid_to_uuid(applet_id) for applet_id in applets_ids] + applets_ids = [ + mongoid_to_uuid(applet_id) + for applet_id in applets_ids + if applet_id not in APPLETS_WITH_ISSUES_DONT_MIGRATE_ANSWERS + ] - await self._wipe_answers_data(regular_session, applets_ids) + # if not update_data: + # answer = input("Please type 'delete' to delete all answers data") + # if answer == "delete": + # await self._wipe_answers_data(regular_session, applets_ids) async for answer_with_files in self._collect_migratable_answers( - applets_ids + applets_ids, assessments_only ): self.total_answers += 1 query = answer_with_files["query"] @@ -95,7 +128,7 @@ async def migrate(self, workspace, applets): mongo_answer["meta"]["reviewing"]["responseId"] ) await self._create_reviewer_assessment( - regular_session, mongo_answer + regular_session, mongo_answer, assessments_only ) else: @@ -173,7 +206,7 @@ async def migrate(self, workspace, applets): async with atomic(regular_session): await self._migrate_answers_items( - regular_session, self.answer_items_data + regular_session, self.answer_items_data, assessments_only ) self._log_migration_results() @@ -221,7 +254,9 @@ async def _get_regular_or_arbitary_session(self, session, applet_id): return arbitary_session return session - async def _collect_migratable_answers(self, applets_ids: list[uuid.UUID]): + async def _collect_migratable_answers( + self, applets_ids: list[uuid.UUID], assessments_only: bool = False + ): migratable_data_count = 0 regular_session = session_manager.get_session() @@ -232,8 +267,12 @@ async def _collect_migratable_answers(self, applets_ids: list[uuid.UUID]): ).get_answers_migration_params(applets_ids) for answer_migration_params in answers_migration_params: + kwargs = { + **answer_migration_params, + "assessments_only": assessments_only, + } answer_migration_queries = self.mongo.get_answer_migration_queries( - **answer_migration_params + **kwargs ) anwswers_with_files = self.mongo.get_answers_with_files( @@ -247,7 +286,9 @@ async def _collect_migratable_answers(self, applets_ids: list[uuid.UUID]): migratable_data_count += 1 - async def _migrate_answers_items(self, regular_session, answer_items_data): + async def _migrate_answers_items( + self, regular_session, answer_items_data, assessments_only + ): for i, answer_item_data in enumerate(answer_items_data): migration_log.debug( f"Migrating {i} answer_item of {len(answer_items_data)}" @@ -271,11 +312,18 @@ async def _migrate_answers_items(self, regular_session, answer_items_data): ) try: async with atomic(regular_or_arbitary_session): - await self.answer_item_migrate_service.create_item( - regular_session=regular_session, - regular_or_arbitary_session=regular_or_arbitary_session, - **answer_item_data, - ) + if assessments_only: + await self.answer_item_migrate_service.create_or_update_assessment( + regular_session=regular_session, + regular_or_arbitary_session=regular_or_arbitary_session, + **answer_item_data, + ) + else: + await self.answer_item_migrate_service.create_item( + regular_session=regular_session, + regular_or_arbitary_session=regular_or_arbitary_session, + **answer_item_data, + ) except Exception as e: self.error_answers_migration.append((answer_item_data, str(e))) continue @@ -322,7 +370,12 @@ def _log_migration_results(self): f"Anonymous users answers count: {self.anonymous_respondent_answers}" ) - async def _create_reviewer_assessment(self, regular_session, mongo_answer): + async def _create_reviewer_assessment( + self, + regular_session, + mongo_answer, + assessment_only, + ): # check if reviewer assessment activity for this answers applet version exists original_answer = self.mongo.db["item"].find_one( {"_id": mongo_answer["meta"]["reviewing"]["responseId"]} @@ -333,13 +386,9 @@ async def _create_reviewer_assessment(self, regular_session, mongo_answer): ) original_applet_version = original_answer["meta"]["applet"]["version"] - all_assessment_activities = await ActivityHistoriesCRUD( + all_assessment_activities = await ActivitiesCRUD( regular_session - ).retrieve_by_applet_ids( - [ - f"{original_applet_id}_{original_applet_version}", - ] - ) + ).get_by_applet_id(original_applet_id) reviewer_assessment_activities = [ _a for _a in all_assessment_activities if _a.is_reviewable ] @@ -352,7 +401,7 @@ async def _create_reviewer_assessment(self, regular_session, mongo_answer): ) # if not, create it - if not reviewer_assessment_activities: + if not reviewer_assessment_activities and not assessment_only: missing_applet_version = mongo_answer["meta"]["applet"]["version"] duplicating_activity_res = await ActivityHistoriesCRUD( @@ -386,9 +435,31 @@ async def _create_reviewer_assessment(self, regular_session, mongo_answer): item = await ActivityItemHistoriesCRUD( regular_session )._create(ActivityItemHistorySchema(**item)) + elif assessment_only and reviewer_assessment_activities: + activity = reviewer_assessment_activities[0] + id_version = ( + f"{activity.id}_{mongo_answer['meta']['applet']['version']}" + ) + activity_hist = await ActivityHistoriesCRUD( + regular_session + ).get_by_id(id_version) + if activity_hist: + mongo_answer["activity_id_version"] = activity_hist.id_version + else: + raise Exception( + f"Assessment activity history {id_version} does not " + f"exist for applet {original_applet_id}" + ) if __name__ == "__main__": args = get_arguments() configure_report(migration_log, args.report_file) - asyncio.run(AnswersMigrateFacade().migrate(args.workspace, args.applet)) + asyncio.run( + AnswersMigrateFacade().migrate( + args.workspace, + args.applet, + args.assessments_only, + args.update_data, + ) + ) diff --git a/src/apps/migrate/answers/user_applet_access.py b/src/apps/migrate/answers/user_applet_access.py index 5333f442cfd..13cf8437721 100644 --- a/src/apps/migrate/answers/user_applet_access.py +++ b/src/apps/migrate/answers/user_applet_access.py @@ -34,7 +34,7 @@ async def add_role_for_legacy_deleted_respondent( self._applet_id, Role.RESPONDENT.value, ) - + nickname = meta.pop("nickname", None) if not access_schema: access_schema = await UserAppletAccessCRUD(self.session).save( UserAppletAccessSchema( @@ -44,6 +44,7 @@ async def add_role_for_legacy_deleted_respondent( owner_id=self._user_id, invitor_id=self._user_id, meta=meta, + nickname=nickname, ) ) diff --git a/src/apps/migrate/answers/utills.py b/src/apps/migrate/answers/utills.py new file mode 100644 index 00000000000..5156fe12d76 --- /dev/null +++ b/src/apps/migrate/answers/utills.py @@ -0,0 +1,41 @@ +import argparse + +from pydantic import BaseModel, validator + + +class Params(BaseModel): + class Config: + orm_mode = True + + workspace: str | None = None + applet: list[str] | None = None + report_file: str | None = None + assessments_only: bool = False + update_data: bool = True + + @validator("applet", pre=True) + def to_array(cls, value, values): + if isinstance(value, str): + return value.split(",") + + return value + + +def get_arguments() -> Params: + parser = argparse.ArgumentParser(argument_default=argparse.SUPPRESS) + parser.add_argument("-w", "--workspace", type=str, required=False) + parser.add_argument("-a", "--applet", type=str, required=False) + parser.add_argument("-r", "--report_file", type=str, required=False) + parser.add_argument("--assessments_only", type=bool, required=False) + parser.add_argument("--update_data", type=bool, required=False) + args = parser.parse_args() + arguments = Params.from_orm(args) + return arguments + + @validator("assessments_only") + def assessments_only_to_bool(values): + return bool(values) + + @validator("update_data") + def update_data_to_bool(values): + return bool(values) diff --git a/src/apps/migrate/data_description/applet_user_access.py b/src/apps/migrate/data_description/applet_user_access.py index e81dcb81d3e..d823a2aae8a 100644 --- a/src/apps/migrate/data_description/applet_user_access.py +++ b/src/apps/migrate/data_description/applet_user_access.py @@ -38,6 +38,8 @@ def insert_stmt(self) -> str: "id", "migrated_date", "migrated_updated", + "created_at", + "updated_at", "is_deleted", "is_pinned", "role", @@ -45,24 +47,32 @@ def insert_stmt(self) -> str: "applet_id", "owner_id", "invitor_id", + "nickname", "meta" ) VALUES ( %s, now() at time zone ('utc'), now() at time zone ('utc'), + now() at time zone ('utc'), + now() at time zone ('utc'), FALSE, - %s, %s, %s, %s, %s, %s, %s + %s, %s, %s, %s, %s, %s, %s, %s ) """ def update_stmt(self): return """ UPDATE user_applet_accesses - SET meta = jsonb_set( - COALESCE(meta, '{}'::jsonb), - '{legacyProfileId}',%s, true - ) + SET + meta = jsonb_set( + COALESCE(meta, '{}'::jsonb), + '{legacyProfileId}',%s, true + ), + "migrated_date" = now() at time zone ('utc'), + "migrated_updated" = now() at time zone ('utc'), + "created_at" = now() at time zone ('utc'), + "updated_at" = now() at time zone ('utc') WHERE role = %s AND user_id = %s AND @@ -71,6 +81,8 @@ def update_stmt(self): """ def values(self) -> tuple: + nickname = self.meta.pop("nickname", None) + return ( str(uuid.uuid4()), self.is_pinned, @@ -79,6 +91,7 @@ def values(self) -> tuple: str(self.applet_id), str(self.owner_id), str(self.inviter_id), + nickname, self.dump_meta(), ) diff --git a/src/apps/migrate/data_description/library_dao.py b/src/apps/migrate/data_description/library_dao.py index 646f2f60da6..9eb7fae96dc 100644 --- a/src/apps/migrate/data_description/library_dao.py +++ b/src/apps/migrate/data_description/library_dao.py @@ -15,6 +15,8 @@ class LibraryDao: updated_at: datetime.datetime migrated_date: datetime.datetime migrated_updated: datetime.datetime + display_name: str + name: str is_deleted: bool = False def __hash__(self): @@ -41,7 +43,6 @@ def values(self) -> tuple: class ThemeDao: id: uuid.UUID creator_id: uuid.UUID - applet_id: uuid.UUID name: str logo: str | None small_logo: str | None diff --git a/src/apps/migrate/domain/activity_full.py b/src/apps/migrate/domain/activity_full.py new file mode 100644 index 00000000000..5ccdad4d7f1 --- /dev/null +++ b/src/apps/migrate/domain/activity_full.py @@ -0,0 +1,20 @@ +import datetime +import uuid +from pydantic import Field + +from apps.activities.domain.activity_base import ActivityBase +from apps.activities.domain.activity_full import ActivityItemFull +from apps.shared.domain import InternalModel + + +class ActivityItemMigratedFull(ActivityItemFull): + extra_fields: dict = Field(default_factory=dict) + + +class ActivityMigratedFull(ActivityBase, InternalModel): + id: uuid.UUID + key: uuid.UUID + order: int + created_at: datetime.datetime + extra_fields: dict = Field(default_factory=dict) + items: list[ActivityItemMigratedFull] = Field(default_factory=list) diff --git a/src/apps/migrate/domain/applet_full.py b/src/apps/migrate/domain/applet_full.py index fb8d5acacf0..22febc0a119 100644 --- a/src/apps/migrate/domain/applet_full.py +++ b/src/apps/migrate/domain/applet_full.py @@ -1,13 +1,24 @@ import datetime -from apps.applets.domain.applet_full import AppletFull +from pydantic import Field +from apps.applets.domain.base import AppletFetchBase +from apps.migrate.domain.activity_full import ActivityMigratedFull +from apps.migrate.domain.flow_full import FlowMigratedFull +from apps.shared.domain import InternalModel -class AppletMigratedFull(AppletFull): + +class AppletMigratedFull(AppletFetchBase, InternalModel): migrated_date: datetime.datetime migrated_updated: datetime.datetime + extra_fields: dict = Field(default_factory=dict) + activities: list[ActivityMigratedFull] = Field(default_factory=list) + activity_flows: list[FlowMigratedFull] = Field(default_factory=list) -class AppletMigratedHistoryFull(AppletFull): +class AppletMigratedHistoryFull(AppletFetchBase, InternalModel): migrated_date: datetime.datetime migrated_updated: datetime.datetime + extra_fields: dict = Field(default_factory=dict) + activities: list[ActivityMigratedFull] = Field(default_factory=list) + activity_flows: list[FlowMigratedFull] = Field(default_factory=list) diff --git a/src/apps/migrate/domain/flow_full.py b/src/apps/migrate/domain/flow_full.py new file mode 100644 index 00000000000..57932318eab --- /dev/null +++ b/src/apps/migrate/domain/flow_full.py @@ -0,0 +1,7 @@ +from pydantic import Field + +from apps.activity_flows.domain.flow_full import FlowFull + + +class FlowMigratedFull(FlowFull): + extra_fields: dict = Field(default_factory=dict) diff --git a/src/apps/migrate/run.py b/src/apps/migrate/run.py index bb49396ed13..6e9b56b06b0 100644 --- a/src/apps/migrate/run.py +++ b/src/apps/migrate/run.py @@ -491,6 +491,16 @@ async def get_applets_ids() -> list[str]: "6307d801924264279508777d", "6324c0afb7ee9765ba54229f", "631aba1db7ee970ffa9009e3", + # library + "61b384f7d386d628d862eb76", + "61df0360bf09cb40db5a2b14", + "62b613e0b90b7f2ba9e1d2ae", + "6296531cb90b7f104d02e3f7", + "61e6e627bf09cb40db5a35d0", + "6249dc8d3b4f351025642c3f", + "6239e7695197b94689825f7e", + "625387043b4f351025643e7e", + "627be2ba0a62aa47962268a4", ] for applet in applets: migrating_applets.append(str(applet["_id"])) @@ -645,9 +655,13 @@ def migrate_user_pins( skipped += 1 continue to_migrate.append(profile) - rows_count = postgres.save_user_pins(to_migrate) - migration_log.info(f"Inserted {rows_count} rows") - migration_log.info("User pins migration end") + try: + rows_count = postgres.save_user_pins(to_migrate) + migration_log.info(f"Inserted {rows_count} rows") + except Exception as e: + migration_log.error(e) + finally: + migration_log.info("User pins migration end") def migrate_folders(workspace_id: str | None, mongo, postgres): @@ -667,11 +681,14 @@ def migrate_folders(workspace_id: str | None, mongo, postgres): migration_log.info("Folders migration end") -def migrate_library(applet_ids: list[ObjectId] | None, mongo, postgres): +def migrate_library( + applet_ids: list[ObjectId] | None, mongo: Mongo, postgres: Postgres +): migration_log.info("Library & themes migration start") lib_count = 0 theme_count = 0 - lib_set, theme_set = mongo.get_library(applet_ids) + lib_set = mongo.get_library(applet_ids) + theme_set = mongo.get_themes() for lib in lib_set: if lib.applet_id_version is None: version = postgres.get_latest_applet_id_version(lib.applet_id) @@ -683,14 +700,19 @@ def migrate_library(applet_ids: list[ObjectId] | None, mongo, postgres): ) lib.search_keywords = keywords + lib.keywords success = postgres.save_library_item(lib) + if success: lib_count += 1 + if lib.name != lib.display_name: + postgres.update_applet_name( + lib.applet_id, lib.name, lib.applet_id_version + ) for theme in theme_set: success = postgres.save_theme_item(theme) if success: theme_count += 1 - postgres.add_theme_to_applet(theme.applet_id, theme.id) + # postgres.add_theme_to_applet(theme.applet_id, theme.id) applet_themes = mongo.get_applet_theme_mapping() applets_count = postgres.set_applets_themes(applet_themes) @@ -831,7 +853,7 @@ async def migrate_public_links(postgres: Postgres, mongo: Mongo): applet_mongo_ids = postgres.get_migrated_applets() links = mongo.get_public_link_mappings(applet_mongo_ids) await postgres.save_public_link(links) - migration_log.info("Public links migration start") + migration_log.info("Public links migration end") async def main(workspace_id: str | None, applets_ids: list[str] | None): diff --git a/src/apps/migrate/services/activity_history_service.py b/src/apps/migrate/services/activity_history_service.py index 2965afb12bf..215d5a042cb 100644 --- a/src/apps/migrate/services/activity_history_service.py +++ b/src/apps/migrate/services/activity_history_service.py @@ -1,8 +1,6 @@ -import uuid - from apps.activities.crud import ActivityHistoriesCRUD from apps.activities.db.schemas import ActivityHistorySchema -from apps.activities.domain.activity_full import ActivityFull +from apps.migrate.domain.activity_full import ActivityMigratedFull from apps.migrate.domain.applet_full import AppletMigratedFull from apps.migrate.services.activity_item_history_service import ( ActivityItemHistoryMigrationService, @@ -19,7 +17,7 @@ def __init__(self, session, applet: AppletMigratedFull, version: str): self._applet_id_version = f"{applet.id}_{version}" self.session = session - async def add(self, activities: list[ActivityFull]): + async def add(self, activities: list[ActivityMigratedFull]): activity_items = [] schemas = [] diff --git a/src/apps/migrate/services/activity_item_history_service.py b/src/apps/migrate/services/activity_item_history_service.py index 7b3fdd038d2..76750669e9d 100644 --- a/src/apps/migrate/services/activity_item_history_service.py +++ b/src/apps/migrate/services/activity_item_history_service.py @@ -1,7 +1,7 @@ from apps.activities.crud import ActivityItemHistoriesCRUD from apps.activities.db.schemas import ActivityItemHistorySchema -from apps.activities.domain.activity_full import ( - ActivityItemFull, +from apps.migrate.domain.activity_full import ( + ActivityItemMigratedFull, ) from apps.migrate.domain.applet_full import AppletMigratedFull from apps.migrate.utilities import prepare_extra_fields_to_save @@ -13,7 +13,7 @@ def __init__(self, session, version: str, applet: AppletMigratedFull): self.session = session self._applet = applet - async def add(self, activity_items: list[ActivityItemFull]): + async def add(self, activity_items: list[ActivityItemMigratedFull]): schemas = [] for item in activity_items: @@ -38,9 +38,7 @@ async def add(self, activity_items: list[ActivityItemFull]): updated_at=self._applet.updated_at, migrated_date=self._applet.migrated_date, migrated_updated=self._applet.migrated_updated, - extra_fields=prepare_extra_fields_to_save( - item.extra_fields - ), + extra_fields={}, ) ) await ActivityItemHistoriesCRUD(self.session).create_many(schemas) diff --git a/src/apps/migrate/services/activity_service.py b/src/apps/migrate/services/activity_service.py index e92fb99eb0c..986bbc6f349 100644 --- a/src/apps/migrate/services/activity_service.py +++ b/src/apps/migrate/services/activity_service.py @@ -3,17 +3,10 @@ from apps.activities.crud import ActivitiesCRUD from apps.activities.db.schemas import ActivitySchema -from apps.activities.domain.activity_create import ( - ActivityCreate, -) - -from apps.activities.domain.activity_full import ActivityFull -from apps.activities.domain.activity_update import ( - ActivityUpdate, - PreparedActivityItemUpdate, -) +from apps.activities.domain.activity_create import ActivityCreate from apps.activities.services.activity_item import ActivityItemService from apps.migrate.domain.activity_create import ActivityItemMigratedCreate +from apps.migrate.domain.activity_full import ActivityMigratedFull from apps.migrate.domain.applet_full import AppletMigratedFull from apps.migrate.utilities import prepare_extra_fields_to_save @@ -27,7 +20,7 @@ async def create( self, applet: AppletMigratedFull, activities_create: list[ActivityCreate], - ) -> list[ActivityFull]: + ) -> list[ActivityMigratedFull]: schemas = [] activity_key_id_map: dict[uuid.UUID, uuid.UUID] = dict() activity_id_key_map: dict[uuid.UUID, uuid.UUID] = dict() @@ -101,12 +94,11 @@ async def create( prepared_activity_items ) activities = list() - - activity_id_map: dict[uuid.UUID, ActivityFull] = dict() + activity_id_map: dict[uuid.UUID, ActivityMigratedFull] = dict() for activity_schema in activity_schemas: activity_schema.key = activity_id_key_map[activity_schema.id] - activity = ActivityFull.from_orm(activity_schema) + activity = ActivityMigratedFull.from_orm(activity_schema) activities.append(activity) activity_id_map[activity.id] = activity @@ -121,7 +113,7 @@ async def update_create( self, applet: AppletMigratedFull, activities_create: list[ActivityCreate], - ) -> list[ActivityFull]: + ) -> list[ActivityMigratedFull]: schemas = [] activity_key_id_map: dict[uuid.UUID, uuid.UUID] = dict() activity_id_key_map: dict[uuid.UUID, uuid.UUID] = dict() @@ -191,12 +183,11 @@ async def update_create( prepared_activity_items ) activities = list() - - activity_id_map: dict[uuid.UUID, ActivityFull] = dict() + activity_id_map: dict[uuid.UUID, ActivityMigratedFull] = dict() for activity_schema in activity_schemas: activity_schema.key = activity_id_key_map[activity_schema.id] - activity = ActivityFull.from_orm(activity_schema) + activity = ActivityMigratedFull.from_orm(activity_schema) activities.append(activity) activity_id_map[activity.id] = activity diff --git a/src/apps/migrate/services/applet_history_service.py b/src/apps/migrate/services/applet_history_service.py index db045ca1c5a..c8c10c199b8 100644 --- a/src/apps/migrate/services/applet_history_service.py +++ b/src/apps/migrate/services/applet_history_service.py @@ -52,6 +52,7 @@ async def add_history( migrated_date=applet.migrated_date, migrated_updated=applet.migrated_updated, extra_fields=prepare_extra_fields_to_save(applet.extra_fields), + stream_enabled=applet.stream_enabled, ) ) await ActivityHistoryMigrationService( diff --git a/src/apps/migrate/services/applet_service.py b/src/apps/migrate/services/applet_service.py index e76194bd2a1..7ecaabdd1de 100644 --- a/src/apps/migrate/services/applet_service.py +++ b/src/apps/migrate/services/applet_service.py @@ -8,10 +8,7 @@ from apps.applets.domain import ( Role, ) -from apps.applets.domain.applet_create_update import ( - AppletCreate, - AppletUpdate, -) +from apps.applets.domain.applet_create_update import AppletCreate from apps.migrate.domain.applet_full import AppletMigratedFull from apps.migrate.services.applet_history_service import ( AppletMigrationHistoryService, @@ -106,6 +103,8 @@ async def _create(self, create_data: AppletCreate) -> AppletMigratedFull: extra_fields=prepare_extra_fields_to_save( create_data.extra_fields ), + retention_period=create_data.retention_period, + retention_type=create_data.retention_type, ) ) return AppletMigratedFull.from_orm(schema) @@ -178,6 +177,8 @@ async def _update( extra_fields=prepare_extra_fields_to_save( update_data.extra_fields ), + retention_period=update_data.retention_period, + retention_type=update_data.retention_type, ), ) return AppletMigratedFull.from_orm(schema) diff --git a/src/apps/migrate/services/event_service.py b/src/apps/migrate/services/event_service.py index 11c6eea5fd0..9660a3ff2ea 100644 --- a/src/apps/migrate/services/event_service.py +++ b/src/apps/migrate/services/event_service.py @@ -385,17 +385,22 @@ async def run_events_migration(self): f"Migrate events {i}/{number_of_events_in_mongo}. Working on Event: {event.id}" ) try: - # Migrate data to PeriodicitySchema - periodicity = await self._create_periodicity(event) - - # Migrate data to EventSchema - pg_event = await self._create_event(event, periodicity) - - # Migrate data to ActivityEventsSchema or FlowEventsSchema - if event.data.activity_id: - await self._create_activity(event, pg_event) - if event.data.activity_flow_id: - await self._create_flow(event, pg_event) + if event.data.activity_id or event.data.activity_flow_id: + # Migrate data to PeriodicitySchema + periodicity = await self._create_periodicity(event) + + # Migrate data to EventSchema + pg_event = await self._create_event(event, periodicity) + + # Migrate data to ActivityEventsSchema or FlowEventsSchema + if event.data.activity_id: + await self._create_activity(event, pg_event) + if event.data.activity_flow_id: + await self._create_flow(event, pg_event) + else: + raise Exception( + "Mongo event do not have any information about activity and flow" + ) # Migrate data to NotificationSchema if event.data.notifications: @@ -414,19 +419,20 @@ async def run_events_migration(self): user_ids: list = self._check_user_existence(event) # add individual event for already created (on previous steps) event - await self._create_user(event, pg_event, user_id[0]) + await self._create_user(event, pg_event, user_ids[0]) # create new events for next users new_events: list = [] - for user_id in user_ids[1:]: + for user_id in event.data.users[1:]: e = copy.deepcopy(event) e.id = ObjectId() e.data.users = [user_id] new_events.append(e) - print( - f"\nWill extend events list. Currents number of events is: {len(self.events)}. New number is: {len(self.events)+len(new_events)}\n" + migration_log.debug( + f"Will extend events list. Currents number of events is: {len(self.events)}. New number is: {len(self.events)+len(new_events)}" ) + number_of_events_in_mongo += len(new_events) self.events.extend(new_events) except Exception as e: @@ -522,7 +528,9 @@ def _check_user_existence(self, event: dict) -> ObjectId: for user in event.data.users: profile = Profile().findOne(query={"_id": ObjectId(user)}) if not profile: - print("Unable to find profile by event. Skip") + migration_log.debug( + "Unable to find profile by event. Skip" + ) continue ids.append(profile["userId"]) diff --git a/src/apps/migrate/services/flow_history_service.py b/src/apps/migrate/services/flow_history_service.py index 4db7a987021..7f7c34de272 100644 --- a/src/apps/migrate/services/flow_history_service.py +++ b/src/apps/migrate/services/flow_history_service.py @@ -2,7 +2,7 @@ from apps.activity_flows.crud import FlowsHistoryCRUD from apps.activity_flows.db.schemas import ActivityFlowHistoriesSchema -from apps.activity_flows.domain.flow_full import FlowFull +from apps.migrate.domain.flow_full import FlowMigratedFull from apps.migrate.domain.applet_full import AppletMigratedFull from apps.migrate.services.flow_item_history_service import ( FlowItemHistoryMigrationService, @@ -17,7 +17,7 @@ def __init__(self, session, applet: AppletMigratedFull, version: str): self.applet_id_version = f"{applet.id}_{version}" self.session = session - async def add(self, flows: list[FlowFull]): + async def add(self, flows: list[FlowMigratedFull]): flow_items = [] schemas = [] diff --git a/src/apps/migrate/services/flow_item_history_service.py b/src/apps/migrate/services/flow_item_history_service.py index ec7cbbfbea8..261b956ebd6 100644 --- a/src/apps/migrate/services/flow_item_history_service.py +++ b/src/apps/migrate/services/flow_item_history_service.py @@ -1,10 +1,6 @@ -import uuid - from apps.activity_flows.crud import FlowItemHistoriesCRUD from apps.activity_flows.db.schemas import ActivityFlowItemHistorySchema -from apps.activity_flows.domain.flow_full import ( - ActivityFlowItemFull, -) +from apps.activity_flows.domain.flow_full import ActivityFlowItemFull from apps.migrate.domain.applet_full import AppletMigratedFull diff --git a/src/apps/migrate/services/flow_service.py b/src/apps/migrate/services/flow_service.py index de53518710c..f1e727fb1ff 100644 --- a/src/apps/migrate/services/flow_service.py +++ b/src/apps/migrate/services/flow_service.py @@ -6,11 +6,8 @@ FlowCreate, PreparedFlowItemCreate, ) -from apps.activity_flows.domain.flow_full import FlowFull -from apps.activity_flows.domain.flow_update import ( - FlowUpdate, - PreparedFlowItemUpdate, -) +from apps.activity_flows.domain.flow_update import PreparedFlowItemUpdate +from apps.migrate.domain.flow_full import FlowMigratedFull from apps.migrate.domain.applet_full import AppletMigratedFull from apps.migrate.services.flow_item_service import FlowItemMigrationService from apps.migrate.utilities import prepare_extra_fields_to_save @@ -25,7 +22,7 @@ async def create( applet: AppletMigratedFull, flows_create: list[FlowCreate], activity_key_id_map: dict[uuid.UUID, uuid.UUID], - ) -> list[FlowFull]: + ) -> list[FlowMigratedFull]: schemas = list() prepared_flow_items = list() for index, flow_create in enumerate(flows_create): @@ -67,7 +64,7 @@ async def create( flow_id_map = dict() for flow_schema in flow_schemas: - flow = FlowFull.from_orm(flow_schema) + flow = FlowMigratedFull.from_orm(flow_schema) flows.append(flow) flow_id_map[flow.id] = flow @@ -81,7 +78,7 @@ async def update_create( applet: AppletMigratedFull, flows_update: list[FlowCreate], activity_key_id_map: dict[uuid.UUID, uuid.UUID], - ) -> list[FlowFull]: + ) -> list[FlowMigratedFull]: schemas = list() prepared_flow_items = list() @@ -124,7 +121,7 @@ async def update_create( flow_id_map = dict() for flow_schema in flow_schemas: - flow = FlowFull.from_orm(flow_schema) + flow = FlowMigratedFull.from_orm(flow_schema) flows.append(flow) flow_id_map[flow.id] = flow diff --git a/src/apps/migrate/services/mongo.py b/src/apps/migrate/services/mongo.py index c15e270509a..0094a5d270f 100644 --- a/src/apps/migrate/services/mongo.py +++ b/src/apps/migrate/services/mongo.py @@ -53,7 +53,7 @@ uuid_to_mongoid, ) from apps.shared.domain.base import InternalModel, PublicModel -from apps.shared.encryption import encrypt, get_key +from apps.shared.encryption import get_key from apps.workspaces.domain.constants import Role from apps.shared.version import INITIAL_VERSION @@ -79,6 +79,29 @@ def decrypt(data): def patch_broken_applet_versions(applet_id: str, applet_ld: dict) -> dict: + broken_conditional_date_item = [ + "62a8d7d7b90b7f2ba9e1aa43", + ] + if applet_id in broken_conditional_date_item: + for property in applet_ld["reprolib:terms/order"][0]["@list"][0][ + "reprolib:terms/addProperties" + ]: + if property["reprolib:terms/isAbout"][0]["@id"] == "EPDSMotherDOB": + property["reprolib:terms/isVis"][0]["@value"] = True + + broken_item_flow_order = ["613f6eba6401599f0e495dc5"] + if applet_id in broken_item_flow_order: + for activity in applet_ld["reprolib:terms/order"][0]["@list"]: + for prop in activity["reprolib:terms/addProperties"]: + prop["reprolib:terms/isVis"][0]["@value"] = True + if applet_ld["schema:version"][0]["@value"] == "1.2.2": + applet_ld["reprolib:terms/order"][0]["@list"][0][ + "reprolib:terms/order" + ][0]["@list"].pop(26) + applet_ld["reprolib:terms/order"][0]["@list"][0][ + "reprolib:terms/addProperties" + ].pop(26) + broken_applet_versions = [ "6201cc26ace55b10691c0814", "6202734eace55b10691c0fc4", @@ -321,13 +344,42 @@ def patch_broken_applet_versions(applet_id: str, applet_ld: dict) -> dict: def patch_broken_applets( applet_id: str, applet_ld: dict, applet_mongo: dict ) -> tuple[dict, dict]: + broken_report_condition_item = [ + "6358265b5cb700431121f033", + "6358267b5cb700431121f143", + "63696d4a52ea02101467671d", + "63696e7c52ea021014676784", + ] + if applet_id in broken_report_condition_item: + for report in applet_ld["reprolib:terms/order"][0]["@list"][0][ + "reprolib:terms/reports" + ][0]["@list"]: + if report["@id"] == "sumScore_suicidalorselfinjury": + report["reprolib:terms/conditionals"][0]["@list"][1][ + "reprolib:terms/printItems" + ][0]["@list"] = [] + report["reprolib:terms/conditionals"][0]["@list"][0][ + "reprolib:terms/printItems" + ][0]["@list"] = [] + + broken_conditional_date_item = [ + "62a8d7d7b90b7f2ba9e1aa43", + "62a8d7e5b90b7f2ba9e1aab3", + ] + if applet_id in broken_conditional_date_item: + for property in applet_ld["reprolib:terms/order"][0]["@list"][0][ + "reprolib:terms/addProperties" + ]: + if property["reprolib:terms/isAbout"][0]["@id"] == "EPDSMotherDOB": + property["reprolib:terms/isVis"][0]["@value"] = False + broken_item_flow = [ "6522a4753c36ce0d4d6cda4d", ] if applet_id in broken_item_flow: applet_ld["reprolib:terms/order"][0]["@list"][0][ "reprolib:terms/addProperties" - ][5]["reprolib:terms/isVis"][0] = {"@value": True} + ][5]["reprolib:terms/isVis"][0] = {"@value": False} broken_activity_order = [ "63d3d579b71996780cdf409a", @@ -391,7 +443,7 @@ def patch_broken_applets( property["reprolib:terms/isAbout"][0]["@id"] == "IUQ_Wd_Social_Device" ): - property["reprolib:terms/isVis"] = [{"@value": True}] + property["reprolib:terms/isVis"] = [{"@value": False}] repo_replacements = [ ( @@ -720,11 +772,7 @@ def patch_broken_applets( applet_ld = patch_prize_activity(applet_id, applet_ld) - if ( - applet_id not in broken_applets - and applet_id not in broken_applet_version - ): - patch_broken_visability_for_applet(applet_ld) + patch_broken_visability_for_applet(applet_ld) return applet_ld, applet_mongo @@ -819,6 +867,15 @@ def set_isvis(entity: dict, value: bool) -> None: set_isvis(add_prop, acitivity_id_isvis_map[activity_id]) +def patch_library_version(applet_id: str, version: str) -> str: + if applet_id == "61f42e5c62485608c74c2a7e": + version = "4.2.42" + elif applet_id == "623b81c45197b9338bdaea22": + version = "2.11.39" + + return version + + class Mongo: def __init__(self) -> None: # Setup MongoDB connection @@ -1223,15 +1280,18 @@ async def get_applet(self, applet_id: str) -> dict: or applet["meta"]["applet"] == {} ): raise EmptyAppletException() - + # fetch version + applet = self.fetch_applet_version(applet) ld_request_schema = self.get_applet_repro_schema(applet) ld_request_schema, applet = patch_broken_applets( applet_id, ld_request_schema, applet ) + ld_request_schema = self.preprocess_performance_task(ld_request_schema) converted = await self.get_converter_result(ld_request_schema) converted.extra_fields["created"] = applet["created"] converted.extra_fields["updated"] = applet["updated"] + converted.extra_fields["creator"] = str(applet.get("creatorId", None)) converted.extra_fields["version"] = applet["meta"]["applet"].get( "version", INITIAL_VERSION ) @@ -1343,11 +1403,17 @@ def resolve_arbitrary_client(profile: dict): def get_answer_migration_queries(self, **kwargs): db = self.get_main_or_arbitrary_db(kwargs["applet_id"]) query = { - "meta.responses": {"$exists": True}, + "meta.responses": { + "$exists": True, + # Some items have response, but response is empty dict, dont't migrate + "$ne": {}, + }, "meta.activity.@id": kwargs["activity_id"], "meta.applet.@id": kwargs["applet_id"], "meta.applet.version": kwargs["version"], } + if kwargs.get("assessments_only"): + query["meta.reviewing"] = {"$exists": True} item_collection = db["item"] try: creators_ids = item_collection.find(query).distinct("creatorId") @@ -1412,6 +1478,9 @@ def docs_by_ids( def get_user_nickname(self, user_profile: dict) -> str: nick_name = decrypt(user_profile.get("nickName")) if not nick_name: + # f_name = decrypt(user_profile.get("firstName")) + # l_name = decrypt(user_profile.get("lastName")) + # nick_name = f"{f_name} {l_name}" if f_name and l_name else f"" nick_name = "" return nick_name @@ -1513,7 +1582,8 @@ def get_anons(self, anon_id: uuid.UUID) -> List[AppletUserDAO]: created_at=datetime.datetime.utcnow(), updated_at=datetime.datetime.utcnow(), meta={ - "nickname": "Mindlogger ChildMindInstitute", + # nickname is encrypted version of 'Mindlogger ChildMindInstitute' + "nickname": "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=", "secretUserId": "Guest Account Submission", "legacyProfileId": str(applet_profile["_id"]), }, @@ -1532,6 +1602,11 @@ def get_user_roles(applet_profile: dict) -> list[str]: return ["manager", "user"] if "user" in roles else ["manager"] return roles + def has_manager_role(self, roles: list[str]): + manager_roles = set(Role.managers()) + exist = bool(set(roles).intersection(manager_roles)) + return exist + def get_roles_mapping_from_applet_profile( self, migrated_applet_ids: List[ObjectId] ): @@ -1551,7 +1626,6 @@ def get_roles_mapping_from_applet_profile( editor_count = 0 coordinator_count = 0 respondent_count = 0 - managerial_applets = [] for applet_profile in applet_profiles: if applet_profile["userId"] in not_found_users: @@ -1572,9 +1646,8 @@ def get_roles_mapping_from_applet_profile( continue roles = self.get_user_roles(applet_profile) + has_manager_role = self.has_manager_role(roles) for role_name in set(roles): - if role_name != "user": - managerial_applets.append(applet_profile["appletId"]) meta = {} if role_name == Role.REVIEWER: meta["respondents"] = self.respondents_by_applet_profile( @@ -1595,15 +1668,13 @@ def get_roles_mapping_from_applet_profile( applet_profile ) if data: - if applet_profile["appletId"] in managerial_applets: + if has_manager_role: if data["nick"] == "": f_name = user["firstName"] l_name = user["lastName"] - meta["nickname"] = ( - f"{f_name} {l_name}" - if f_name and l_name - else f"- -" - ) + f_name = f_name if f_name else "-" + l_name = l_name if l_name else "-" + meta["nickname"] = f"{f_name} {l_name}" else: meta["nickname"] = data["nick"] @@ -1615,6 +1686,12 @@ def get_roles_mapping_from_applet_profile( else: meta["nickname"] = data["nick"] meta["secretUserId"] = data["secret"] + if "nickname" in meta: + nickname = meta.pop("nickname") + if nickname != "": + meta["nickname"] = enc.process_bind_param( + nickname, String + ) owner_id = self.get_owner_by_applet(applet_profile["appletId"]) if not owner_id: @@ -1915,41 +1992,36 @@ def get_folder_mapping( ) return set(folders_list), set(applets_list) - def get_theme( - self, key: str | ObjectId, applet_id: uuid.UUID - ) -> ThemeDao | None: - if not isinstance(key, ObjectId): - try: - theme_id = ObjectId(key) - except Exception: - return None - theme_doc = self.db["folder"].find_one({"_id": theme_id}) - if theme_doc: - meta = theme_doc.get("meta", {}) - return ThemeDao( - id=mongoid_to_uuid(theme_doc["_id"]), - creator_id=mongoid_to_uuid(theme_doc["creatorId"]), - name=theme_doc["name"], - logo=meta.get("logo"), - small_logo=meta.get("smallLogo"), - background_image=meta.get("backgroundImage"), - primary_color=meta.get("primaryColor"), - secondary_color=meta.get("secondaryColor"), - tertiary_color=meta.get("tertiaryColor"), - public=theme_doc["public"], - allow_rename=True, - created_at=theme_doc["created"], - updated_at=theme_doc["updated"], - is_default=False, - applet_id=applet_id, - ) - return None + def get_themes(self) -> list[ThemeDao]: + themes = [] + theme_docs = self.db["folder"].find( + {"parentId": ObjectId("61323c0ff7102f0a6e9b3588")} + ) + for theme_doc in theme_docs: + if theme_doc: + meta = theme_doc.get("meta", {}) + themes.append( + ThemeDao( + id=mongoid_to_uuid(theme_doc["_id"]), + creator_id=mongoid_to_uuid(theme_doc["creatorId"]), + name=theme_doc["name"], + logo=meta.get("logo"), + small_logo=meta.get("smallLogo"), + background_image=meta.get("backgroundImage"), + primary_color=meta.get("primaryColor"), + secondary_color=meta.get("secondaryColor"), + tertiary_color=meta.get("tertiaryColor"), + public=theme_doc["public"], + allow_rename=True, + created_at=theme_doc["created"], + updated_at=theme_doc["updated"], + is_default=False, + ) + ) + return themes - def get_library( - self, applet_ids: list[ObjectId] | None - ) -> (LibraryDao, ThemeDao): + def get_library(self, applet_ids: list[ObjectId] | None) -> LibraryDao: lib_set = set() - theme_set = set() query = {} if applet_ids: query["appletId"] = {"$in": applet_ids} @@ -1957,6 +2029,7 @@ def get_library( for lib_doc in library: applet_id = mongoid_to_uuid(lib_doc["appletId"]) version = lib_doc.get("version") + version = patch_library_version(str(lib_doc["appletId"]), version) if version: version_id = f"{applet_id}_{version}" else: @@ -1975,14 +2048,11 @@ def get_library( migrated_date=now, migrated_updated=now, is_deleted=False, + name=lib_doc["name"], + display_name=lib_doc["displayName"], ) - theme_id = lib_doc.get("themeId") - if theme_id: - theme = self.get_theme(theme_id, applet_id) - if theme: - theme_set.add(theme) lib_set.add(lib) - return lib_set, theme_set + return lib_set def get_applets_by_workspace(self, workspace_id: str) -> list[str]: items = Profile().find(query={"accountId": ObjectId(workspace_id)}) @@ -2015,7 +2085,7 @@ def get_public_link_mappings( user_id = applet_profile["userId"] if not isinstance(user_id, ObjectId): user_id = ObjectId(user_id) - if link_id and login: + if link_id is not None and login is not None: result.append( PublicLinkDao( applet_bson=document["_id"], @@ -2053,3 +2123,160 @@ def get_applet_theme_mapping(self) -> list[AppletTheme]: ) result.append(mapper) return result + + def get_repro_order(self, schema: dict): + act_list = schema.get("reprolib:terms/order", []) + result = [] + for act in act_list: + _list_attr = act.get("@list", []) + result += _list_attr + return result + + @staticmethod + def is_has_item_types( + _types: list[str], activity_items: list[dict] + ) -> bool: + for item in activity_items: + _inputs = item.get("reprolib:terms/inputType", []) + for _input in _inputs: + if _input.get("@value") in _types: + return True + return False + + def get_activity_names(self, activity_schemas: list[dict]) -> list[str]: + names = [] + for activity in activity_schemas: + name_attr = activity.get( + "http://www.w3.org/2004/02/skos/core#prefLabel" + ) + name_attr = next(iter(name_attr), {}) + name = name_attr.get("@value") + if name: + names.append(name) + return names + + def _is_cst(self, activity_items: list[dict], cst_type: str): + def _filter_user_input_type(item: dict): + _type = next(iter(item.get("@type", [])), None) + if not _type or _type != "http://schema.org/Text": + return False + name = next(iter(item.get("schema:name", [])), {}) + value = next(iter(item.get("schema:value", [])), {}) + if ( + name.get("@value") == "userInputType" + and value.get("@value") == cst_type + ): + return True + + for item in activity_items: + _inputs = item.get("reprolib:terms/inputs", []) + for _input in _inputs: + flt_result = next( + filter(_filter_user_input_type, _inputs), None + ) + if flt_result: + return self.is_has_item_types( + ["stabilityTracker"], activity_items + ) + return False + + def is_cst(self, activity_items: list[dict]) -> bool: + return self._is_cst(activity_items, "touch") + + def is_cst_gyro(self, activity_items: list[dict]) -> bool: + return self._is_cst(activity_items, "gyroscope") + + def is_ab_trails( + self, + applet_schema: dict, + activity_items: list[dict], + activity_names: list[str], + ) -> bool: + # Check activity names + # Try to find 'Trails_iPad', 'Trails_Mobile' strings as activity name + ab_trails_act_names = ["Trails_iPad", "Trails_Mobile"] + m = list(map(lambda name: name in ab_trails_act_names, activity_names)) + if not any(m): + return False + # Check applet name + # Try to find 'A/B Trails' as expected applet name + ab_trails_name = "A/B Trails" + applet_name = applet_schema.get( + "http://www.w3.org/2004/02/skos/core#prefLabel" + ) + applet_name = next(iter(applet_name), {}) + if applet_name.get("@value") != ab_trails_name: + return False + # Check activity item types + # Try to find items with type 'trail' + return self.is_has_item_types(["trail"], activity_items) + + def is_flanker(self, activity_items: list[dict]) -> bool: + return self.is_has_item_types( + ["visual-stimulus-response"], activity_items + ) + + def preprocess_performance_task(self, applet_schema) -> dict: + # Add activity type by activity items for activities without type + activities = self.get_repro_order(applet_schema) + activity_names = self.get_activity_names(activities) + for activity in activities: + activity_type = activity.get("reprolib:terms/activityType") + if activity_type is not None: + # If activity have activityType it is normal case + continue + items = self.get_repro_order(activity) + if self.is_ab_trails(applet_schema, items, activity_names): + name_attr = activity.get( + "http://www.w3.org/2004/02/skos/core#prefLabel" + ) + activity_name = next(iter(name_attr), {}) + if activity_name.get("@value") == "Trails_Mobile": + name = "TRAILS_MOBILE" + else: + name = "TRAILS_IPAD" + activity["reprolib:terms/activityType"] = [ + { + "@type": "http://www.w3.org/2001/XMLSchema#string", + "@value": name, + } + ] + continue + elif self.is_cst_gyro(items): + activity["reprolib:terms/activityType"] = [ + { + "@type": "http://www.w3.org/2001/XMLSchema#string", + "@value": "CST_GYRO", + } + ] + elif self.is_cst(items): + activity["reprolib:terms/activityType"] = [ + { + "@type": "http://www.w3.org/2001/XMLSchema#string", + "@value": "CST_TOUCH", + } + ] + continue + elif self.is_flanker(items): + activity["reprolib:terms/activityType"] = [ + { + "@type": "http://www.w3.org/2001/XMLSchema#string", + "@value": "FLANKER", + } + ] + continue + return applet_schema + + def fetch_applet_version(self, applet: dict): + if not applet["meta"]["applet"].get("version", None): + protocol = self.db["folder"].find_one( + { + "_id": ObjectId( + str(applet["meta"]["protocol"]["_id"]).split("/")[1] + ) + } + ) + applet["meta"]["applet"]["version"] = protocol["meta"]["protocol"][ + "schema:version" + ][0]["@value"] + return applet diff --git a/src/apps/migrate/services/postgres.py b/src/apps/migrate/services/postgres.py index 522e1847df1..66b3f277a1d 100644 --- a/src/apps/migrate/services/postgres.py +++ b/src/apps/migrate/services/postgres.py @@ -881,3 +881,42 @@ def themes_slice(self) -> str: for row in rows: s += f"\t{row[0]}: {row[1]}\n" return s + + def update_applet_name( + self, applet_id: uuid.UUID, name: str, applet_id_version: str + ): + sql_applet_version = """ + UPDATE applet_histories + SET display_name = %s + WHERE id_version = %s; + """ + + sql_applet = """ + UPDATE applets + SET display_name = %s + WHERE id = %s AND version = %s; + """ + + try: + cursor = self.connection.cursor() + + cursor.execute( + sql_applet_version, + ( + str(name), + str(applet_id_version), + ), + ) + cursor.execute( + sql_applet, + ( + str(name), + str(applet_id), + str(applet_id_version.split("_")[1]), + ), + ) + migration_log.debug(f"[LIBRARY] Name changed: {applet_id}") + except Exception as ex: + migration_log.debug(f"[LIBRARY] Name cannot be changed: {ex}") + finally: + self.connection.commit() diff --git a/src/apps/migrate/utilities.py b/src/apps/migrate/utilities.py index 4ed3628e6e3..3c97fe1f230 100644 --- a/src/apps/migrate/utilities.py +++ b/src/apps/migrate/utilities.py @@ -15,7 +15,9 @@ def mongoid_to_uuid(id_): return uuid.UUID(str(id_) + "00000000") -def uuid_to_mongoid(uid: uuid.UUID) -> None | ObjectId: +def uuid_to_mongoid(uid: uuid.UUID | str) -> None | ObjectId: + if isinstance(uid, str): + uid = uuid.UUID(uid) return ObjectId(uid.hex[:-8]) if uid.hex[-8:] == "0" * 8 else None diff --git a/src/apps/schedule/api/schedule.py b/src/apps/schedule/api/schedule.py index babd1ff14ce..f33d37e5381 100644 --- a/src/apps/schedule/api/schedule.py +++ b/src/apps/schedule/api/schedule.py @@ -1,11 +1,13 @@ import uuid from copy import deepcopy +from datetime import date, timedelta from fastapi import Body, Depends from firebase_admin.exceptions import FirebaseError from apps.answers.errors import UserDoesNotHavePermissionError -from apps.applets.crud import UserAppletAccessCRUD +from apps.applets.crud import AppletsCRUD, UserAppletAccessCRUD +from apps.applets.db.schemas import AppletSchema from apps.applets.service import AppletService from apps.authentication.deps import get_current_user from apps.schedule.domain.schedule.filters import EventQueryParams @@ -52,8 +54,8 @@ async def schedule_create( try: await applet_service.send_notification_to_applet_respondents( applet_id, - "Schedules are updated", - "Schedules are updated", + "Your schedule has been changed, click to update.", + "Your schedule has been changed, click to update.", FirebaseNotificationType.SCHEDULE_UPDATED, respondent_ids=[schedule.respondent_id] if schedule.respondent_id @@ -145,8 +147,8 @@ async def schedule_delete_all( try: await applet_service.send_notification_to_applet_respondents( applet_id, - "Schedules are updated", - "Schedules are updated", + "Your schedule has been changed, click to update.", + "Your schedule has been changed, click to update.", FirebaseNotificationType.SCHEDULE_UPDATED, ) except FirebaseError as e: @@ -175,8 +177,8 @@ async def schedule_delete_by_id( try: await applet_service.send_notification_to_applet_respondents( applet_id, - "Schedules are updated", - "Schedules are updated", + "Your schedule has been changed, click to update.", + "Your schedule has been changed, click to update.", FirebaseNotificationType.SCHEDULE_UPDATED, respondent_ids=[respondent_id] if respondent_id else None, ) @@ -207,8 +209,8 @@ async def schedule_update( try: await applet_service.send_notification_to_applet_respondents( applet_id, - "Schedules are updated", - "Schedules are updated", + "Your schedule has been changed, click to update.", + "Your schedule has been changed, click to update.", FirebaseNotificationType.SCHEDULE_UPDATED, respondent_ids=[schedule.respondent_id] if schedule.respondent_id @@ -256,8 +258,8 @@ async def schedule_delete_by_user( try: await applet_service.send_notification_to_applet_respondents( applet_id, - "Schedules are updated", - "Schedules are updated", + "Your schedule has been changed, click to update.", + "Your schedule has been changed, click to update.", FirebaseNotificationType.SCHEDULE_UPDATED, respondent_ids=[respondent_id], ) @@ -281,6 +283,45 @@ async def schedule_get_all_by_user( return ResponseMulti(result=schedules, count=count) +async def schedule_get_all_by_respondent_user( + user: User = Depends(get_current_user), + session=Depends(get_session), +) -> ResponseMulti[PublicEventByUser]: + """Get all the respondent's schedules for the next 2 weeks.""" + max_date_from_event_delta_days = 15 + min_date_to_event_delta_days = 2 + today: date = date.today() + max_start_date: date = today + timedelta( + days=max_date_from_event_delta_days + ) + min_end_date: date = today - timedelta(days=min_date_to_event_delta_days) + + async with atomic(session): + # applets for this endpoint must be equal to + # applets from /applets?roles=respondent endpoint + query_params: QueryParams = QueryParams( + filters={"roles": Role.RESPONDENT, "flat_list": False}, + limit=10000, + ) + applets: list[AppletSchema] = await AppletsCRUD( + session + ).get_applets_by_roles( + user_id=user.id, + roles=[Role.RESPONDENT], + query_params=query_params, + exclude_without_encryption=True, + ) + applet_ids: list[uuid.UUID] = [applet.id for applet in applets] + + schedules = await ScheduleService(session).get_upcoming_events_by_user( + user_id=user.id, + applet_ids=applet_ids, + min_end_date=min_end_date, + max_start_date=max_start_date, + ) + return ResponseMulti(result=schedules, count=len(schedules)) + + async def schedule_get_by_user( applet_id: uuid.UUID, user: User = Depends(get_current_user), @@ -315,8 +356,8 @@ async def schedule_remove_individual_calendar( try: await applet_service.send_notification_to_applet_respondents( applet_id, - "Schedules are updated", - "Schedules are updated", + "Your schedule has been changed, click to update.", + "Your schedule has been changed, click to update.", FirebaseNotificationType.SCHEDULE_UPDATED, respondent_ids=[respondent_id], ) @@ -368,8 +409,8 @@ async def schedule_create_individual( try: await applet_service.send_notification_to_applet_respondents( applet_id, - "Schedules are updated", - "Schedules are updated", + "Your schedule has been changed, click to update.", + "Your schedule has been changed, click to update.", FirebaseNotificationType.SCHEDULE_UPDATED, respondent_ids=[respondent_id], ) diff --git a/src/apps/schedule/commands/__init__.py b/src/apps/schedule/commands/__init__.py new file mode 100644 index 00000000000..d5dcc72dd58 --- /dev/null +++ b/src/apps/schedule/commands/__init__.py @@ -0,0 +1,3 @@ +from apps.schedule.commands.remove_events import ( # noqa: F401 + app as events_cli, +) diff --git a/src/apps/schedule/commands/remove_events.py b/src/apps/schedule/commands/remove_events.py new file mode 100644 index 00000000000..c0399b38513 --- /dev/null +++ b/src/apps/schedule/commands/remove_events.py @@ -0,0 +1,52 @@ +import asyncio +from functools import wraps + +import typer +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Query + +from apps.activities.db.schemas.activity import ActivitySchema +from apps.schedule.service import ScheduleService +from infrastructure.database import atomic, session_manager + +app = typer.Typer() + + +def coro(f): + @wraps(f) + def wrapper(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +async def get_assessments(session: AsyncSession) -> list[ActivitySchema]: + query: Query = select(ActivitySchema) + query = query.where(ActivitySchema.is_reviewable.is_(True)) + res = await session.execute(query) + return res.scalars().all() # noqa + + +@app.command(short_help="Remove events for assessments") +@coro +async def remove_events(): + session_maker = session_manager.get_session() + try: + async with session_maker() as session: + async with atomic(session): + try: + assessments = await get_assessments(session) + service = ScheduleService(session) + for activity in assessments: + print( + f"Applet: {activity.applet_id} " + f"Activity: {activity.id}" + ) + await service.delete_by_activity_ids( + activity.applet_id, [activity.id] + ) + except Exception as ex: + print(ex) + finally: + await session_maker.remove() diff --git a/src/apps/schedule/crud/events.py b/src/apps/schedule/crud/events.py index daa31d845a2..3f07a7b15bc 100644 --- a/src/apps/schedule/crud/events.py +++ b/src/apps/schedule/crud/events.py @@ -1,4 +1,5 @@ import uuid +from datetime import date from sqlalchemy.exc import IntegrityError, MultipleResultsFound from sqlalchemy.orm import Query @@ -219,6 +220,105 @@ async def get_all_by_applet_and_user( ) return events + async def get_all_by_applets_and_user( + self, + applet_ids: list[uuid.UUID], + user_id: uuid.UUID, + min_end_date: date | None = None, + max_start_date: date | None = None, + ) -> tuple[dict[uuid.UUID, list[EventFull]], set[uuid.UUID]]: + """Get events by applet_ids and user_id + Return {applet_id: [EventFull]}""" + + query: Query = select( + EventSchema, + PeriodicitySchema.start_date, + PeriodicitySchema.end_date, + PeriodicitySchema.selected_date, + PeriodicitySchema.type, + ActivityEventsSchema.activity_id, + FlowEventsSchema.flow_id, + ) + query = query.join( + UserEventsSchema, + and_( + EventSchema.id == UserEventsSchema.event_id, + UserEventsSchema.user_id == user_id, + ), + ) + + query = query.join( + PeriodicitySchema, + PeriodicitySchema.id == EventSchema.periodicity_id, + ) + + query = query.join( + FlowEventsSchema, + FlowEventsSchema.event_id == EventSchema.id, + isouter=True, + ) + query = query.join( + ActivityEventsSchema, + ActivityEventsSchema.event_id == EventSchema.id, + isouter=True, + ) + + query = query.where(EventSchema.applet_id.in_(applet_ids)) + query = query.where(EventSchema.is_deleted == False) # noqa: E712 + if min_end_date and max_start_date: + query = query.where( + or_( + PeriodicitySchema.type == PeriodicityType.ALWAYS, + and_( + PeriodicitySchema.type != PeriodicityType.ONCE, + or_( + PeriodicitySchema.start_date.is_(None), + PeriodicitySchema.start_date <= max_start_date, + ), + or_( + PeriodicitySchema.end_date.is_(None), + PeriodicitySchema.end_date >= min_end_date, + ), + ), + and_( + PeriodicitySchema.type == PeriodicityType.ONCE, + PeriodicitySchema.selected_date <= max_start_date, + PeriodicitySchema.selected_date >= min_end_date, + ), + ) + ) + + db_result = await self._execute(query) + + events_map: dict[uuid.UUID, list[EventFull]] = dict() + event_ids: set[uuid.UUID] = set() + for row in db_result: + event_ids.add(row.EventSchema.id) + events_map.setdefault(row.EventSchema.applet_id, list()) + events_map[row.EventSchema.applet_id].append( + EventFull( + id=row.EventSchema.id, + start_time=row.EventSchema.start_time, + end_time=row.EventSchema.end_time, + access_before_schedule=row.EventSchema.access_before_schedule, # noqa: E501 + one_time_completion=row.EventSchema.one_time_completion, + timer=row.EventSchema.timer, + timer_type=row.EventSchema.timer_type, + user_id=user_id, + periodicity=Periodicity( + id=row.EventSchema.periodicity_id, + type=row.type, + start_date=row.start_date, + end_date=row.end_date, + selected_date=row.selected_date, + ), + activity_id=row.activity_id, + flow_id=row.flow_id, + ) + ) + + return events_map, event_ids + async def delete_by_ids(self, ids: list[uuid.UUID]) -> None: """Delete event by event ids.""" query: Query = delete(EventSchema) @@ -258,7 +358,8 @@ async def get_all_by_applet_and_activity( ) query = query.where(EventSchema.applet_id == applet_id) query = query.where(EventSchema.is_deleted == False) # noqa: E712 - query = query.where(UserEventsSchema.user_id == respondent_id) + if respondent_id: + query = query.where(UserEventsSchema.user_id == respondent_id) result = await self._execute(query) return result.scalars().all() @@ -299,7 +400,8 @@ async def get_all_by_applet_and_flow( query = query.where(EventSchema.applet_id == applet_id) query = query.where(EventSchema.is_deleted == False) # noqa: E712 - query = query.where(UserEventsSchema.user_id == respondent_id) + if respondent_id: + query = query.where(UserEventsSchema.user_id == respondent_id) result = await self._execute(query) return result.scalars().all() @@ -411,6 +513,144 @@ async def get_general_events_by_user( ) return events + async def get_general_events_by_applets_and_user( + self, + applet_ids: list[uuid.UUID], + user_id: uuid.UUID, + min_end_date: date | None = None, + max_start_date: date | None = None, + ) -> tuple[dict[uuid.UUID, list[EventFull]], set[uuid.UUID]]: + """Get general events by applet_id and user_id""" + # select flow_ids to exclude + flow_ids = ( + select(distinct(FlowEventsSchema.flow_id)) + .select_from(FlowEventsSchema) + .join( + UserEventsSchema, + UserEventsSchema.event_id == FlowEventsSchema.event_id, + ) + .join( + EventSchema, + EventSchema.id == FlowEventsSchema.event_id, + ) + .where(UserEventsSchema.user_id == user_id) + .where(EventSchema.applet_id.in_(applet_ids)) + ) + activity_ids = ( + select(distinct(ActivityEventsSchema.activity_id)) + .select_from(ActivityEventsSchema) + .join( + UserEventsSchema, + UserEventsSchema.event_id == ActivityEventsSchema.event_id, + ) + .join( + EventSchema, + EventSchema.id == ActivityEventsSchema.event_id, + ) + .where(UserEventsSchema.user_id == user_id) + .where(EventSchema.applet_id.in_(applet_ids)) + ) + + query: Query = select( + EventSchema, + PeriodicitySchema.start_date, + PeriodicitySchema.end_date, + PeriodicitySchema.selected_date, + PeriodicitySchema.type, + ActivityEventsSchema.activity_id, + FlowEventsSchema.flow_id, + ) + + query = query.join( + PeriodicitySchema, + PeriodicitySchema.id == EventSchema.periodicity_id, + ) + + query = query.join( + FlowEventsSchema, + FlowEventsSchema.event_id == EventSchema.id, + isouter=True, + ) + query = query.join( + ActivityEventsSchema, + ActivityEventsSchema.event_id == EventSchema.id, + isouter=True, + ) + query = query.join( + UserEventsSchema, + UserEventsSchema.event_id == EventSchema.id, + isouter=True, + ) + + query = query.where(EventSchema.applet_id.in_(applet_ids)) + query = query.where(EventSchema.is_deleted == False) # noqa: E712 + query = query.where( + or_( + FlowEventsSchema.flow_id.is_(None), + FlowEventsSchema.flow_id.not_in(flow_ids), + ) + ) + query = query.where( + or_( + ActivityEventsSchema.activity_id.is_(None), + ActivityEventsSchema.activity_id.not_in(activity_ids), + ) + ) + query = query.where(UserEventsSchema.user_id == None) # noqa: E711 + if min_end_date and max_start_date: + query = query.where( + or_( + PeriodicitySchema.type == PeriodicityType.ALWAYS, + and_( + PeriodicitySchema.type != PeriodicityType.ONCE, + or_( + PeriodicitySchema.start_date.is_(None), + PeriodicitySchema.start_date <= max_start_date, + ), + or_( + PeriodicitySchema.end_date.is_(None), + PeriodicitySchema.end_date >= min_end_date, + ), + ), + and_( + PeriodicitySchema.type == PeriodicityType.ONCE, + PeriodicitySchema.selected_date <= max_start_date, + PeriodicitySchema.selected_date >= min_end_date, + ), + ) + ) + + db_result = await self._execute(query) + + events_map: dict[uuid.UUID, list[EventFull]] = dict() + event_ids: set[uuid.UUID] = set() + for row in db_result: + event_ids.add(row.EventSchema.id) + events_map.setdefault(row.EventSchema.applet_id, list()) + events_map[row.EventSchema.applet_id].append( + EventFull( + id=row.EventSchema.id, + start_time=row.EventSchema.start_time, + end_time=row.EventSchema.end_time, + access_before_schedule=row.EventSchema.access_before_schedule, # noqa: E501 + one_time_completion=row.EventSchema.one_time_completion, + timer=row.EventSchema.timer, + timer_type=row.EventSchema.timer_type, + user_id=user_id, + periodicity=Periodicity( + id=row.EventSchema.periodicity_id, + type=row.type, + start_date=row.start_date, + end_date=row.end_date, + selected_date=row.selected_date, + ), + activity_id=row.activity_id, + flow_id=row.flow_id, + ) + ) + + return events_map, event_ids + async def count_general_events_by_user( self, applet_id: uuid.UUID, user_id: uuid.UUID ) -> int: @@ -502,6 +742,13 @@ async def count_individual_events_by_user( db_result = await self._execute(query) return db_result.scalar() + async def get_all(self, applet_id: uuid.UUID) -> list[EventSchema]: + query: Query = select(EventSchema) + query = query.where(EventSchema.applet_id == applet_id) + query = query.where(EventSchema.is_deleted.is_(False)) + result = await self._execute(query) + return result.scalars().all() + class UserEventsCRUD(BaseCRUD[UserEventsSchema]): schema_class = UserEventsSchema @@ -708,6 +955,22 @@ async def get_by_applet_and_user_id( for activity_event in activity_events ] + async def get_missing_events( + self, activity_ids: list[uuid.UUID] + ) -> list[uuid.UUID]: + query: Query = select(ActivityEventsSchema.activity_id) + query.join( + ActivitySchema, + and_( + ActivitySchema.id == ActivityEventsSchema.activity_id, + ActivitySchema.is_reviewable.is_(False), + ), + ) + query.where(ActivityEventsSchema.activity_id.in_(activity_ids)) + res = await self._execute(query) + db_result = res.scalars().all() + return list(set(activity_ids) - set(db_result)) + class FlowEventsCRUD(BaseCRUD[FlowEventsSchema]): schema_class = FlowEventsSchema diff --git a/src/apps/schedule/crud/notification.py b/src/apps/schedule/crud/notification.py index 576073a0585..a669480acb3 100644 --- a/src/apps/schedule/crud/notification.py +++ b/src/apps/schedule/crud/notification.py @@ -46,6 +46,26 @@ async def get_all_by_event_id( for notification in result ] + async def get_all_by_event_ids( + self, event_ids: set[uuid.UUID] + ) -> dict[uuid.UUID, list[NotificationSetting]]: + """Return all notifications in map by event ids.""" + + query: Query = select(NotificationSchema) + query = query.where(NotificationSchema.event_id.in_(event_ids)) + query = query.order_by(NotificationSchema.order.asc()) + db_result = await self._execute(query) + result = db_result.scalars().all() + + notifications_map: dict[uuid.UUID, list[NotificationSetting]] = dict() + for notification in result: + notifications_map.setdefault(notification.event_id, list()) + notifications_map[notification.event_id].append( + NotificationSetting.from_orm(notification) + ) + + return notifications_map + async def delete_by_event_ids(self, event_ids: list[uuid.UUID]): """Delete all notifications by event id.""" query: Query = delete(NotificationSchema) @@ -72,6 +92,24 @@ async def get_by_event_id(self, event_id: uuid.UUID) -> ReminderSchema: return db_result.scalars().first() + async def get_by_event_ids( + self, event_ids: set[uuid.UUID] + ) -> dict[uuid.UUID, ReminderSchema]: + """Return all reminders in map by event ids.""" + + query: Query = select(ReminderSchema) + query = query.where(ReminderSchema.event_id.in_(event_ids)) + query = query.order_by(ReminderSchema.id.asc()) + db_result = await self._execute(query) + + result = db_result.scalars().all() + reminders_map: dict[uuid.UUID, ReminderSchema] = dict() + for reminder in result: + if reminder.event_id not in reminders_map: + reminders_map[reminder.event_id] = reminder + + return reminders_map + async def delete_by_event_ids(self, event_ids: list[uuid.UUID]): """Delete all reminders by event id.""" query: Query = delete(ReminderSchema) diff --git a/src/apps/schedule/domain/schedule/requests.py b/src/apps/schedule/domain/schedule/requests.py index 799e8726aa0..dacd20deae7 100644 --- a/src/apps/schedule/domain/schedule/requests.py +++ b/src/apps/schedule/domain/schedule/requests.py @@ -16,6 +16,7 @@ ActivityOrFlowRequiredError, OneTimeCompletionCaseError, StartEndTimeAccessBeforeScheduleCaseError, + StartEndTimeEqualError, UnavailableActivityOrFlowError, ) from apps.shared.domain import InternalModel, PublicModel @@ -85,10 +86,10 @@ def validate_optional_fields(cls, values): if ( notification.trigger_type == NotificationTriggerType.FIXED - and not ( - values.get("start_time") - <= notification.at_time - <= values.get("end_time") # noqa: E501 + and ( + values.get("start_time") is None + or values.get("end_time") is None + or notification.at_time is None # noqa: E501 ) ): raise UnavailableActivityOrFlowError() @@ -96,21 +97,26 @@ def validate_optional_fields(cls, values): if ( notification.trigger_type == NotificationTriggerType.RANDOM - and not ( - values.get("start_time") - <= notification.from_time - <= notification.to_time - <= values.get("end_time") # noqa: E501 + and ( + values.get("start_time") is None + or values.get("end_time") is None + or notification.from_time is None + or notification.to_time is None # noqa: E501 ) ): raise UnavailableActivityOrFlowError() if values.get("notification").reminder: - if not ( - values.get("start_time") - <= values.get("notification").reminder.reminder_time - <= values.get("end_time") + if ( + values.get("start_time") is None + or values.get("end_time") is None + or values.get("notification").reminder.reminder_time + is None ): raise UnavailableActivityOrFlowError() + + if values.get("start_time") == values.get("end_time"): + raise StartEndTimeEqualError() + return values diff --git a/src/apps/schedule/errors.py b/src/apps/schedule/errors.py index 058c328f19b..35aba85d5c0 100644 --- a/src/apps/schedule/errors.py +++ b/src/apps/schedule/errors.py @@ -10,14 +10,17 @@ class EventNotFoundError(NotFoundError): + message_is_template: bool = True message = _("No such event with {key}={value}.") class PeriodicityNotFoundError(NotFoundError): + message_is_template: bool = True message = _("No such periodicity with {key}={value}.") class AppletScheduleNotFoundError(NotFoundError): + message_is_template: bool = True message = _("No schedules found for applet {applet_id}") @@ -42,16 +45,19 @@ class EventError(InternalServerError): class UserEventAlreadyExists(ValidationError): + message_is_template: bool = True message = _("The event {event_id} for user {user_id} already exists.") class ActivityEventAlreadyExists(ValidationError): + message_is_template: bool = True message = _( "The event {event_id} for activity {activity_id} already exists." ) class FlowEventAlreadyExists(ValidationError): + message_is_template: bool = True message = _("The event {event_id} for flow {flow_id} already exists.") @@ -82,5 +88,9 @@ class StartEndTimeAccessBeforeScheduleCaseError(FieldError): ) +class StartEndTimeEqualError(FieldError): + message = _("The start_time and end_time fields can't be equal.") + + class UnavailableActivityOrFlowError(FieldError): message = _("Activity/flow is unavailable at this time.") diff --git a/src/apps/schedule/router.py b/src/apps/schedule/router.py index 7b40b46fc92..d539a5f01fb 100644 --- a/src/apps/schedule/router.py +++ b/src/apps/schedule/router.py @@ -10,6 +10,7 @@ schedule_delete_by_id, schedule_delete_by_user, schedule_get_all, + schedule_get_all_by_respondent_user, schedule_get_all_by_user, schedule_get_by_id, schedule_get_by_user, @@ -207,3 +208,15 @@ **NO_CONTENT_ERROR_RESPONSES, }, )(schedule_get_by_user) + +user_router.get( + "/me/respondent/current_events", + response_model=ResponseMulti[PublicEventByUser], + status_code=status.HTTP_200_OK, + responses={ + status.HTTP_200_OK: {"model": ResponseMulti[PublicEventByUser]}, + **AUTHENTICATION_ERROR_RESPONSES, + **DEFAULT_OPENAPI_RESPONSE, + **NO_CONTENT_ERROR_RESPONSES, + }, +)(schedule_get_all_by_respondent_user) diff --git a/src/apps/schedule/service/schedule.py b/src/apps/schedule/service/schedule.py index 5d040424aad..7a5a1542a30 100644 --- a/src/apps/schedule/service/schedule.py +++ b/src/apps/schedule/service/schedule.py @@ -1,4 +1,6 @@ +import asyncio import uuid +from datetime import date from apps.activities.crud import ActivitiesCRUD from apps.activity_flows.crud import FlowsCRUD @@ -336,7 +338,7 @@ async def delete_all_schedules(self, applet_id: uuid.UUID): event_schemas: list[EventSchema] = await EventCRUD( self.session - ).get_all_by_applet_id_with_filter(applet_id, None) + ).get_all(applet_id) event_ids = [event_schema.id for event_schema in event_schemas] periodicity_ids = [ event_schema.periodicity_id for event_schema in event_schemas @@ -816,6 +818,73 @@ async def get_events_by_user( return events + async def get_upcoming_events_by_user( + self, + user_id: uuid.UUID, + applet_ids: list[uuid.UUID], + min_end_date: date | None = None, + max_start_date: date | None = None, + ) -> list[PublicEventByUser]: + """Get all events for user in applets that user is respondent.""" + user_events_map, user_event_ids = await EventCRUD( + self.session + ).get_all_by_applets_and_user( + applet_ids=applet_ids, + user_id=user_id, + min_end_date=min_end_date, + max_start_date=max_start_date, + ) + general_events_map, general_event_ids = await EventCRUD( + self.session + ).get_general_events_by_applets_and_user( + applet_ids=applet_ids, + user_id=user_id, + min_end_date=min_end_date, + max_start_date=max_start_date, + ) + full_events_map = self._sum_applets_events_map( + user_events_map, general_events_map + ) + + event_ids = user_event_ids | general_event_ids + notifications_map_c = NotificationCRUD( + self.session + ).get_all_by_event_ids(event_ids) + reminders_map_c = ReminderCRUD(self.session).get_by_event_ids( + event_ids + ) + notifications_map, reminders_map = await asyncio.gather( + notifications_map_c, reminders_map_c + ) + + events: list[PublicEventByUser] = [] + for applet_id, all_events in full_events_map.items(): + events.append( + PublicEventByUser( + applet_id=applet_id, + events=[ + self._convert_to_dto( + event=event, + notifications=notifications_map.get(event.id), + reminder=reminders_map.get(event.id), + ) + for event in all_events + ], + ) + ) + + return events + + @staticmethod + def _sum_applets_events_map(m1: dict, m2: dict): + result = dict() + for k, v in m1.items(): + result[k] = v + for k, v in m2.items(): + result.setdefault(k, list()) + result[k] += v + return result + def _convert_to_dto( self, event: EventFull, @@ -1102,6 +1171,16 @@ async def import_schedule( """Import schedule.""" events = [] for schedule in schedules: + if schedule.periodicity.type == PeriodicityType.ALWAYS: + # delete alwaysAvailable events of this activity or flow, + # if new event type is AA + await self._delete_by_activity_or_flow( + applet_id=applet_id, + activity_id=schedule.activity_id, + flow_id=schedule.flow_id, + respondent_id=schedule.respondent_id, + only_always_available=True, + ) event = await self.create_schedule( applet_id=applet_id, schedule=schedule ) @@ -1116,7 +1195,7 @@ async def create_schedule_individual( # get list of activity ids activity_ids = [] activities = await ActivitiesCRUD(self.session).get_by_applet_id( - applet_id + applet_id, is_reviewable=False ) activity_ids = [ activity.id for activity in activities if not activity.is_hidden @@ -1147,3 +1226,18 @@ async def create_schedule_individual( applet_id, QueryParams(filters={"respondent_id": respondent_id}), ) + + async def create_default_schedules_if_not_exist( + self, + applet_id: uuid.UUID, + activity_ids: list[uuid.UUID], + ) -> None: + """Create default schedules for applet.""" + activities_without_events = await ActivityEventsCRUD( + self.session + ).get_missing_events(activity_ids) + await self.create_default_schedules( + applet_id=applet_id, + activity_ids=activities_without_events, + is_activity=True, + ) diff --git a/src/apps/schedule/tests/test_schedule.py b/src/apps/schedule/tests/test_schedule.py index 121ce519f92..284ded8bff4 100644 --- a/src/apps/schedule/tests/test_schedule.py +++ b/src/apps/schedule/tests/test_schedule.py @@ -27,6 +27,10 @@ class TestSchedule(BaseTest): schedule_user_url = "users/me/events" schedule_detail_user_url = f"{schedule_user_url}/{{applet_id}}" + erspondent_schedules_user_two_weeks_url = ( + "/users/me/respondent/current_events" + ) + schedule_url = f"{applet_detail_url}/events" schedule_import_url = f"{applet_detail_url}/events/import" schedule_create_individual = ( @@ -46,6 +50,46 @@ class TestSchedule(BaseTest): public_events_url = "public/applets/{key}/events" + @rollback + async def test_schedule_create_with_equal_start_end_time(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + create_data = { + "start_time": "08:00:00", + "end_time": "08:00:00", + "access_before_schedule": False, + "one_time_completion": False, + "timer": "00:00:00", + "timer_type": "NOT_SET", + "periodicity": { + "type": "ONCE", + "start_date": "2021-09-01", + "end_date": "2021-09-01", + "selected_date": "2023-09-01", + }, + "respondent_id": None, + "activity_id": "09e3dbf0-aefb-4d0e-9177-bdb321bf3611", + "flow_id": None, + "notification": { + "notifications": [ + {"trigger_type": "FIXED", "at_time": "08:30:00"}, + ], + "reminder": { + "activity_incomplete": 1, + "reminder_time": "08:30:00", + }, + }, + } + + response = await self.client.post( + self.schedule_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ), + data=create_data, + ) + assert response.status_code == 422 + @rollback async def test_schedule_create_with_activity(self): await self.client.login( @@ -365,6 +409,66 @@ async def test_schedule_delete_detail(self): assert response.status_code == 204 + @rollback + async def test_schedule_update_with_equal_start_end_time(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + create_data = { + "start_time": "08:00:00", + "end_time": "09:00:00", + "access_before_schedule": True, + "one_time_completion": True, + "timer": "00:00:00", + "timer_type": "NOT_SET", + "periodicity": { + "type": "MONTHLY", + "start_date": "2021-09-01", + "end_date": "2021-09-01", + "selected_date": "2023-09-01", + }, + "respondent_id": "7484f34a-3acc-4ee6-8a94-fd7299502fa2", + "activity_id": None, + "flow_id": "3013dfb1-9202-4577-80f2-ba7450fb5831", + "notification": { + "notifications": [ + {"trigger_type": "FIXED", "at_time": "08:30:00"}, + ], + "reminder": { + "activity_incomplete": 1, + "reminder_time": "08:30:00", + }, + }, + } + + response = await self.client.post( + self.schedule_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ), + data=create_data, + ) + event = response.json()["result"] + + update_data = { + "start_time": "00:00:15", + "end_time": "00:00:15", + "periodicity": { + "type": "MONTHLY", + "start_date": "2021-09-01", + "end_date": "2021-09-01", + "selected_date": "2023-09-01", + }, + } + + response = await self.client.put( + self.schedule_detail_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1", + event_id=event["id"], + ), + data=update_data, + ) + assert response.status_code == 422 + @rollback async def test_schedule_update(self): await self.client.login( @@ -581,6 +685,80 @@ async def test_schedules_get_user_all(self): assert response.status_code == 200 assert response.json()["count"] == 6 + @rollback + async def test_respondent_schedules_get_user_two_weeks(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + + response = await self.client.get( + self.erspondent_schedules_user_two_weeks_url + ) + + assert response.status_code == 200 + assert response.json()["count"] == 2 + + data = sorted(response.json()["result"], key=lambda x: x["appletId"]) + apppet_0 = data[0] + apppet_1 = data[1] + assert set(apppet_0.keys()) == { + "appletId", + "events", + } + + apppet_0["appletId"] = "92917a56-d586-4613-b7aa-991f2c4b15b1" + assert len(apppet_0["events"]) == 3 + events_data = sorted(apppet_0["events"], key=lambda x: x["id"]) + assert set(events_data[0].keys()) == { + "id", + "entityId", + "availability", + "selectedDate", + "timers", + "availabilityType", + "notificationSettings", + } + assert set(events_data[0]["availability"].keys()) == { + "oneTimeCompletion", + "periodicityType", + "timeFrom", + "timeTo", + "allowAccessBeforeFromTime", + "startDate", + "endDate", + } + events_data[0]["id"] = "04c93c4a-2cd4-45ce-9aec-b1912f330584" + events_data[0]["entityId"] = "09e3dbf0-aefb-4d0e-9177-bdb321bf3612" + events_data[1]["id"] = "04c93c4a-2cd4-45ce-9aec-b1912f330583" + events_data[1]["entityId"] = "09e3dbf0-aefb-4d0e-9177-bdb321bf3611" + events_data[2]["id"] = "04c93c4a-2cd4-45ce-9aec-b1912f330582" + events_data[2]["entityId"] = "3013dfb1-9202-4577-80f2-ba7450fb5832" + + apppet_1["appletId"] = "92917a56-d586-4613-b7aa-991f2c4b15b2" + assert len(apppet_1["events"]) == 1 + # events_data = sorted(apppet_1["events"], key=lambda x: x["id"]) + events_data = apppet_1["events"] + assert set(events_data[0].keys()) == { + "id", + "entityId", + "availability", + "selectedDate", + "timers", + "availabilityType", + "notificationSettings", + } + assert set(events_data[0]["availability"].keys()) == { + "oneTimeCompletion", + "periodicityType", + "timeFrom", + "timeTo", + "allowAccessBeforeFromTime", + "startDate", + "endDate", + } + events_data[0]["id"] = "04c93c4a-2cd4-45ce-9aec-b1912f330584" + events_data[0]["entityId"] = "09e3dbf0-aefb-4d0e-9177-bdb321bf3612" + @rollback async def test_schedule_get_user_by_applet(self): await self.client.login( diff --git a/src/apps/shared/changes_generator.py b/src/apps/shared/changes_generator.py index 12929e44262..bc0b70956df 100644 --- a/src/apps/shared/changes_generator.py +++ b/src/apps/shared/changes_generator.py @@ -1,29 +1,24 @@ -from apps.activities.domain.activity_item_history import ( - ActivityItemHistoryChange, -) -from apps.shared.domain.base import to_camelcase - -__all__ = ["ChangeTextGenerator", "ChangeGenerator"] - """ Dictionary to generate needed text in one format """ _DICTIONARY = dict( en=dict( - added='"{0}" is added.', - removed='"{0}" is removed.', - changed='"{0}" is changed to "{1}".', - cleared='"{0}" is cleared.', - filled='"{0}" is updated to "{1}".', - updated='"{0}" is updated.', - changed_dict='For {0} language "{1}" is changed to "{2}".', - set_to='"{0}" is set to "{1}".', - set_dict='For {0} language "{1}" is set to "{2}".', - set_bool='"{0}" option was "{1}".', + added="{0} was added", + removed="{0} was removed", + changed="{0} was changed to {1}", + cleared="{0} was cleared", + filled="{0} was changed to {1}", + updated="{0} was updated", + changed_dict="For {0} language {1} was changed to {2}", + set_to="{0} was set to {1}", + set_dict="For {0} language {1} was set to {2}", + set_bool="{0} option was {1}", + bool_enabled="{0} was enabled", + bool_disabled="{0} was disabled", ) ) -EMPY_VALUES: tuple = (None, "", 0, dict()) +EMPTY_VALUES: tuple = (None, "", 0, dict(), dict(en=""), []) class ChangeTextGenerator: @@ -36,7 +31,7 @@ def __init__( @classmethod def is_considered_empty(cls, value) -> bool: - return value in EMPY_VALUES + return value in EMPTY_VALUES def added_text(self, object_name: str) -> str: """ @@ -50,26 +45,25 @@ def removed_text(self, object_name: str) -> str: """ return self._dictionary["removed"].format(object_name) - def changed_text(self, from_, to_) -> str: - """ - Generates text for value updating. - """ - return self._dictionary["changed"].format(str(from_), str(to_)) - - def changed_dict(self, from_, to_) -> str: - """ - Generates text of dicts for value updating. - """ - changes = "" - - # get all keys from both dicts, in set - keys = set(from_.keys()) | set(to_.keys()) - for key in keys: - changes += self._dictionary["changed_dict"].format( - key, from_.get(key, None), to_.get(key, None) - ) - - return changes + def changed_text( + self, + field: str, + value: str | dict[str, str] | list[str], + is_initial=False, + ) -> str: + """ + Generates text for value chaning or setting if it is initial value. + """ + # We don't support translations yet + if isinstance(value, dict): + v = list(value.values())[0] + elif isinstance(value, list): + v = ", ".join(value) + else: + v = value + if is_initial: + return self._dictionary["set_to"].format(field, v) + return self._dictionary["filled"].format(field, v) def cleared_text(self, field: str) -> str: """ @@ -77,12 +71,6 @@ def cleared_text(self, field: str) -> str: """ return self._dictionary["cleared"].format(field) - def filled_text(self, field: str, value: str) -> str: - """ - Generates text for setting value. - """ - return self._dictionary["filled"].format(field, value) - def updated_text(self, field: str) -> str: """ Generates text for setting value. @@ -95,599 +83,24 @@ def set_text(self, field: str, value: str) -> str: """ return self._dictionary["set_to"].format(field, value) - def set_dict(self, field, value) -> str: - """ - Generates text for setting value. - """ - changes = "" - - # get all keys from both dicts, in set - keys = set(value.keys()) - for key in keys: - changes += self._dictionary["set_dict"].format( - key, field, value.get(key, None) - ) - - return changes - - def set_bool(self, field: str, value: str) -> str: + def set_bool(self, field_name: str, value: bool) -> str: """ Generates text for setting value. """ - return self._dictionary["set_bool"].format(field, value) + if value: + return self._dictionary["bool_enabled"].format(field_name) + return self._dictionary["bool_disabled"].format(field_name) -class ChangeGenerator: +class BaseChangeGenerator: def __init__(self): self._change_text_generator = ChangeTextGenerator() - def generate_applet_changes(self, new_applet, old_applet): - changes = [] - for field, old_value in old_applet.dict().items(): - new_value = getattr(new_applet, field, None) - if not any([old_value, new_value]): - continue - if new_value == old_value: - continue - if self._change_text_generator.is_considered_empty(new_value): - changes.append( - self._change_text_generator.cleared_text( - to_camelcase(field) - ), - ) - elif self._change_text_generator.is_considered_empty(old_value): - changes.append( - self._change_text_generator.filled_text( - to_camelcase(field), new_value - ), - ) - else: - changes.append( - self._change_text_generator.changed_text( - f"Applet {field}", new_value - ) - if field not in ["about", "description"] - else f"Applet {to_camelcase(field)} updated: {self._change_text_generator.changed_dict(old_value, new_value)}." # noqa: E501 - ) - - return changes - - def generate_activity_insert(self, new_activity): - changes = list() - for field, value in new_activity.dict().items(): - if field == "items": - continue - elif field == "name": - changes.append( - self._change_text_generator.set_text( - f"Activity {to_camelcase(field)}", value - ) - ) - elif field in [ - "id", - "created_at", - "id_version", - "applet_id", - ]: - continue - elif field in [ - "scores_and_reports", - "subscale_setting", - ]: - if field == "scores_and_reports": - if value: - for key, val in value.items(): - if key in [ - "generate_report", - "show_score_summary", - ]: - changes.append( - self._change_text_generator.set_bool( - f"Activity {to_camelcase(key)}", - "enabled" if val else "disabled", - ) - ) - elif key == "reports": - for rep in val: - text = "" - if rep["type"] == "score": - text = f"Activity score {rep['name']}" - elif rep["type"] == "section": - text = ( - f"Activity section {rep['name']}" - ) - if text == "": - continue - self._change_text_generator.added_text( - text - ) - - elif field == "subscale_setting": - if value: - for key, val in value.items(): - if key == "subscales": - for v in val: - changes.append( - self._change_text_generator.added_text( - f'Activity subscale {v["name"]}' - ) - ) - elif key == "total_scores_table_data": - changes.append( - self._change_text_generator.added_text( - f"Activity subscale {to_camelcase(key)}" # noqa: E501 - ) - ) - - elif key == "calculate_total_score": - changes.append( - self._change_text_generator.set_text( - f"Activity subscale {to_camelcase(key)}", # noqa: E501 - val, - ) - ) - - elif type(value) == bool: - changes.append( - self._change_text_generator.set_bool( - f"Activity {to_camelcase(field)}", - "enabled" if value else "disabled", - ), - ) - else: - if value: - changes.append( - self._change_text_generator.set_text( - f"Activity {to_camelcase(field)}", value - ) - if field not in ["description"] - else self._change_text_generator.set_dict( - f"Activity {to_camelcase(field)}", value - ), - ) - return changes - - def generate_activity_update(self, new_activity, old_activity): - changes = list() - - for field, value in new_activity.dict().items(): - old_value = getattr(old_activity, field, None) - if field == "items": - continue - elif field in [ - "id", - "created_at", - "id_version", - "applet_id", - ]: - continue - elif field in [ - "scores_and_reports", - "subscale_setting", - ]: - if field == "scores_and_reports": - if value and value != old_value: - for key, val in value.items(): - old_val = getattr(old_activity, key, None) - if key in [ - "generate_report", - "show_score_summary", - ]: - changes.append( - self._change_text_generator.set_bool( - f"Activity {to_camelcase(key)}", - "enabled" if val else "disabled", - ) - ) - elif key == "scores": - if val: - old_names = [] - if old_val: - old_names = [ - old_v.name for old_v in old_val - ] - new_names = [v["name"] for v in val] - deleted_names = list( - set(old_names) - set(new_names) - ) - for k, v in enumerate(val): - if v["name"] not in old_names: - changes.append( - self._change_text_generator.added_text( # noqa: E501 - f'Activity score {v["name"]}' # noqa: E501 - ) - ) - else: - if ( - getattr( - old_val, k, None - ).dict() - != v.dict() - ): - changes.append( - self._change_text_generator.changed_text( # noqa: E501 - f'Activity score {v["name"]}' # noqa: E501 - ) - ) - - if deleted_names: - changes.append( - self._change_text_generator.removed_text( # noqa: E501 - f'Activity scores {", ".join(deleted_names)}' # noqa: E501 - ) - ) - else: - if old_val: - changes.append( - self._change_text_generator.removed_text( # noqa: E501 - "Activity scores" - ) - ) - - elif key == "sections": - if val: - old_names = [] - if old_val: - old_names = [ - old_v.name for old_v in old_val - ] - new_names = [v["name"] for v in val] - deleted_names = list( - set(old_names) - set(new_names) - ) - - for k, v in enumerate(val): - if v["name"] not in old_names: - changes.append( - self._change_text_generator.added_text( # noqa: E501 - f'Activity section {v["name"]}' # noqa: E501 - ) - ) - else: - if ( - getattr( - old_val, k, None - ).dict() - != v.dict() - ): - changes.append( - self._change_text_generator.changed_text( # noqa: E501 - f'Activity section {v["name"]}' # noqa: E501 - ) - ) - - if deleted_names: - changes.append( - self._change_text_generator.removed_text( # noqa: E501 - f'Activity section {", ".join(deleted_names)}' # noqa: E501 - ) - ) - else: - if old_val: - changes.append( - self._change_text_generator.removed_text( # noqa: E501 - "Activity sections" - ) - ) - else: - if old_value: - changes.append( - self._change_text_generator.removed_text( - f"Activity {to_camelcase(field)}" - ) - ) - elif field == "subscale_setting": - if value and value != old_value: - for key, val in value.items(): - old_val = getattr(old_activity, key, None) - - if key == "subscales": - if val: - old_names = [] - if old_val: - old_names = [ - old_v.name for old_v in old_val - ] - new_names = [v["name"] for v in val] - deleted_names = list( - set(old_names) - set(new_names) - ) - for k, v in enumerate(val): - if v["name"] not in old_names: - changes.append( - self._change_text_generator.added_text( # noqa: E501 - f'Activity subscale {v["name"]}' # noqa: E501 - ) - ) - else: - if ( - getattr( - old_val, k, None - ).dict() - != v.dict() - ): - changes.append( - self._change_text_generator.changed_text( # noqa: E501 - f'Activity subscale {v["name"]}' # noqa: E501 - ) - ) - - if deleted_names: - changes.append( - self._change_text_generator.removed_text( # noqa: E501 - f'Activity subscale {", ".join(deleted_names)}' # noqa: E501 - ) - ) - else: - if old_val: - changes.append( - self._change_text_generator.removed_text( # noqa: E501 - "Activity subscales" - ) - ) - else: - if val != old_val: - if val and not old_val: - changes.append( - self._change_text_generator.set_text( # noqa: E501 - f"Activity subscale {to_camelcase(key)}", # noqa: E501 - val, - ) - ) - elif not val and old_val: - changes.append( - self._change_text_generator.removed_text( # noqa: E501 - f"Activity subscale {to_camelcase(key)}" # noqa: E501 - ) - ) - else: - changes.append( - self._change_text_generator.changed_text( # noqa: E501 - f"Activity subscale {to_camelcase(key)}", # noqa: E501 - val, - ) - ) - else: - if old_value: - changes.append( - self._change_text_generator.removed_text( - f"Activity {to_camelcase(field)}" - ) - ) - - elif type(value) == bool: - if value and value != old_value: - changes.append( - self._change_text_generator.set_bool( - f"Activity {to_camelcase(field)}", - "enabled" if value else "disabled", - ), - ) - else: - if value != old_value: - if field == "description": - desc_change = f"Activity {to_camelcase(field)} updated: {self._change_text_generator.changed_dict(old_value, value)}." # noqa: E501 - changes.append(desc_change) - - else: - changes.append( - self._change_text_generator.changed_text( - f"Activity {to_camelcase(field)}", value - ) - ) - return changes, bool(changes) - - def generate_activity_items_insert(self, items): - change_items = [] - for item in items: - change = ActivityItemHistoryChange( - name=self._change_text_generator.added_text( - f"Item {item.name}" - ) - ) - changes = [] - for field, value in item.dict().items(): - if field == "name": - changes.append( - self._change_text_generator.set_text( - f"Item {to_camelcase(field)}", value - ) - ) - elif field in [ - "id", - "created_at", - "id_version", - "activity_id", - ]: - continue - elif type(value) == bool: - changes.append( - self._change_text_generator.set_bool( - f"Item {to_camelcase(field)}", - "enabled" if value else "disabled", - ), - ) - - elif field in [ - "response_values", - "config", - "conditional_logic", - ]: - if field == "response_values": - if value: - changes.append( - self._change_text_generator.added_text( - f"Item {field}" - ) - ) - elif field == "config": - if value: - for key, val in value.items(): - if type(val) == bool: - changes.append( - self._change_text_generator.set_bool( - f"Item {to_camelcase(key)}", - "enabled" if val else "disabled", - ) - ) - - elif type(val) == dict: - for k, v in val.items(): - if type(v) == bool: - changes.append( - self._change_text_generator.set_bool( # noqa: E501 - f"Item {to_camelcase(k)}", - "enabled" - if v - else "disabled", - ) - ) - else: - changes.append( - self._change_text_generator.added_text( # noqa: E501 - f"Item {to_camelcase(k)}", - ) - ) - else: - changes.append( - self._change_text_generator.added_text( - f"Item {to_camelcase(key)}" - ) - ) - - else: - if value: - changes.append( - self._change_text_generator.set_text( - f"Item {to_camelcase(field)}", value - ) - if field not in ["question"] - else self._change_text_generator.set_dict( - f"Item {to_camelcase(field)}", value - ), - ) - - change.changes = changes - change_items.append(change) - - return change_items - - def generate_activity_items_update(self, item_groups): - change_items = [] - - for _, (prev_item, new_item) in item_groups.items(): - if not prev_item and new_item: - change_items.extend( - self.generate_activity_items_insert( - [ - new_item, - ] - ) - ) - elif not new_item and prev_item: - change_items.append( - ActivityItemHistoryChange( - name=self._change_text_generator.removed_text( - f"Item {prev_item.name}" - ) - ) - ) - elif new_item and prev_item: - changes, has_changes = self._generate_activity_item_update( - new_item, prev_item - ) - if has_changes: - change_items.append( - ActivityItemHistoryChange( - name=self._change_text_generator.updated_text( - f"Item {new_item.name}", - ), - changes=changes, - ) - ) - - return change_items, bool(change_items) - - def _generate_activity_item_update(self, new_item, prev_item): - changes = list() - - for field, value in new_item.dict().items(): - old_value = getattr(prev_item, field, None) - if field in [ - "id", - "created_at", - "id_version", - "activity_id", - ]: - continue - elif type(value) == bool: - if value and value != old_value: - changes.append( - self._change_text_generator.set_bool( - f"Item {to_camelcase(field)}", - "enabled" if value else "disabled", - ), - ) - - elif field in [ - "response_values", - "config", - "conditional_logic", - ]: - if field == "response_values": - if value and value != old_value: - changes.append( - self._change_text_generator.added_text( - f"Item {field}" - ) - ) - elif field == "config": - if value and value != old_value: - for key, val in value.items(): - old_val = getattr(old_value, key, None) - if val != old_val: - if type(val) == bool: - changes.append( - self._change_text_generator.set_bool( - f"Item {to_camelcase(key)}", - "enabled" if val else "disabled", - ) - ) - - elif type(val) == dict: - for k, v in val.items(): - old_v = getattr(old_val, k, None) - if v != old_v: - if type(v) == bool: - changes.append( - self._change_text_generator.set_bool( # noqa: E501 - f"Item {to_camelcase(k)}", # noqa: E501 - "enabled" - if v - else "disabled", - ) - ) - else: - changes.append( - self._change_text_generator.added_text( # noqa: E501 - f"Item {to_camelcase(k)}", # noqa: E501 - ) - ) - else: - changes.append( - self._change_text_generator.added_text( - f"Item {to_camelcase(key)}" - ) - ) - - else: - if value and value != old_value: - changes.append( - self._change_text_generator.changed_text( - f"Item {to_camelcase(field)}", value - ) - if field not in ["question"] - else f"Item {to_camelcase(field)} updated: {self._change_text_generator.changed_dict(old_value, value)}." # noqa: E501 - ) - - return changes, bool(changes) + def _populate_bool_changes( + self, field_name: str, value: bool, changes: list[str] + ) -> None: + # Invert value for hidden (UI name contains visibility) because on UI + # it will be visibility + if "Visibility" in field_name: + value = not value + changes.append(self._change_text_generator.set_bool(field_name, value)) diff --git a/src/apps/shared/commands/__init__.py b/src/apps/shared/commands/__init__.py new file mode 100644 index 00000000000..aec44ee4476 --- /dev/null +++ b/src/apps/shared/commands/__init__.py @@ -0,0 +1 @@ +from apps.shared.commands.patch_commands import app as patch # noqa: F401 diff --git a/src/apps/shared/commands/domain.py b/src/apps/shared/commands/domain.py new file mode 100644 index 00000000000..04f8f448504 --- /dev/null +++ b/src/apps/shared/commands/domain.py @@ -0,0 +1,19 @@ +import os + +from pydantic import validator + +from apps.shared.domain import InternalModel + + +class Patch(InternalModel): + file_path: str + task_id: str + description: str + manage_session: bool + + @validator("file_path") + def validate_file_existance(cls, v): + path = os.path.join(os.path.dirname(__file__), "patches", v) + if not os.path.exists(path): + raise ValueError("File does not exist") + return v diff --git a/src/apps/shared/commands/patch.py b/src/apps/shared/commands/patch.py new file mode 100644 index 00000000000..3360cf35b2c --- /dev/null +++ b/src/apps/shared/commands/patch.py @@ -0,0 +1,44 @@ +from apps.shared.commands.domain import Patch + + +class PatchRegister: + patches: list[Patch] | None = None + + @classmethod + def register( + self, + file_path: str, + task_id: str, + description: str, + manage_session: bool, + ): + self.patches = self.patches or [] + # check if task_id already exist + found_patch = next( + (p for p in self.patches if p.task_id == task_id), None + ) + if found_patch: + raise ValueError(f"Patch with task_id {task_id} already exist") + self.patches.append( + Patch( + file_path=file_path, + task_id=task_id, + description=description, + manage_session=manage_session, + ) + ) + + @classmethod + def get_all(self): + return self.patches or [] + + @classmethod + def get_by_task_id(self, task_id: str): + if not self.patches: + return [] + # find patch by task_id + found_patch = next( + (p for p in self.patches if p.task_id == task_id), None + ) + + return found_patch diff --git a/src/apps/shared/commands/patch_commands.py b/src/apps/shared/commands/patch_commands.py new file mode 100644 index 00000000000..8da68ce109c --- /dev/null +++ b/src/apps/shared/commands/patch_commands.py @@ -0,0 +1,177 @@ +import asyncio +import importlib +import uuid +from functools import wraps +from pathlib import Path +from typing import Optional + +import typer +from rich import print +from rich.style import Style +from rich.table import Table + +from apps.shared.commands.domain import Patch +from apps.shared.commands.patch import PatchRegister +from apps.workspaces.errors import WorkspaceNotFoundError +from apps.workspaces.service.workspace import WorkspaceService +from infrastructure.database import atomic, session_manager + +PatchRegister.register( + file_path="slider_tickmark_label.py", + task_id="M2-3781", + description="Slider tick marks and labels fix patch", + manage_session=False, +) + + +app = typer.Typer() + + +def coro(f): + @wraps(f) + def wrapper(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +def print_data_table(data: list[Patch]): + table = Table( + "Task ID", + "Description", + "Manage session inside patch", + show_header=True, + title="Patches", + title_style=Style(bold=True), + ) + + for patch in data: + table.add_row( + f"[bold]{patch.task_id}[bold]", + str(patch.description), + str(patch.manage_session), + ) + print(table) + + +def wrap_error_msg(msg): + return f"[bold red]Error: \n{msg}[/bold red]" + + +@app.command(short_help="Show list of registered patches.") +@coro +async def show(): + data = PatchRegister.get_all() + if not data: + print("[bold green]Patches not registered[/bold green]") + return + print_data_table(data) + + +@app.command(short_help="Execute registered patch.") +@coro +async def exec( + task_id: str = typer.Argument(..., help="Patch task id"), + owner_id: Optional[uuid.UUID] = typer.Option( + None, + "--owner-id", + "-o", + help="Workspace owner id", + ), +): + patch = PatchRegister.get_by_task_id(task_id) + if not patch: + print(wrap_error_msg("Patch not registered")) + else: + await exec_patch(patch, owner_id) + + return + + +async def exec_patch(patch: Patch, owner_id: Optional[uuid.UUID]): + session_maker = session_manager.get_session() + arbitrary = None + try: + async with session_maker() as session: + async with atomic(session): + if owner_id: + try: + arbitrary = await WorkspaceService( + session, owner_id + ).get_arbitrary_info_by_owner_id(owner_id) + if not arbitrary: + raise WorkspaceNotFoundError("Workspace not found") + + except WorkspaceNotFoundError as e: + print(wrap_error_msg(e)) + raise + finally: + await session_maker.remove() + + arbitrary_session_maker = None + if arbitrary: + arbitrary_session_maker = session_manager.get_session( + arbitrary.database_uri + ) + + session_maker = session_manager.get_session() + + if patch.file_path.endswith(".sql"): + # execute sql file + try: + async with session_maker() as session: + async with atomic(session): + try: + with open( + ( + str(Path(__file__).parent.resolve()) + + "/patches/" + + patch.file_path + ), + "r", + ) as f: + sql = f.read() + await session.execute(sql) + await session.commit() + print( + f"[bold green]Patch {patch.task_id} executed[/bold green]" # noqa: E501 + ) + return + except Exception as e: + print(wrap_error_msg(e)) + finally: + await session_maker.remove() + elif patch.file_path.endswith(".py"): + try: + # run main from the file + patch_file = importlib.import_module( + str(__package__) + + ".patches." + + patch.file_path.replace(".py", ""), + ) + + # if manage_session is True, pass sessions to patch_file main + if patch.manage_session: + await patch_file.main(session_maker, arbitrary_session_maker) + else: + try: + async with session_maker() as session: + async with atomic(session): + if arbitrary_session_maker: + async with arbitrary_session_maker() as arbitrary_session: # noqa: E501 + async with atomic(arbitrary_session): + await patch_file.main( + session, arbitrary_session + ) + else: + await patch_file.main(session) + finally: + await session_maker.remove() + if arbitrary_session_maker: + await arbitrary_session_maker.remove() + + print( + f"[bold green]Patch {patch.task_id} executed[/bold green]" # noqa: E501 + ) + except Exception as e: + print(wrap_error_msg(e)) diff --git a/src/apps/shared/commands/patches/__init__.py b/src/apps/shared/commands/patches/__init__.py new file mode 100644 index 00000000000..3abe530ad3e --- /dev/null +++ b/src/apps/shared/commands/patches/__init__.py @@ -0,0 +1 @@ +from apps.shared.commands.patches.slider_tickmark_label import * # noqa: F401 F403 E501 diff --git a/src/apps/shared/commands/patches/sample.sql b/src/apps/shared/commands/patches/sample.sql new file mode 100644 index 00000000000..e8c2e440887 --- /dev/null +++ b/src/apps/shared/commands/patches/sample.sql @@ -0,0 +1 @@ +update public.invitations set is_deleted = True where is_deleted = True; \ No newline at end of file diff --git a/src/apps/shared/commands/patches/sample_arbitrary.py b/src/apps/shared/commands/patches/sample_arbitrary.py new file mode 100644 index 00000000000..2d115f53acb --- /dev/null +++ b/src/apps/shared/commands/patches/sample_arbitrary.py @@ -0,0 +1,7 @@ +from sqlalchemy.ext.asyncio import AsyncSession + + +async def main( + session: AsyncSession, arbitrary_session: AsyncSession, *args, **kwargs +): + pass diff --git a/src/apps/shared/commands/patches/sample_manage_session_arbitrary.py b/src/apps/shared/commands/patches/sample_manage_session_arbitrary.py new file mode 100644 index 00000000000..d6342b3d25e --- /dev/null +++ b/src/apps/shared/commands/patches/sample_manage_session_arbitrary.py @@ -0,0 +1,19 @@ +from infrastructure.database import atomic + + +async def main(session_maker, arbitrary_session_maker, *args, **kwargs): + try: + async with session_maker() as session: + async with atomic(session): + pass + + finally: + await session_maker.remove() + + if arbitrary_session_maker is not None: + try: + async with arbitrary_session_maker() as arb_session: + async with atomic(arb_session): + pass + finally: + await arbitrary_session_maker.remove() diff --git a/src/apps/shared/commands/patches/slider_tickmark_label.py b/src/apps/shared/commands/patches/slider_tickmark_label.py new file mode 100644 index 00000000000..f93f37c5294 --- /dev/null +++ b/src/apps/shared/commands/patches/slider_tickmark_label.py @@ -0,0 +1,33 @@ +import uuid + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Query + +from apps.activities.db.schemas import ActivityItemSchema, ActivitySchema +from apps.activities.domain.response_type_config import ResponseType + + +async def main(session: AsyncSession, *args, **kwargs): + query: Query = select(ActivityItemSchema) + query = query.join( + ActivitySchema, ActivityItemSchema.activity_id == ActivitySchema.id + ) + query = query.where( + ActivitySchema.applet_id + == uuid.UUID("62d06045-acd3-5a10-54f1-06f600000000") + ) + query = query.where( + ActivityItemSchema.response_type == ResponseType.SLIDER + ) + res = await session.execute(query) + slider_items: list[ActivityItemSchema] = res.scalars().all() + for item in slider_items: + item.config["show_tick_marks"] = True + item.config["show_tick_labels"] = True + + await session.execute( + update(ActivityItemSchema) + .where(ActivityItemSchema.id == item.id) + .values(config=item.config) + ) diff --git a/src/apps/shared/exception.py b/src/apps/shared/exception.py index 3f3e12efab6..696032664a4 100644 --- a/src/apps/shared/exception.py +++ b/src/apps/shared/exception.py @@ -15,18 +15,24 @@ class ExceptionTypes(str, Enum): class BaseError(Exception): + message_is_template: bool = False message = _("Oops, something went wrong.") fallback_language = Language.ENGLISH status_code = status.HTTP_500_INTERNAL_SERVER_ERROR type = ExceptionTypes.UNDEFINED - def __init__(self, **kwargs): + def __init__(self, *args, **kwargs): self.kwargs = kwargs + self.updated_message = None + if self.args and not self.message_is_template: + self.updated_message = args[0] super().__init__(self.message.format(**kwargs)) @property def error(self): + if self.updated_message: + return self.updated_message return _(self.message).format(**self.kwargs) diff --git a/src/apps/shared/test/client.py b/src/apps/shared/test/client.py index a1be21aa9e7..7f9fc1ccde7 100644 --- a/src/apps/shared/test/client.py +++ b/src/apps/shared/test/client.py @@ -30,7 +30,7 @@ def _get_updated_headers(self, headers: dict | None = None) -> dict: @staticmethod def _get_body(data: dict | None = None): if data: - return json.dumps(data) + return json.dumps(data, default=str) return None async def post( diff --git a/src/apps/test_data/service.py b/src/apps/test_data/service.py index d9c7a6815af..eb7bb5060cb 100644 --- a/src/apps/test_data/service.py +++ b/src/apps/test_data/service.py @@ -230,6 +230,7 @@ def _generate_response_value_config(self, type_: ResponseType): "tooltip": None, "is_hidden": False, "color": None, + "value": 0, }, { "id": str(uuid.uuid4()), @@ -239,6 +240,7 @@ def _generate_response_value_config(self, type_: ResponseType): "tooltip": None, "is_hidden": False, "color": None, + "value": 1, }, ] } @@ -269,6 +271,7 @@ def _generate_response_value_config(self, type_: ResponseType): "tooltip": None, "is_hidden": False, "color": None, + "value": 0, }, { "id": str(uuid.uuid4()), @@ -278,6 +281,7 @@ def _generate_response_value_config(self, type_: ResponseType): "tooltip": None, "is_hidden": False, "color": None, + "value": 1, }, ] } diff --git a/src/apps/test_data/test_data.py b/src/apps/test_data/test_data.py index 8f67a87877b..146c09b12b2 100644 --- a/src/apps/test_data/test_data.py +++ b/src/apps/test_data/test_data.py @@ -1,7 +1,5 @@ import uuid -import pytest - from apps.shared.test import BaseTest from infrastructure.database import rollback @@ -10,6 +8,7 @@ class TestData(BaseTest): fixtures = [ "users/fixtures/users.json", "folders/fixtures/folders.json", + "themes/fixtures/themes.json", ] login_url = "/auth/login" @@ -17,7 +16,6 @@ class TestData(BaseTest): generate_applet_url = f"{generating_url}/generate_applet" applet_list_url = "applets" - @pytest.mark.skip @rollback async def test_generate_applet(self): await self.client.login( diff --git a/src/apps/themes/db/schemas.py b/src/apps/themes/db/schemas.py index 1522f4f0b38..7b8538692de 100644 --- a/src/apps/themes/db/schemas.py +++ b/src/apps/themes/db/schemas.py @@ -14,8 +14,6 @@ class ThemeSchema(Base): tertiary_color = Column(String(length=100)) public = Column(Boolean(), default=False) allow_rename = Column(Boolean(), default=False) - creator_id = Column( - ForeignKey("users.id", ondelete="RESTRICT"), nullable=False - ) + creator_id = Column(ForeignKey("users.id", ondelete="RESTRICT")) small_logo = Column(Text()) is_default = Column(Boolean(), default=False, nullable=False) diff --git a/src/apps/themes/domain.py b/src/apps/themes/domain.py index 06c4483a161..aa2434ac537 100644 --- a/src/apps/themes/domain.py +++ b/src/apps/themes/domain.py @@ -68,7 +68,7 @@ def validate_color(cls, value): class Theme(ThemeBase, InternalModel): id: uuid.UUID - creator_id: uuid.UUID + creator_id: uuid.UUID | None public: bool allow_rename: bool @@ -79,6 +79,52 @@ class PublicTheme(ThemeBase, PublicModel): allow_rename: bool +class PublicThemeMobile(PublicModel): + id: uuid.UUID + name: str = Field( + ..., + description="Name of the theme", + example="My theme", + max_length=100, + ) + logo: str | None = Field( + ..., + description="URL to logo image", + example="https://example.com/logo.png", + ) + background_image: str | None = Field( + ..., + description="URL to background image", + example="https://example.com/background.png", + ) + primary_color: Color = Field( + ..., + description="Primary color", + example="#FFFFFF", + ) + secondary_color: Color = Field( + ..., + description="Secondary color", + example="#FFFFFF", + ) + tertiary_color: Color = Field( + ..., + description="Tertiary color", + example="#FFFFFF", + ) + + def __str__(self) -> str: + return self.name + + @validator("logo", "background_image") + def validate_image(cls, value): + return validate_image(value) if value else value + + @validator("primary_color", "secondary_color", "tertiary_color") + def validate_color(cls, value): + return validate_color(value) if value else value + + class ThemeRequest(ThemeBase, PublicModel): pass diff --git a/src/apps/themes/errors.py b/src/apps/themes/errors.py index 646302779bd..3436f056ee2 100644 --- a/src/apps/themes/errors.py +++ b/src/apps/themes/errors.py @@ -8,6 +8,7 @@ class ThemeNotFoundError(NotFoundError): + message_is_template: bool = True message = _("No such theme with {key}={value}.") diff --git a/src/apps/transfer_ownership/constants.py b/src/apps/transfer_ownership/constants.py new file mode 100644 index 00000000000..4f9d445c0ac --- /dev/null +++ b/src/apps/transfer_ownership/constants.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class TransferOwnershipStatus(str, Enum): + PENDING = "pending" + APPROVED = "approved" + DECLINED = "declined" diff --git a/src/apps/transfer_ownership/crud.py b/src/apps/transfer_ownership/crud.py index 618b06d238d..feea6c6d2aa 100644 --- a/src/apps/transfer_ownership/crud.py +++ b/src/apps/transfer_ownership/crud.py @@ -1,7 +1,8 @@ import uuid -from sqlalchemy import delete +from sqlalchemy import select, update +from apps.transfer_ownership.constants import TransferOwnershipStatus from apps.transfer_ownership.db.schemas import TransferSchema from apps.transfer_ownership.domain import Transfer from apps.transfer_ownership.errors import TransferNotFoundError @@ -17,17 +18,37 @@ async def create(self, transfer: Transfer) -> TransferSchema: return await self._create(TransferSchema(**transfer.dict())) async def get_by_key(self, key: uuid.UUID) -> TransferSchema: - if not (instance := await self._get(key="key", value=key)): + query = select(self.schema_class) + query = query.where(self.schema_class.key == key) + query = query.where( + self.schema_class.status == TransferOwnershipStatus.PENDING + ) + result = await self._execute(query) + instance = result.scalars().first() + if not instance: raise TransferNotFoundError() return instance - async def delete_all_by_applet_id(self, applet_id: uuid.UUID) -> None: - query = delete(self.schema_class) + async def decline_all_pending_by_applet_id( + self, applet_id: uuid.UUID + ) -> None: + query = update(self.schema_class) query = query.where(TransferSchema.applet_id == applet_id) + query = query.where( + self.schema_class.status == TransferOwnershipStatus.PENDING + ) + query = query.values(status=TransferOwnershipStatus.DECLINED) + await self._execute(query) + + async def decline_by_key(self, key: uuid.UUID) -> None: + query = update(self.schema_class) + query = query.where(self.schema_class.key == key) + query = query.values(status=TransferOwnershipStatus.DECLINED) await self._execute(query) - async def delete_by_key(self, key: uuid.UUID) -> None: - query = delete(self.schema_class) + async def approve_by_key(self, key: uuid.UUID) -> None: + query = update(self.schema_class) query = query.where(self.schema_class.key == key) + query = query.values(status=TransferOwnershipStatus.APPROVED) await self._execute(query) diff --git a/src/apps/transfer_ownership/db/schemas.py b/src/apps/transfer_ownership/db/schemas.py index d09a9f13c77..de74a280bc6 100644 --- a/src/apps/transfer_ownership/db/schemas.py +++ b/src/apps/transfer_ownership/db/schemas.py @@ -1,14 +1,18 @@ -from sqlalchemy import Column, ForeignKey, String +from sqlalchemy import Column, ForeignKey, String, Unicode from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy_utils import StringEncryptedType +from apps.shared.encryption import get_key +from apps.transfer_ownership.constants import TransferOwnershipStatus from infrastructure.database import Base class TransferSchema(Base): __tablename__ = "transfer_ownership" - email = Column(String()) + email = Column(StringEncryptedType(Unicode, get_key)) applet_id = Column( ForeignKey("applets.id", ondelete="RESTRICT"), nullable=False ) key = Column(UUID(as_uuid=True)) + status = Column(String(), server_default=TransferOwnershipStatus.PENDING) diff --git a/src/apps/transfer_ownership/domain.py b/src/apps/transfer_ownership/domain.py index a90e86c86cc..15ac4d58412 100644 --- a/src/apps/transfer_ownership/domain.py +++ b/src/apps/transfer_ownership/domain.py @@ -3,6 +3,7 @@ from pydantic import EmailStr from apps.shared.domain import InternalModel +from apps.transfer_ownership.constants import TransferOwnershipStatus __all__ = [ "Transfer", @@ -16,6 +17,7 @@ class Transfer(InternalModel): email: EmailStr applet_id: uuid.UUID key: uuid.UUID + status: TransferOwnershipStatus class InitiateTransfer(InternalModel): diff --git a/src/apps/transfer_ownership/fixtures/transfers.json b/src/apps/transfer_ownership/fixtures/transfers.json index e71bbd7fe76..1c4f632d101 100644 --- a/src/apps/transfer_ownership/fixtures/transfers.json +++ b/src/apps/transfer_ownership/fixtures/transfers.json @@ -6,9 +6,12 @@ "created_at": "2023-01-05T15:49:51.752113", "updated_at": "2023-01-05T15:49:51.752113", "is_deleted": false, - "email": "lucy@gmail.com", + "email": "VwkVdPjpEtq4bS35CpEtwg==", "applet_id": "92917a56-d586-4613-b7aa-991f2c4b15b1", "key": "6a3ab8e6-f2fa-49ae-b2db-197136677da7" + }, + "note": { + "plain_email": "lucy@gmail.com" } } ] \ No newline at end of file diff --git a/src/apps/transfer_ownership/service.py b/src/apps/transfer_ownership/service.py index 070f903f00b..01b3043c543 100644 --- a/src/apps/transfer_ownership/service.py +++ b/src/apps/transfer_ownership/service.py @@ -8,6 +8,7 @@ from apps.invitations.services import InvitationsService from apps.mailing.domain import MessageSchema from apps.mailing.services import MailingService +from apps.transfer_ownership.constants import TransferOwnershipStatus from apps.transfer_ownership.crud import TransferCRUD from apps.transfer_ownership.domain import InitiateTransfer, Transfer from apps.transfer_ownership.errors import TransferEmailError @@ -39,6 +40,7 @@ async def initiate_transfer( email=transfer_request.email, applet_id=applet_id, key=uuid.uuid4(), + status=TransferOwnershipStatus.PENDING, ) await TransferCRUD(self.session).create(transfer) try: @@ -81,6 +83,7 @@ async def accept_transfer(self, applet_id: uuid.UUID, key: uuid.UUID): """Respond to a transfer of ownership of an applet.""" await AppletsCRUD(self.session).get_by_id(applet_id) await AppletsCRUD(self.session).clear_encryption(applet_id) + await AppletsCRUD(self.session).clear_report_settings(applet_id) transfer = await TransferCRUD(self.session).get_by_key(key=key) if ( @@ -99,8 +102,8 @@ async def accept_transfer(self, applet_id: uuid.UUID, key: uuid.UUID): await AnswersCRUD(self.session).delete_by_applet_user( applet_id=transfer.applet_id ) - - await TransferCRUD(self.session).delete_all_by_applet_id( + await TransferCRUD(self.session).approve_by_key(key=key) + await TransferCRUD(self.session).decline_all_pending_by_applet_id( applet_id=transfer.applet_id ) @@ -119,8 +122,8 @@ async def accept_transfer(self, applet_id: uuid.UUID, key: uuid.UUID): role=Role.RESPONDENT, meta=dict( secretUserId=str(uuid.uuid4()), - nickname=f"{self._user.first_name} {self._user.last_name}", ), + nickname=f"{self._user.first_name} {self._user.last_name}", **roles_data, ), ] @@ -147,4 +150,4 @@ async def decline_transfer(self, applet_id: uuid.UUID, key: uuid.UUID): raise PermissionsError() # delete transfer - await TransferCRUD(self.session).delete_by_key(key=key) + await TransferCRUD(self.session).decline_by_key(key=key) diff --git a/src/apps/transfer_ownership/tests.py b/src/apps/transfer_ownership/tests.py index e933d9750dd..647035d5d15 100644 --- a/src/apps/transfer_ownership/tests.py +++ b/src/apps/transfer_ownership/tests.py @@ -10,11 +10,13 @@ class TestTransfer(BaseTest): "applets/fixtures/applets.json", "applets/fixtures/applet_user_accesses.json", "transfer_ownership/fixtures/transfers.json", + "themes/fixtures/themes.json", ] login_url = "/auth/login" transfer_url = "/applets/{applet_id}/transferOwnership" response_url = "/applets/{applet_id}/transferOwnership/{key}" + applet_details_url = "/applets/{applet_id}" @rollback async def test_initiate_transfer(self): @@ -140,3 +142,47 @@ async def test_re_accept_transfer(self): ) assert response.status_code == 404 + + @rollback + async def test_accept_transfer_report_settings_are_cleared(self): + report_settings_keys = ( + "reportServerIp", + "reportPublicKey", + "reportRecipients", + "reportEmailBody", + "reportIncludeUserId", + "reportIncludeCaseId", + ) + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + resp = await self.client.get( + self.applet_details_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ) + ) + assert resp.status_code == 200 + resp_data = resp.json()["result"] + # Fot this test all report settings are set for applet + for key in report_settings_keys: + assert resp_data[key] + + await self.client.login(self.login_url, "lucy@gmail.com", "Test123") + response = await self.client.post( + self.response_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1", + key="6a3ab8e6-f2fa-49ae-b2db-197136677da7", + ), + ) + assert response.status_code == 200 + + resp = await self.client.get( + self.applet_details_url.format( + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b1" + ) + ) + assert resp.status_code == 200 + resp_data = resp.json()["result"] + # After accept transfership all report settings must be cleared + for key in report_settings_keys: + assert not resp_data[key] diff --git a/src/apps/users/api/password.py b/src/apps/users/api/password.py index 9e1ad911f35..fcfb6261bb2 100644 --- a/src/apps/users/api/password.py +++ b/src/apps/users/api/password.py @@ -19,7 +19,11 @@ User, UserChangePassword, ) -from apps.users.errors import ReencryptionInProgressError, UserNotFound +from apps.users.errors import ( + PasswordHasSpacesError, + ReencryptionInProgressError, + UserNotFound, +) from apps.users.services import PasswordRecoveryCache, PasswordRecoveryService from apps.users.tasks import reencrypt_answers from config import settings @@ -37,6 +41,9 @@ async def password_update( session=Depends(get_session), ) -> Response[PublicUser]: """General endpoint for update password for signin.""" + if " " in schema.password: + raise PasswordHasSpacesError() + reencryption_in_progress = await JobService( session, user.id ).is_job_in_progress("reencrypt_answers") diff --git a/src/apps/users/api/users.py b/src/apps/users/api/users.py index 568a567c342..9c4cfb848b9 100644 --- a/src/apps/users/api/users.py +++ b/src/apps/users/api/users.py @@ -13,7 +13,7 @@ UserCreateRequest, UserUpdateRequest, ) -from apps.users.errors import EmailAddressNotValid +from apps.users.errors import EmailAddressNotValid, PasswordHasSpacesError from apps.workspaces.crud.workspaces import UserWorkspaceCRUD from apps.workspaces.db.schemas import UserWorkspaceSchema from infrastructure.database.core import atomic @@ -24,6 +24,8 @@ async def user_create( user_create_schema: UserCreateRequest = Body(...), session=Depends(get_session), ) -> Response[PublicUser]: + if " " in user_create_schema.password: + raise PasswordHasSpacesError() async with atomic(session): email_hash = hash_sha224(user_create_schema.email) user_schema = await UsersCRUD(session).save( diff --git a/src/apps/users/cruds/user.py b/src/apps/users/cruds/user.py index d248085866f..852b856bc3a 100644 --- a/src/apps/users/cruds/user.py +++ b/src/apps/users/cruds/user.py @@ -148,6 +148,11 @@ async def get_anonymous_respondent(self) -> UserSchema | None: async def get_by_ids(self, ids: Collection[uuid.UUID]) -> List[UserSchema]: query: Query = select(UserSchema) - query.where(UserSchema.id.in_(ids)) + query = query.where(UserSchema.id.in_(ids)) db_result = await self._execute(query) return db_result.scalars().all() # noqa + + async def get_user_or_none_by_email(self, email: str) -> UserSchema | None: + email_hash = hash_sha224(email) + user = await self._get("email", email_hash) + return user diff --git a/src/apps/users/errors.py b/src/apps/users/errors.py index 4ec053e2e02..9faa2ca774e 100644 --- a/src/apps/users/errors.py +++ b/src/apps/users/errors.py @@ -8,7 +8,9 @@ class UserNotFound(NotFoundError): class UserAlreadyExistError(ValidationError): - message = _("That email is already registered in the system.") + message = _( + "That email address is already associated with a MindLogger account." + ) class EmailAddressError(ValidationError): @@ -16,6 +18,7 @@ class EmailAddressError(ValidationError): class EmailAddressNotValid(ValidationError): + message_is_template: bool = True message = _("Email address: {email} is not valid.") @@ -23,6 +26,10 @@ class PasswordRecoveryKeyNotFound(NotFoundError): message = _("Password recovery key not found.") +class PasswordHasSpacesError(NotFoundError): + message = _("Password should not contain blank spaces") + + class UserIsDeletedError(NotFoundError): message = _("User is deleted.") @@ -32,6 +39,7 @@ class UserDeviceNotFound(NotFoundError): class UsersError(ValidationError): + message_is_template: bool = True message = _("Can not make the looking up by {key} {value}.") diff --git a/src/apps/users/services/user.py b/src/apps/users/services/user.py index 79f4fbdaf71..a5967f0aec0 100644 --- a/src/apps/users/services/user.py +++ b/src/apps/users/services/user.py @@ -1,6 +1,9 @@ +import uuid + from apps.authentication.services import AuthenticationService from apps.users import UserSchema, UsersCRUD from apps.users.domain import User +from apps.users.errors import UserNotFound from apps.workspaces.crud.workspaces import UserWorkspaceCRUD from apps.workspaces.db.schemas import UserWorkspaceSchema from config import settings @@ -88,3 +91,11 @@ async def create_anonymous_respondent(self): async def get_by_email(self, email: str) -> User: crud = UsersCRUD(self.session) return await crud.get_by_email(email) + + async def exists_by_id(self, user_id: uuid.UUID): + user_exist = await UsersCRUD(self.session).exist_by_id(id_=user_id) + if not user_exist: + raise UserNotFound() + + async def get(self, user_id: uuid.UUID) -> User | None: + return await UsersCRUD(self.session).get_by_id(user_id) diff --git a/src/apps/users/tests/test_password.py b/src/apps/users/tests/test_password.py index afed084e6d4..dc965ece823 100644 --- a/src/apps/users/tests/test_password.py +++ b/src/apps/users/tests/test_password.py @@ -2,7 +2,6 @@ import datetime from unittest.mock import patch -import pytest from asynctest import CoroutineMock from httpx import Response as HttpResponse from starlette import status @@ -93,7 +92,6 @@ async def test_password_update(self, task_mock: CoroutineMock): assert internal_response.status_code == status.HTTP_200_OK task_mock.assert_awaited_once() - @pytest.mark.skip @rollback async def test_password_recovery( self, @@ -118,7 +116,9 @@ async def test_password_recovery( cache = RedisCache() assert response.status_code == status.HTTP_201_CREATED - keys = await cache.keys() + keys = await cache.keys( + key="PasswordRecoveryCache:tom2@mindlogger.com*" + ) assert len(keys) == 1 assert password_recovery_request.email in keys[0] assert len(TestMail.mails) == 1 @@ -137,7 +137,9 @@ async def test_password_recovery( assert response.status_code == status.HTTP_201_CREATED - new_keys = await cache.keys() + new_keys = await cache.keys( + key="PasswordRecoveryCache:tom2@mindlogger.com*" + ) assert len(keys) == 1 assert keys[0] != new_keys[0] assert len(TestMail.mails) == 2 @@ -145,7 +147,6 @@ async def test_password_recovery( TestMail.mails[0].recipients[0] == password_recovery_request.email ) - @pytest.mark.skip @rollback async def test_password_recovery_approve( self, @@ -171,8 +172,10 @@ async def test_password_recovery_approve( data=password_recovery_request.dict(), ) - assert response.status_code == status.HTTP_200_OK - key = (await cache.keys())[0].split(":")[-1] + assert response.status_code == status.HTTP_201_CREATED + key = ( + await cache.keys(key="PasswordRecoveryCache:tom2@mindlogger.com*") + )[0].split(":")[-1] data = { "email": self.create_request_user.dict()["email"], @@ -185,14 +188,15 @@ async def test_password_recovery_approve( data=data, ) - keys = await cache.keys() + keys = await cache.keys( + key="PasswordRecoveryCache:tom2@mindlogger.com*" + ) assert response.status_code == status.HTTP_200_OK assert response.json() == expected_result assert len(keys) == 0 assert len(keys) == 0 - @pytest.mark.skip @rollback async def test_password_recovery_approve_expired( self, @@ -217,8 +221,10 @@ async def test_password_recovery_approve_expired( data=password_recovery_request.dict(), ) - assert response.status_code == status.HTTP_200_OK - key = (await cache.keys())[0].split(":")[-1] + assert response.status_code == status.HTTP_201_CREATED + key = ( + await cache.keys(key="PasswordRecoveryCache:tom2@mindlogger.com*") + )[0].split(":")[-1] await asyncio.sleep(2) data = { @@ -232,7 +238,9 @@ async def test_password_recovery_approve_expired( data=data, ) - keys = await cache.keys() + keys = await cache.keys( + key="PasswordRecoveryCache:tom2@mindlogger.com*" + ) assert response.status_code == status.HTTP_404_NOT_FOUND assert len(keys) == 0 diff --git a/src/apps/workspaces/api.py b/src/apps/workspaces/api.py index 0cc65c5d538..cac57432194 100644 --- a/src/apps/workspaces/api.py +++ b/src/apps/workspaces/api.py @@ -3,10 +3,15 @@ from fastapi import Body, Depends, Query +from apps.answers.deps.preprocess_arbitrary import ( + get_answer_session_by_owner_id, +) +from apps.answers.service import AnswerService from apps.applets.domain.applet_full import PublicAppletFull from apps.applets.filters import AppletQueryParams from apps.applets.service import AppletService from apps.authentication.deps import get_current_user +from apps.invitations.services import InvitationsService from apps.shared.domain import Response, ResponseMulti from apps.shared.query_params import ( BaseQueryParams, @@ -14,6 +19,7 @@ parse_query_params, ) from apps.users.domain import User +from apps.users.services.user import UserService # from apps.workspaces.crud.user_applet_access import UserAppletAccessCRUD from apps.workspaces.domain.constants import Role, UserPinRole @@ -23,6 +29,7 @@ RemoveManagerAccess, RemoveRespondentAccess, RespondentInfo, + RespondentInfoPublic, ) from apps.workspaces.domain.workspace import ( PublicWorkspace, @@ -253,6 +260,16 @@ async def workspace_remove_manager_access( """Remove manager access from a specific user.""" async with atomic(session): await UserAccessService(session, user.id).remove_manager_access(schema) + # Get applets where user still have access + ex_admin = await UserService(session).get(schema.user_id) + if ex_admin: + management_applets = await UserAccessService( + session, schema.user_id + ).get_management_applets(schema.applet_ids) + ids_to_remove = set(schema.applet_ids) - set(management_applets) + await InvitationsService(session, ex_admin).delete_for_managers( + list(ids_to_remove) + ) async def applet_remove_respondent_access( @@ -264,6 +281,11 @@ async def applet_remove_respondent_access( await UserAccessService(session, user.id).remove_respondent_access( schema ) + ex_resp = await UserService(session).get(schema.user_id) + if ex_resp: + await InvitationsService(session, ex_resp).delete_for_respondents( + schema.applet_ids + ) async def workspace_respondents_list( @@ -273,6 +295,7 @@ async def workspace_respondents_list( parse_query_params(WorkspaceUsersQueryParams) ), session=Depends(get_session), + answer_session=Depends(get_answer_session_by_owner_id), ) -> ResponseMulti[PublicWorkspaceRespondent]: service = WorkspaceService(session, user.id) await service.exists_by_owner_id(owner_id) @@ -284,8 +307,10 @@ async def workspace_respondents_list( data, total = await service.get_workspace_respondents( owner_id, None, deepcopy(query_params) ) - - return ResponseMulti(result=data, count=total) + respondents = await AnswerService( + session=session, arbitrary_session=answer_session + ).fill_last_activity(data) + return ResponseMulti(result=respondents, count=total) async def workspace_applet_respondents_list( @@ -296,6 +321,7 @@ async def workspace_applet_respondents_list( parse_query_params(WorkspaceUsersQueryParams) ), session=Depends(get_session), + answer_session=Depends(get_answer_session_by_owner_id), ) -> ResponseMulti[PublicWorkspaceRespondent]: service = WorkspaceService(session, user.id) await service.exists_by_owner_id(owner_id) @@ -307,8 +333,10 @@ async def workspace_applet_respondents_list( data, total = await service.get_workspace_respondents( owner_id, applet_id, deepcopy(query_params) ) - - return ResponseMulti(result=data, count=total) + respondents = await AnswerService( + session=session, arbitrary_session=answer_session + ).fill_last_activity(data, applet_id) + return ResponseMulti(result=respondents, count=total) async def workspace_managers_list( @@ -443,10 +471,34 @@ async def workspace_managers_applet_access_set( ): async with atomic(session): await WorkspaceService(session, user.id).exists_by_owner_id(owner_id) + await AppletService(session, user.id).exist_by_ids( + [access.applet_id for access in accesses.accesses] + ) await CheckAccessService( session, user.id ).check_workspace_manager_accesses_access(owner_id) + await UserService(session).exists_by_id(manager_id) await UserAccessService(session, user.id).set( owner_id, manager_id, accesses ) + + +async def workspace_applet_get_respondent( + owner_id: uuid.UUID, + applet_id: uuid.UUID, + respondent_id: uuid.UUID, + user: User = Depends(get_current_user), + session=Depends(get_session), +) -> Response[RespondentInfoPublic]: + async with atomic(session): + await AppletService(session, user.id).exist_by_id(applet_id) + await WorkspaceService(session, user.id).exists_by_owner_id(owner_id) + await CheckAccessService( + session, user.id + ).check_applet_respondent_list_access(applet_id) + + respondent_info = await UserAppletAccessService( + session, user.id, applet_id + ).get_respondent_info(respondent_id, applet_id, owner_id) + return Response(result=respondent_info) diff --git a/src/apps/workspaces/commands/__init__.py b/src/apps/workspaces/commands/__init__.py new file mode 100644 index 00000000000..916ed20411e --- /dev/null +++ b/src/apps/workspaces/commands/__init__.py @@ -0,0 +1,3 @@ +from apps.workspaces.commands.arbitrary_server import ( # noqa: F401 + app as arbitrary_server_cli, +) diff --git a/src/apps/workspaces/commands/arbitrary_server.py b/src/apps/workspaces/commands/arbitrary_server.py new file mode 100644 index 00000000000..02bf4c2107d --- /dev/null +++ b/src/apps/workspaces/commands/arbitrary_server.py @@ -0,0 +1,186 @@ +import asyncio +import uuid +from functools import wraps +from typing import Optional + +import typer +from pydantic import ValidationError +from rich import print +from rich.style import Style +from rich.table import Table + +from apps.workspaces.constants import StorageType +from apps.workspaces.domain.workspace import ( + WorkspaceArbitraryCreate, + WorkspaceArbitraryFields, +) +from apps.workspaces.errors import ( + ArbitraryServerSettingsError, + WorkspaceNotFoundError, +) +from apps.workspaces.service.workspace import WorkspaceService +from infrastructure.database import atomic, session_manager + +app = typer.Typer() + + +def coro(f): + @wraps(f) + def wrapper(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + return wrapper + + +def print_data_table(data: WorkspaceArbitraryFields): + table = Table( + show_header=False, + title="Arbitrary server settings", + title_style=Style(bold=True), + ) + for k, v in data.dict(by_alias=False).items(): + table.add_row(f"[bold]{k}[/bold]", str(v)) + + print(table) + + +def wrap_error_msg(msg): + return f"[bold red]Error: \n{msg}[/bold red]" + + +@app.command(short_help="Add arbitrary server settings") +@coro +async def add( + owner_id: uuid.UUID = typer.Argument(..., help="Workspace owner id"), + database_uri: str = typer.Option( + ..., + "--db-uri", + "-d", + help="Arbitrary server database uri", + ), + storage_type: StorageType = typer.Option( + ..., + "--storage-type", + "-t", + help="Arbitrary server storage type", + ), + storage_url: str = typer.Option( + None, + "--storage-url", + "-u", + help="Arbitrary server storage url", + ), + storage_access_key: str = typer.Option( + None, + "--storage-access-key", + "-a", + help="Arbitrary server storage access key", + ), + storage_secret_key: str = typer.Option( + ..., + "--storage-secret-key", + "-s", + help="Arbitrary server storage secret key", + ), + storage_region: str = typer.Option( + None, + "--storage-region", + "-r", + help="Arbitrary server storage region", + ), + storage_bucket: str = typer.Option( + None, + "--storage-bucket", + "-b", + help="Arbitrary server storage bucket", + ), + use_arbitrary: bool = typer.Option( + True, + is_flag=True, + help="Use arbitrary server for workspace", + ), + force: bool = typer.Option( + False, + "--force", + "-f", + is_flag=True, + help="Rewrite existing settings", + ), +): + try: + data = WorkspaceArbitraryCreate( + database_uri=database_uri, + storage_type=storage_type, + storage_url=storage_url, + storage_access_key=storage_access_key, + storage_secret_key=storage_secret_key, + storage_region=storage_region, + storage_bucket=storage_bucket, + use_arbitrary=use_arbitrary, + ) + except ValidationError as e: + err = next(iter(e.errors())) + loc = err["loc"] + loc_str = "" + if isinstance(loc[-1], int) and len(loc) > 1: + loc_str = f"{loc[-2]}.{loc[-1]}: " + elif loc[-1] != "__root__": + loc_str = f"{loc[-1]}: " + print(wrap_error_msg(loc_str + err["msg"])) + return + + session_maker = session_manager.get_session() + try: + async with session_maker() as session: + async with atomic(session): + try: + await WorkspaceService( + session, owner_id + ).set_arbitrary_server(data, rewrite=force) + except WorkspaceNotFoundError as e: + print(wrap_error_msg(e)) + except ArbitraryServerSettingsError as e: + print( + wrap_error_msg( + "Arbitrary server is already set. " + "Use --force to rewrite." + ) + ) + print_data_table(e.data) + else: + print("[bold green]Success:[/bold green]") + print_data_table(data) + finally: + await session_maker.remove() + + +@app.command(short_help="Show arbitrary server settings") +@coro +async def show( + owner_id: Optional[uuid.UUID] = typer.Argument( + None, help="Workspace owner id" + ), +): + session_maker = session_manager.get_session() + try: + async with session_maker() as session: + if owner_id: + data = await WorkspaceService( + session, owner_id + ).get_arbitrary_info_by_owner_id(owner_id) + if not data: + print( + "[bold green]" + "Arbitrary server not configured" + "[/bold green]" + ) + return + print_data_table(WorkspaceArbitraryFields.from_orm(data)) + else: + workspaces = await WorkspaceService( + session, uuid.uuid4() + ).get_arbitrary_list() + for data in workspaces: + print_data_table(WorkspaceArbitraryFields.from_orm(data)) + finally: + await session_maker.remove() diff --git a/src/apps/workspaces/constants.py b/src/apps/workspaces/constants.py index 8732d7a1141..4a94e01d72b 100644 --- a/src/apps/workspaces/constants.py +++ b/src/apps/workspaces/constants.py @@ -1,4 +1,10 @@ -class StorageType: +import enum + + +class StorageType(str, enum.Enum): AWS = "aws" AZURE = "azure" GCP = "gcp" + + def __str__(self): + return self.value diff --git a/src/apps/workspaces/crud/applet_access.py b/src/apps/workspaces/crud/applet_access.py index 71bd663b71f..bd337249633 100644 --- a/src/apps/workspaces/crud/applet_access.py +++ b/src/apps/workspaces/crud/applet_access.py @@ -55,7 +55,9 @@ async def check_export_access( query = query.where(UserAppletAccessSchema.user_id == user_id) query = query.where( or_( - UserAppletAccessSchema.role.in_([Role.OWNER, Role.MANAGER]), + UserAppletAccessSchema.role.in_( + [Role.OWNER, Role.MANAGER, Role.RESPONDENT] + ), and_( UserAppletAccessSchema.role == Role.REVIEWER, func.json_array_length( diff --git a/src/apps/workspaces/crud/user_applet_access.py b/src/apps/workspaces/crud/user_applet_access.py index b368641d27f..4c42e9d49f0 100644 --- a/src/apps/workspaces/crud/user_applet_access.py +++ b/src/apps/workspaces/crud/user_applet_access.py @@ -6,6 +6,7 @@ from asyncpg.exceptions import UniqueViolationError from pydantic import parse_obj_as from sqlalchemy import ( + Unicode, and_, any_, case, @@ -20,15 +21,22 @@ true, update, ) -from sqlalchemy.dialects.postgresql import UUID, aggregate_order_by, insert +from sqlalchemy.dialects.postgresql import ( + ARRAY, + UUID, + aggregate_order_by, + insert, +) from sqlalchemy.engine import Result from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import Query from sqlalchemy.sql.functions import count +from sqlalchemy_utils import StringEncryptedType from apps.applets.db.schemas import AppletSchema from apps.folders.db.schemas import FolderAppletSchema from apps.schedule.db.schemas import EventSchema, UserEventsSchema +from apps.shared.encryption import get_key from apps.shared.filtering import Comparisons, FilterField, Filtering from apps.shared.ordering import Ordering from apps.shared.paging import paging @@ -103,14 +111,13 @@ class _AppletRespondentOrdering(Ordering): class _WorkspaceRespondentSearch(Searching): search_fields = [ - func.array_agg(UserAppletAccessSchema.meta["nickname"].astext), + func.array_agg(UserAppletAccessSchema.nickname), func.array_agg(UserAppletAccessSchema.meta["secretUserId"].astext), ] class _AppletRespondentSearch(Searching): search_fields = [ - UserAppletAccessSchema.meta["nickname"].astext, UserAppletAccessSchema.meta["secretUserId"].astext, ] @@ -266,6 +273,17 @@ async def get_applet_role_by_user_id( return db_result.scalars().first() + async def get_applet_role_by_user_id_exist( + self, applet_id: uuid.UUID, user_id: uuid.UUID, role: Role + ) -> UserAppletAccessSchema | None: + query: Query = select(UserAppletAccessSchema) + query = query.where(UserAppletAccessSchema.applet_id == applet_id) + query = query.where(UserAppletAccessSchema.user_id == user_id) + query = query.where(UserAppletAccessSchema.role == role) + db_result = await self._execute(query) + + return db_result.scalars().first() + def user_applet_ids_query(self, user_id: uuid.UUID) -> Query: query: Query = select(UserAppletAccessSchema.applet_id) query = query.where(UserAppletAccessSchema.soft_exists()) @@ -356,6 +374,7 @@ async def upsert_user_applet_access( "role": schema.role, "is_deleted": schema.is_deleted, "meta": schema.meta, + "nickname": schema.nickname, } stmt = insert(UserAppletAccessSchema).values(values) stmt = stmt.on_conflict_do_update( @@ -374,6 +393,7 @@ async def upsert_user_applet_access( "created_at": datetime.utcnow(), "updated_at": datetime.utcnow(), "meta": stmt.excluded.meta, + "nickname": stmt.excluded.nickname, }, where=where, ).returning(UserAppletAccessSchema) @@ -399,6 +419,7 @@ async def upsert_user_applet_access_list( "role": schema.role, "is_deleted": schema.is_deleted, "meta": schema.meta, + "nickname": schema.nickname, } for schema in schemas ] @@ -411,11 +432,14 @@ async def upsert_user_applet_access_list( UserAppletAccessSchema.role, ], set_={ + "invitor_id": stmt.excluded.invitor_id, + "owner_id": stmt.excluded.owner_id, "user_id": stmt.excluded.user_id, "applet_id": stmt.excluded.applet_id, "role": stmt.excluded.role, "is_deleted": stmt.excluded.is_deleted, "meta": stmt.excluded.meta, + "nickname": stmt.excluded.nickname, }, ) @@ -624,11 +648,12 @@ async def get_workspace_respondents( UserSchema.last_seen_at, UserSchema.created_at ).label("last_seen"), - func.array_agg( - aggregate_order_by( - func.distinct(field_nickname), field_nickname - ) - ).label("nicknames"), + func.array_remove( + func.array_agg( + func.distinct(field_nickname) + ), None) + .cast(ARRAY(StringEncryptedType(Unicode, get_key))) + .label("nicknames"), func.array_agg( aggregate_order_by( @@ -1022,6 +1047,7 @@ async def get_respondent_accesses_by_owner_id( query: Query = select( UserAppletAccessSchema.meta, + UserAppletAccessSchema.nickname, AppletSchema.id, AppletSchema.display_name, AppletSchema.image, @@ -1043,6 +1069,7 @@ async def get_respondent_accesses_by_owner_id( results = db_result.all() for ( meta, + nickname, applet_id, display_name, image, @@ -1055,7 +1082,7 @@ async def get_respondent_accesses_by_owner_id( applet_name=display_name, applet_image=image, secret_user_id=meta.get("secretUserId", ""), - nickname=meta.get("nickname", ""), + nickname=nickname, has_individual_schedule=has_individual, encryption=encryption, ) @@ -1188,11 +1215,13 @@ async def remove_manager_accesses_by_user_id_in_workspace( await self._execute(query) - async def update_meta_by_access_id(self, access_id: uuid.UUID, meta: dict): + async def update_meta_by_access_id( + self, access_id: uuid.UUID, meta: dict, nickname: str + ): query: Query = update(UserAppletAccessSchema) query = query.where(UserAppletAccessSchema.soft_exists()) query = query.where(UserAppletAccessSchema.id == access_id) - query = query.values(meta=meta) + query = query.values(meta=meta, nickname=nickname) await self._execute(query) @@ -1276,7 +1305,7 @@ async def get_responsible_persons( async def get_user_nickname( self, applet_id: uuid.UUID, user_id: uuid.UUID ) -> str | None: - query: Query = select(UserAppletAccessSchema.meta) + query: Query = select(UserAppletAccessSchema.nickname) query = query.where( UserAppletAccessSchema.applet_id == applet_id, UserAppletAccessSchema.user_id == user_id, @@ -1284,4 +1313,38 @@ async def get_user_nickname( ) db_result = await self._execute(query) db_result = db_result.first() - return db_result[0].get("nickname") if db_result else None + return db_result[0] if db_result else None + + async def get_respondent_by_applet_and_owner( + self, + respondent_id: uuid.UUID, + applet_id: uuid.UUID, + owner_id: uuid.UUID, + ) -> UserAppletAccessSchema | None: + query: Query = select(UserAppletAccessSchema) + query = query.where( + UserAppletAccessSchema.owner_id == owner_id, + UserAppletAccessSchema.applet_id == applet_id, + UserAppletAccessSchema.user_id == respondent_id, + UserAppletAccessSchema.role == Role.RESPONDENT, + UserAppletAccessSchema.soft_exists(), + ) + db_result = await self._execute(query) + db_result = db_result.first() # noqa + return db_result[0] if db_result else None + + async def get_management_applets( + self, + user_id: uuid.UUID, + applet_ids: list[uuid.UUID], + ) -> list[uuid.UUID]: + query: Query = select(UserAppletAccessSchema.applet_id) + query = query.where( + UserAppletAccessSchema.applet_id.in_(applet_ids), + UserAppletAccessSchema.user_id == user_id, + UserAppletAccessSchema.role.in_(Role.managers()), + UserAppletAccessSchema.soft_exists(), + ) + db_result = await self._execute(query) + db_result = db_result.scalars().all() # noqa + return db_result diff --git a/src/apps/workspaces/crud/workspaces.py b/src/apps/workspaces/crud/workspaces.py index 80c70cf3b8c..deb6febfb22 100644 --- a/src/apps/workspaces/crud/workspaces.py +++ b/src/apps/workspaces/crud/workspaces.py @@ -6,13 +6,12 @@ from sqlalchemy.orm import Query from apps.applets.db.schemas import AppletSchema -from apps.users import User from apps.workspaces.db.schemas import ( UserAppletAccessSchema, UserWorkspaceSchema, ) from apps.workspaces.domain.constants import Role -from apps.workspaces.domain.workspace import UserAnswersDBInfo, UserWorkspace +from apps.workspaces.domain.workspace import UserAnswersDBInfo from infrastructure.database.crud import BaseCRUD __all__ = ["UserWorkspaceCRUD"] @@ -48,23 +47,6 @@ async def save(self, schema: UserWorkspaceSchema) -> UserWorkspaceSchema: """Return UserWorkspace instance.""" return await self._create(schema) - async def update(self, user: User, workspace_prefix: str) -> UserWorkspace: - # Update UserWorkspace in database - instance = await self._update_one( - lookup="user_id", - value=user.id, - schema=UserWorkspaceSchema( - user_id=user.id, - workspace_name=workspace_prefix, - is_modified=True, - ), - ) - - # Create internal data model - user_workspace = UserWorkspace.from_orm(instance) - - return user_workspace - async def update_by_user_id( self, user_id: uuid.UUID, schema: UserWorkspaceSchema ) -> UserWorkspaceSchema: @@ -85,14 +67,65 @@ async def get_by_applet_id( UserAppletAccessSchema.applet_id == applet_id, ) ) - access_subquery = access_subquery.subquery() - query: Query = select(UserWorkspaceSchema) query = query.where(UserWorkspaceSchema.user_id.in_(access_subquery)) db_result = await self._execute(query) res = db_result.scalars().first() return res + async def get_arbitraries_map_by_applet_ids( + self, applet_ids: list[uuid.UUID] + ) -> dict[str | None, list[uuid.UUID]]: + """Returning map {"arbitrary_uri": [applet_ids]}""" + applet_owner_map = await self._get_applet_owners_map_by_applet_ids( + applet_ids + ) + owner_ids = set(applet_owner_map.values()) + + query: Query = select(UserWorkspaceSchema) + query = query.where(UserWorkspaceSchema.user_id.in_(owner_ids)) + db_result = await self._execute(query) + res = db_result.scalars().all() + + user_arb_uri_map: dict[uuid.UUID, str] = dict() + for user_workspace in res: + user_arb_uri_map[user_workspace.user_id] = ( + user_workspace.database_uri + if user_workspace.use_arbitrary + else None + ) + + arb_uri_applet_ids_map: dict[str | None, list[uuid.UUID]] = dict() + for applet_id in applet_ids: + user_id = applet_owner_map[applet_id] + arb_uri = user_arb_uri_map[user_id] + arb_uri_applet_ids_map.setdefault(arb_uri, list()) + arb_uri_applet_ids_map[arb_uri].append(applet_id) + + return arb_uri_applet_ids_map + + async def _get_applet_owners_map_by_applet_ids( + self, applet_ids: list[uuid.UUID] + ) -> dict[uuid.UUID, uuid.UUID]: + """Returning map {"applet_id": owner_id(user_id)}""" + query: Query = select(UserAppletAccessSchema) + query = query.where( + and_( + UserAppletAccessSchema.role == Role.OWNER, + UserAppletAccessSchema.applet_id.in_(applet_ids), + ) + ) + db_result = await self._execute(query) + res = db_result.scalars().all() + + applet_owner_map: dict[uuid.UUID, uuid.UUID] = dict() + for user_applet_access in res: + applet_owner_map[ + user_applet_access.applet_id + ] = user_applet_access.owner_id + + return applet_owner_map + async def get_bucket_info(self, applet_id: uuid.UUID): query: Query = select( UserWorkspaceSchema.storage_access_key, @@ -147,3 +180,9 @@ async def get_user_answers_db_info( res = db_result.all() return parse_obj_as(list[UserAnswersDBInfo], res) + + async def get_arbitrary_list(self) -> UserWorkspaceSchema: + query: Query = select(UserWorkspaceSchema) + query = query.where(UserWorkspaceSchema.database_uri.isnot(None)) + result: Result = await self._execute(query) + return result.scalars().all() diff --git a/src/apps/workspaces/db/schemas/user_applet_access.py b/src/apps/workspaces/db/schemas/user_applet_access.py index d8e8ada0b20..343aef7ef44 100644 --- a/src/apps/workspaces/db/schemas/user_applet_access.py +++ b/src/apps/workspaces/db/schemas/user_applet_access.py @@ -8,6 +8,7 @@ ForeignKey, Index, String, + Unicode, UniqueConstraint, case, func, @@ -16,7 +17,9 @@ ) from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy_utils import StringEncryptedType +from apps.shared.encryption import get_key from apps.workspaces.domain.constants import UserPinRole from infrastructure.database.base import Base @@ -42,6 +45,8 @@ class UserAppletAccessSchema(Base): ForeignKey("users.id", ondelete="RESTRICT"), nullable=False ) meta = Column(JSONB()) + nickname = Column(StringEncryptedType(Unicode, get_key)) + is_pinned = Column(Boolean(), default=False) __table_args__ = ( Index( @@ -55,11 +60,11 @@ class UserAppletAccessSchema(Base): @hybrid_property def respondent_nickname(self): - return self.meta.get("nickname") + return self.nickname @respondent_nickname.expression # type: ignore[no-redef] def respondent_nickname(cls): - return cls.meta[text("'nickname'")].astext + return cls.nickname @hybrid_property def respondent_secret_id(self): diff --git a/src/apps/workspaces/db/schemas/user_workspace.py b/src/apps/workspaces/db/schemas/user_workspace.py index b89634d24c3..845b7741d08 100644 --- a/src/apps/workspaces/db/schemas/user_workspace.py +++ b/src/apps/workspaces/db/schemas/user_workspace.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, Column, ForeignKey, String, Unicode +from sqlalchemy import Boolean, Column, ForeignKey, Unicode from sqlalchemy_utils import StringEncryptedType from apps.shared.encryption import get_key @@ -18,11 +18,11 @@ class UserWorkspaceSchema(Base): StringEncryptedType(Unicode, get_key), nullable=False, index=True ) is_modified = Column(Boolean(), default=False) - database_uri = Column(String()) - storage_type = Column(String()) - storage_access_key = Column(String()) - storage_secret_key = Column(String()) - storage_region = Column(String()) - storage_url = Column(String(), nullable=True, default=None) - storage_bucket = Column(String(), nullable=True, default=None) + database_uri = Column(StringEncryptedType(Unicode, get_key)) + storage_type = Column(StringEncryptedType(Unicode, get_key)) + storage_access_key = Column(StringEncryptedType(Unicode, get_key)) + storage_secret_key = Column(StringEncryptedType(Unicode, get_key)) + storage_region = Column(StringEncryptedType(Unicode, get_key)) + storage_url = Column(StringEncryptedType(Unicode, get_key)) + storage_bucket = Column(StringEncryptedType(Unicode, get_key)) use_arbitrary = Column(Boolean(), default=False) diff --git a/src/apps/workspaces/domain/user_applet_access.py b/src/apps/workspaces/domain/user_applet_access.py index 7c42ad9c3ae..fd6471f6630 100644 --- a/src/apps/workspaces/domain/user_applet_access.py +++ b/src/apps/workspaces/domain/user_applet_access.py @@ -132,3 +132,8 @@ class RespondentExportData(InternalModel): secret_id: str | None legacy_profile_id: str | None is_manager: bool + + +class RespondentInfoPublic(PublicModel): + nickname: str | None + secret_user_id: str diff --git a/src/apps/workspaces/domain/workspace.py b/src/apps/workspaces/domain/workspace.py index d971ffaf1f9..6852dcec7e0 100644 --- a/src/apps/workspaces/domain/workspace.py +++ b/src/apps/workspaces/domain/workspace.py @@ -2,10 +2,14 @@ import uuid from typing import Optional -from pydantic import Field, validator +from pydantic import Field, root_validator, validator +from sqlalchemy import Unicode +from sqlalchemy.dialects.postgresql.asyncpg import PGDialect_asyncpg +from sqlalchemy_utils import StringEncryptedType from apps.applets.domain.base import Encryption from apps.shared.domain import InternalModel, PublicModel +from apps.shared.encryption import get_key __all__ = [ "PublicWorkspace", @@ -17,8 +21,11 @@ "WorkspaceInfo", "PublicWorkspaceInfo", "WorkspaceArbitrary", + "WorkspaceArbitraryCreate", + "WorkspaceArbitraryFields", ] +from apps.workspaces.constants import StorageType from apps.workspaces.domain.constants import Role @@ -67,13 +74,24 @@ class WorkspaceRespondentDetails(InternalModel): has_individual_schedule: bool = False encryption: WorkspaceAppletEncryption | None = None + @root_validator + def decrypt_nickname(cls, values): + nickname = values.get("respondent_nickname") + if nickname: + nickname = StringEncryptedType( + Unicode, get_key + ).process_result_value(nickname, dialect=PGDialect_asyncpg.name) + values["respondent_nickname"] = str(nickname) + + return values + class WorkspaceRespondent(InternalModel): id: uuid.UUID nicknames: list[str] | None = None secret_ids: list[str] | None = None is_anonymous_respondent: bool - last_seen: datetime.datetime + last_seen: datetime.datetime | None is_pinned: bool = False details: list[WorkspaceRespondentDetails] | None = None @@ -133,14 +151,25 @@ def group_applets(cls, value): return list(applets.values()) +class PublicWorkspaceRespondentDetails(PublicModel): + applet_id: uuid.UUID + applet_display_name: str + applet_image: str | None + access_id: uuid.UUID + respondent_nickname: str | None = None + respondent_secret_id: str | None = None + has_individual_schedule: bool = False + encryption: WorkspaceAppletEncryption | None = None + + class PublicWorkspaceRespondent(PublicModel): id: uuid.UUID nicknames: list[str] | None secret_ids: list[str] | None is_anonymous_respondent: bool - last_seen: datetime.datetime + last_seen: datetime.datetime | None is_pinned: bool = False - details: list[WorkspaceRespondentDetails] | None = None + details: list[PublicWorkspaceRespondentDetails] | None = None class PublicWorkspaceManager(PublicModel): @@ -235,17 +264,67 @@ class AppletRoles(InternalModel): roles: list[Role] -class WorkspaceArbitrary(InternalModel): +class WorkspaceArbitraryFields(InternalModel): + database_uri: str | None = None + storage_type: str | None = None + storage_url: str | None = None + storage_access_key: str | None = None + storage_secret_key: str | None = None + storage_region: str | None = None + storage_bucket: str | None = None + use_arbitrary: bool + + def is_arbitrary_empty(self): + return not any( + [ + self.database_uri, + self.storage_access_key, + self.storage_secret_key, + self.storage_region, + self.storage_type, + self.storage_url, + self.storage_bucket, + self.use_arbitrary, + ] + ) + + @validator("use_arbitrary", always=True, pre=True) + def to_bool(cls, value): + if value is None: + return False + + return value + + +class WorkspaceArbitraryCreate(WorkspaceArbitraryFields): + database_uri: str + storage_secret_key: str + storage_type: StorageType + + @root_validator() + def validate_storage_settings(cls, values): + storage_type = values["storage_type"] + required = [] + if storage_type == StorageType.AWS: + required = ["storage_access_key", "storage_region"] + elif storage_type == StorageType.GCP: + required = ["storage_url", "storage_bucket", "storage_access_key"] + + if required and not all((values[itm] is not None) for itm in required): + raise ValueError( + f"{', '.join(required)} are required " + f"for {storage_type} storage" + ) + + return values + + +class WorkspaceArbitrary(WorkspaceArbitraryFields): id: uuid.UUID database_uri: str - storage_access_key: str storage_secret_key: str - storage_region: str storage_type: str - storage_url: Optional[str] = None - storage_bucket: Optional[str] = None storage_bucket_answer: Optional[str] = None - use_arbitrary: bool class AnswerDbApplet(InternalModel): diff --git a/src/apps/workspaces/errors.py b/src/apps/workspaces/errors.py index 1246f501039..31b4bb2bf2d 100644 --- a/src/apps/workspaces/errors.py +++ b/src/apps/workspaces/errors.py @@ -15,8 +15,12 @@ "AccessDeniedToUpdateOwnAccesses", "RemoveOwnPermissionAccessDenied", "UserAccessAlreadyExists", + "ArbitraryServerSettingsError", + "WorkspaceNotFoundError", ] +from apps.workspaces.domain.workspace import WorkspaceArbitraryFields + class WorkspaceDoesNotExistError(NotFoundError): message = _("Workspace does not exist.") @@ -39,6 +43,7 @@ class WorkspaceFolderManipulationAccessDenied(AccessDeniedError): class UserAppletAccessesNotFound(NotFoundError): + message_is_template: bool = True message = _("No such UserAppletAccess with id={id_}.") @@ -112,7 +117,7 @@ class InvalidAppletIDFilter(FieldError): class UserSecretIdAlreadyExists(ValidationError): - message = _("Secret id already exists.") + message = _("Secret User ID already exists") class UserSecretIdAlreadyExistsInInvitation(ValidationError): @@ -125,3 +130,13 @@ class AnswerCheckAccessDenied(AccessDeniedError): class UserAccessAlreadyExists(ValidationError): message = _("User Access already exists.") + + +class WorkspaceNotFoundError(Exception): + ... + + +class ArbitraryServerSettingsError(Exception): + def __init__(self, data: WorkspaceArbitraryFields, *args, **kwargs): + super().__init__(*args, **kwargs) + self.data = data diff --git a/src/apps/workspaces/router.py b/src/apps/workspaces/router.py index 4951e3b987b..27b564c8575 100644 --- a/src/apps/workspaces/router.py +++ b/src/apps/workspaces/router.py @@ -19,6 +19,7 @@ search_workspace_applets, user_workspaces, workspace_applet_detail, + workspace_applet_get_respondent, workspace_applet_managers_list, workspace_applet_respondent_update, workspace_applet_respondents_list, @@ -145,6 +146,15 @@ }, )(workspace_applet_respondent_update) +router.get( + "/{owner_id}/applets/{applet_id}/respondents/{respondent_id}", + status_code=status.HTTP_200_OK, + responses={ + **DEFAULT_OPENAPI_RESPONSE, + **AUTHENTICATION_ERROR_RESPONSES, + }, +)(workspace_applet_get_respondent) + router.post( "/{owner_id}/applets", description="""This endpoint is used to create a new applet""", diff --git a/src/apps/workspaces/service/user_access.py b/src/apps/workspaces/service/user_access.py index 251cc21deb6..4871b18534c 100644 --- a/src/apps/workspaces/service/user_access.py +++ b/src/apps/workspaces/service/user_access.py @@ -426,3 +426,10 @@ def raise_for_developer_access(email: str | None): email_list = config.settings.logs.get_access_emails() if email not in email_list: raise AccessDeniedError() + + async def get_management_applets( + self, applet_ids: list[uuid.UUID] + ) -> list[uuid.UUID]: + return await UserAppletAccessCRUD(self.session).get_management_applets( + self._user_id, applet_ids + ) diff --git a/src/apps/workspaces/service/user_applet_access.py b/src/apps/workspaces/service/user_applet_access.py index 76863874f43..ecf12327edc 100644 --- a/src/apps/workspaces/service/user_applet_access.py +++ b/src/apps/workspaces/service/user_applet_access.py @@ -7,12 +7,16 @@ from apps.invitations.constants import InvitationStatus from apps.invitations.crud import InvitationCRUD from apps.invitations.domain import InvitationDetailGeneric -from apps.users import User, UserNotFound, UsersCRUD +from apps.shared.exception import NotFoundError +from apps.users import UserNotFound, UsersCRUD from apps.workspaces.db.schemas import UserAppletAccessSchema __all__ = ["UserAppletAccessService"] -from apps.workspaces.domain.user_applet_access import RespondentInfo +from apps.workspaces.domain.user_applet_access import ( + RespondentInfo, + RespondentInfoPublic, +) from apps.workspaces.errors import ( UserAppletAccessNotFound, UserSecretIdAlreadyExists, @@ -39,17 +43,11 @@ async def _get_default_role_meta( return meta - async def _get_default_role_meta_for_anonymous_respondent( - self, user_id: uuid.UUID - ) -> dict: + async def _get_default_role_meta_for_anonymous_respondent(self) -> dict: meta: dict = {} - - user = await UsersCRUD(self.session).get_by_id(user_id) meta.update( secretUserId="Guest Account Submission", - nickname=f"{user.first_name} {user.last_name}", ) - return meta async def add_role( @@ -62,6 +60,7 @@ async def add_role( return UserAppletAccess.from_orm(access_schema) meta = await self._get_default_role_meta(role, user_id) + nickname = meta.pop("nickname", None) access_schema = await UserAppletAccessCRUD(self.session).save( UserAppletAccessSchema( @@ -71,6 +70,7 @@ async def add_role( owner_id=self._user_id, invitor_id=self._user_id, meta=meta, + nickname=nickname, ) ) return UserAppletAccess.from_orm(access_schema) @@ -84,24 +84,29 @@ async def add_role_for_anonymous_respondent( if anonymous_respondent: access_schema = await UserAppletAccessCRUD( self.session - ).get_applet_role_by_user_id( + ).get_applet_role_by_user_id_exist( self._applet_id, anonymous_respondent.id, Role.RESPONDENT ) if access_schema: + if access_schema.is_deleted: + await UserAppletAccessCRUD(self.session).restore( + "id", access_schema.id + ) return UserAppletAccess.from_orm(access_schema) - meta = await self._get_default_role_meta_for_anonymous_respondent( - anonymous_respondent.id, - ) - + meta = await self._get_default_role_meta_for_anonymous_respondent() + owner_access = await UserAppletAccessCRUD( + self.session + ).get_applet_owner(applet_id=self._applet_id) access_schema = await UserAppletAccessCRUD(self.session).save( UserAppletAccessSchema( user_id=anonymous_respondent.id, applet_id=self._applet_id, role=Role.RESPONDENT, - owner_id=self._user_id, + owner_id=owner_access.user_id, invitor_id=self._user_id, meta=meta, + nickname=None, ) ) return UserAppletAccess.from_orm(access_schema) @@ -129,6 +134,7 @@ async def add_role_by_invitation( self.session ).get_applet_owner(invitation.applet_id) meta: dict = dict() + respondent_nickname = invitation.dict().get("nickname", None) if invitation.role in [Role.RESPONDENT, Role.REVIEWER]: meta = invitation.meta.dict(by_alias=True) # type: ignore @@ -138,15 +144,20 @@ async def add_role_by_invitation( invitation.applet_id, self._user_id, manager_included_roles ) - access_schema = await UserAppletAccessCRUD(self.session).save( - UserAppletAccessSchema( + access_schema = await UserAppletAccessCRUD( + self.session + ).upsert_user_applet_access( + schema=UserAppletAccessSchema( user_id=self._user_id, applet_id=invitation.applet_id, role=invitation.role, owner_id=owner_access.user_id, invitor_id=invitation.invitor_id, meta=meta, - ) + nickname=respondent_nickname, + is_deleted=False, + ), + where=UserAppletAccessSchema.soft_exists(exists=False), ) if invitation.role != Role.RESPONDENT: @@ -157,6 +168,7 @@ async def add_role_by_invitation( meta = await self._get_default_role_meta( Role.RESPONDENT, self._user_id ) + nickname = meta.pop("nickname", None) schema = UserAppletAccessSchema( user_id=self._user_id, applet_id=invitation.applet_id, @@ -164,6 +176,7 @@ async def add_role_by_invitation( owner_id=owner_access.user_id, invitor_id=invitation.invitor_id, meta=meta, + nickname=nickname, is_deleted=False, ) @@ -171,17 +184,16 @@ async def add_role_by_invitation( self.session ).upsert_user_applet_access(schema) - return UserAppletAccess.from_orm(access_schema) + return UserAppletAccess.from_orm(access_schema[0]) async def add_role_by_private_invitation(self, role: Role): owner_access = await UserAppletAccessCRUD( self.session ).get_applet_owner(self._applet_id) - user: User = await UsersCRUD(self.session).get_by_id(self._user_id) + if role == Role.RESPONDENT: meta = dict( secretUserId=str(uuid.uuid4()), - nickname=f"{user.first_name} {user.last_name}", ) else: meta = dict() @@ -218,9 +230,11 @@ async def update_meta( if not access: raise UserAppletAccessNotFound() await self._validate_secret_user_id(access.id, schema.secret_user_id) - for key, val in schema.dict(by_alias=True).items(): - access.meta[key] = val - await crud.update_meta_by_access_id(access.id, access.meta) + # change here + access.meta["secretUserId"] = schema.secret_user_id + await crud.update_meta_by_access_id( + access.id, access.meta, nickname=schema.nickname + ) async def _validate_secret_user_id( self, exclude_id: uuid.UUID, secret_id: str @@ -364,3 +378,40 @@ async def get_nickname(self) -> str | None: return await UserAppletAccessCRUD(self.session).get_user_nickname( self._applet_id, self._user_id ) + + async def get_respondent_info( + self, + respondent_id: uuid.UUID, + applet_id: uuid.UUID, + owner_id: uuid.UUID, + ) -> RespondentInfoPublic: + crud = UserAppletAccessCRUD(self.session) + respondent_schema = await crud.get_respondent_by_applet_and_owner( + respondent_id, applet_id, owner_id + ) + if not respondent_schema: + raise NotFoundError() + + if respondent_schema.meta: + return RespondentInfoPublic( + nickname=respondent_schema.nickname, + secret_user_id=respondent_schema.meta.get("secretUserId"), + ) + else: + return RespondentInfoPublic( + nickname=respondent_schema.nickname, secret_user_id=None + ) + + async def has_role(self, role: str) -> bool: + manager_roles = set(Role.managers()) + is_manager = role in manager_roles + current_roles = await UserAppletAccessCRUD( + self.session + ).get_user_roles_to_applet(self._user_id, self._applet_id) + if not is_manager: + return role in current_roles + else: + user_roles = set(current_roles) + return role in manager_roles and bool( + user_roles.intersection(manager_roles) + ) diff --git a/src/apps/workspaces/service/workspace.py b/src/apps/workspaces/service/workspace.py index c2addb95eba..b0dee95e852 100644 --- a/src/apps/workspaces/service/workspace.py +++ b/src/apps/workspaces/service/workspace.py @@ -15,15 +15,19 @@ AnswerDbApplets, WorkspaceApplet, WorkspaceArbitrary, + WorkspaceArbitraryCreate, + WorkspaceArbitraryFields, WorkspaceInfo, WorkspaceManager, WorkspaceRespondent, WorkspaceSearchApplet, ) from apps.workspaces.errors import ( + ArbitraryServerSettingsError, InvalidAppletIDFilter, WorkspaceAccessDenied, WorkspaceDoesNotExistError, + WorkspaceNotFoundError, ) from apps.workspaces.service.check_access import CheckAccessService from apps.workspaces.service.user_access import UserAccessService @@ -92,9 +96,10 @@ async def update_workspace_name( if not user_workspace: user_workspace = await self.create_workspace_from_user(user) if not user_workspace.is_modified and workspace_prefix: - await UserWorkspaceCRUD(self.session).update( - user, - workspace_prefix, + user_workspace.workspace_name = workspace_prefix + await UserWorkspaceCRUD(self.session).update_by_user_id( + user.id, + user_workspace, ) async def get_workspace_respondents( @@ -301,6 +306,25 @@ async def get_arbitrary_info( except ValidationError: return None + async def get_arbitrary_info_by_owner_id( + self, owner_id: uuid.UUID + ) -> WorkspaceArbitrary | None: + schema = await UserWorkspaceCRUD(self.session).get_by_user_id(owner_id) + if not schema: + return None + try: + return WorkspaceArbitrary.from_orm(schema) if schema else None + except ValidationError: + return None + + async def get_arbitraries_map( + self, applet_ids: list[uuid.UUID] + ) -> dict[str | None, list[uuid.UUID]]: + """Returning map {"arbitrary_uri": [applet_ids]}""" + return await UserWorkspaceCRUD( + self.session + ).get_arbitraries_map_by_applet_ids(applet_ids) + async def get_user_answer_db_info(self) -> list[AnswerDbApplets]: db_info = await UserWorkspaceCRUD( self.session @@ -328,3 +352,25 @@ async def get_user_answer_db_info(self) -> list[AnswerDbApplets]: return [default_db_applets, *db_applets_map.values()] return list(db_applets_map.values()) + + async def set_arbitrary_server( + self, data: WorkspaceArbitraryCreate, *, rewrite=False + ): + repository = UserWorkspaceCRUD(self.session) + schema = await repository.get_by_user_id(self._user_id) + if not schema: + raise WorkspaceNotFoundError("Workspace not found") + arbitrary_data = WorkspaceArbitraryFields.from_orm(schema) + if not arbitrary_data.is_arbitrary_empty() and not rewrite: + raise ArbitraryServerSettingsError( + arbitrary_data, "Arbitrary settings are already set" + ) + for k, v in data.dict(by_alias=False).items(): + setattr(schema, k, v) + await repository.update_by_user_id(schema.user_id, schema) + + async def get_arbitrary_list(self) -> list[WorkspaceArbitrary]: + schemas = await UserWorkspaceCRUD(self.session).get_arbitrary_list() + if not schemas: + return [] + return [WorkspaceArbitrary.from_orm(schema) for schema in schemas] diff --git a/src/apps/workspaces/test_workspaces.py b/src/apps/workspaces/test_workspaces.py index 1526cfeb8bc..92e7ed69fce 100644 --- a/src/apps/workspaces/test_workspaces.py +++ b/src/apps/workspaces/test_workspaces.py @@ -1,7 +1,5 @@ from uuid import uuid4 -import pytest - from apps.shared.test import BaseTest from apps.workspaces.domain.constants import Role from infrastructure.database import rollback @@ -63,6 +61,11 @@ class TestWorkspaces(BaseTest): "/workspaces/{owner_id}/respondents/{user_id}/pin" ) workspace_managers_pin = "/workspaces/{owner_id}/managers/{user_id}/pin" + workspace_get_applet_respondent = ( + "/workspaces/{owner_id}" + "/applets/{applet_id}" + "/respondents/{respondent_id}" + ) @rollback async def test_user_workspace_list(self): @@ -253,12 +256,15 @@ async def test_workspace_applets_respondent_update(self): role="respondent", ), ) - assert response.json()["count"] == 3 - assert "New respondent" in response.json()["result"][1]["nicknames"] - assert ( - "f0dd4996-e0eb-461f-b2f8-ba873a674710" - in response.json()["result"][1]["secretIds"] - ) + payload = response.json() + assert payload["count"] == 4 + nicknames = [] + secret_ids = [] + for respondent in payload["result"]: + nicknames += respondent.get("nicknames", []) + secret_ids += respondent.get("secretIds", []) + assert "New respondent" in nicknames + assert "f0dd4996-e0eb-461f-b2f8-ba873a674710" in secret_ids @rollback async def test_wrong_workspace_applets_list(self): @@ -284,18 +290,16 @@ async def test_get_workspace_respondents(self): assert response.status_code == 200, response.json() data = response.json() - assert data["count"] == 3 + assert data["count"] == 5 assert data["result"][0]["nicknames"] assert data["result"][0]["secretIds"] # test search search_params = { "f0dd4996-e0eb-461f-b2f8-ba873a674788": [ - "jane", "b2f8-ba873a674788", ], "f0dd4996-e0eb-461f-b2f8-ba873a674789": [ - "john", "f0dd4996-e0eb-461f-b2f8-ba873a674789", ], } @@ -332,18 +336,18 @@ async def test_get_workspace_applet_respondents(self): assert response.status_code == 200, response.json() data = response.json() - assert data["count"] == 3 + assert data["count"] == 4 assert data["result"][0]["nicknames"] assert data["result"][0]["secretIds"] # test search search_params = { "f0dd4996-e0eb-461f-b2f8-ba873a674788": [ - "jane", + # "jane", "b2f8-ba873a674788", ], "f0dd4996-e0eb-461f-b2f8-ba873a674789": [ - "john", + # "john", "f0dd4996-e0eb-461f-b2f8-ba873a674789", ], } @@ -522,7 +526,6 @@ async def test_set_workspace_manager_accesses(self): assert response.status_code == 200, response.json() # TODO: check from database results - @pytest.mark.skip @rollback async def test_pin_workspace_respondents(self): await self.client.login( @@ -596,7 +599,6 @@ async def test_pin_workspace_respondents(self): ) assert response.json()["result"][-1]["id"] == user_id - @pytest.mark.skip @rollback async def test_pin_workspace_managers(self): await self.client.login( @@ -808,3 +810,51 @@ async def test_applets_flat_list(self): assert response.status_code == 200 assert response.json()["count"] == 1 assert response.json()["result"][0]["type"] == "applet" + + @rollback + async def test_applet_get_respondent_success(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + url = self.workspace_get_applet_respondent.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b2", + respondent_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + ) + res = await self.client.get(url) + assert res.status_code == 200 + body = res.json() + respondent = body.get("result", {}) + assert len(respondent) == 2 + # encrypted "hFywashKw+KlcDPazIy5QHz4AdkTOYkD28Q8+dpeDDA=" nickname + # is 'Mindlogger ChildMindInstitute' + assert respondent["nickname"] == "Mindlogger ChildMindInstitute" + assert respondent["secretUserId"] == ( + "f0dd4996-e0eb-461f-b2f8-ba873a674782" + ) + + @rollback + async def test_applet_get_respondent_not_found(self): + await self.client.login( + self.login_url, "tom@mindlogger.com", "Test1234!" + ) + url = self.workspace_get_applet_respondent.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b2", + respondent_id="7484f34a-3acc-4ee6-8a94-fd7299502fa0", + ) + res = await self.client.get(url) + assert res.status_code == 404 + + @rollback + async def test_applet_get_respondent_access_denied_for_respondent_role( + self, + ): + await self.client.login(self.login_url, "bob@gmail.com", "Test1234!") + url = self.workspace_get_applet_respondent.format( + owner_id="7484f34a-3acc-4ee6-8a94-fd7299502fa1", + applet_id="92917a56-d586-4613-b7aa-991f2c4b15b2", + respondent_id="7484f34a-3acc-4ee6-8a94-fd7299502fa0", + ) + res = await self.client.get(url) + assert res.status_code == 403 diff --git a/src/broker.py b/src/broker.py index dd66f181550..c7d15c9441c 100644 --- a/src/broker.py +++ b/src/broker.py @@ -1,10 +1,13 @@ import taskiq_fastapi from taskiq import InMemoryBroker from taskiq_aio_pika import AioPikaBroker +from taskiq_redis import RedisAsyncResultBackend from config import settings -broker = AioPikaBroker(settings.rabbitmq.url) +broker = AioPikaBroker(settings.rabbitmq.url).with_result_backend( + RedisAsyncResultBackend(settings.redis.url) +) if settings.env == "testing": broker = InMemoryBroker() diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 00000000000..75331a7106f --- /dev/null +++ b/src/cli.py @@ -0,0 +1,24 @@ +import os + +abspath = os.path.abspath(__file__) +dname = os.path.dirname(os.path.dirname(abspath)) +os.chdir(dname) + + +import typer # noqa: E402 + +from apps.activities.commands import activities # noqa: E402 +from apps.answers.commands import convert_assessments # noqa: E402 +from apps.shared.commands import patch # noqa: E402 +from apps.workspaces.commands import arbitrary_server_cli # noqa: E402 + +cli = typer.Typer() +cli.add_typer(arbitrary_server_cli, name="arbitrary") +cli.add_typer(convert_assessments, name="assessments") +cli.add_typer(activities, name="activities") + +cli.add_typer(patch, name="patch") + +if __name__ == "__main__": + # with app context? + cli() diff --git a/src/config/__init__.py b/src/config/__init__.py index f64c23608e7..5dfc8b55643 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -17,7 +17,7 @@ from config.sentry import SentrySettings from config.service import JsonLdConverterSettings, ServiceSettings from config.superuser import SuperAdmin -from config.task import AnswerEncryption +from config.task import AnswerEncryption, AudioFileConvert, ImageConvert # NOTE: Settings powered by pydantic @@ -27,6 +27,7 @@ class Settings(BaseSettings): apps_dir: Path locale_dir: Path default_language: str = "en" + content_length_limit: int | None = 150 * 1024 * 1024 debug: bool = True commit_id: str = "Not assigned" @@ -79,9 +80,15 @@ class Settings(BaseSettings): anonymous_respondent = AnonymousRespondent() task_answer_encryption = AnswerEncryption() + task_audio_file_convert = AudioFileConvert() + task_image_convert = ImageConvert() logs: Logs = Logs() + @property + def uploads_dir(self): + return self.root_dir.parent / "uploads" + class Config: env_nested_delimiter = "__" env_file = ".env" diff --git a/src/config/cdn.py b/src/config/cdn.py index 4c17ed2ec43..c92dbf02976 100644 --- a/src/config/cdn.py +++ b/src/config/cdn.py @@ -19,7 +19,11 @@ class CDNSettings(BaseModel): ttl_signed_urls: int = 3600 gcp_endpoint_url = "https://storage.googleapis.com" + endpoint_url: str | None = None + storage_address: str | None = None @property def url(self): - return f"https://{self.domain}/{{key}}" + if self.domain: + return f"https://{self.domain}/{{key}}" + return f"{self.storage_address}/{self.bucket}/{{key}}" diff --git a/src/config/task.py b/src/config/task.py index ec904b65dec..c0f7dec4bdc 100644 --- a/src/config/task.py +++ b/src/config/task.py @@ -5,3 +5,18 @@ class AnswerEncryption(BaseModel): batch_limit: int = 1000 max_retries: int = 5 retry_timeout: int = 12 * 60 * 60 + + +class AudioFileConvert(BaseModel): + command: str = "ffmpeg -i {fin} -vn -ar 44100 -ac 2 -b:a 192k {fout}" + subprocess_timeout: int = 60 # sec + task_wait_timeout: int = 30 # sec + + +class ImageConvert(BaseModel): + command: str = ( + "convert -strip -interlace JPEG -sampling-factor 4:2:0 " + "-quality 85 -colorspace RGB {fin} {fout}" + ) + subprocess_timeout: int = 20 # sec + task_wait_timeout: int = 10 # sec diff --git a/src/infrastructure/app.py b/src/infrastructure/app.py index a2daed520a8..88d3b02dfb7 100644 --- a/src/infrastructure/app.py +++ b/src/infrastructure/app.py @@ -64,6 +64,13 @@ # Declare your middlewares here middlewares: Iterable[tuple[Type[middlewares_.Middleware], dict]] = ( + ( + middlewares_.ContentLengthLimitMiddleware, + dict( + content_length_limit=settings.content_length_limit, + methods=["POST"], + ), + ), (middlewares_.InternalizationMiddleware, {}), (middlewares_.CORSMiddleware, middlewares_.cors_options), ) diff --git a/src/infrastructure/database/crud.py b/src/infrastructure/database/crud.py index d26a495a06d..7131a147351 100644 --- a/src/infrastructure/database/crud.py +++ b/src/infrastructure/database/crud.py @@ -137,3 +137,12 @@ async def exist_by_key(self, key: str, val: typing.Any) -> bool: query = query.exists() db_result = await self._execute(select(query)) return db_result.scalars().first() or False + + async def restore(self, key: str, val: typing.Any) -> None: + field = getattr(self.schema_class, key) + query: Query = update(self.schema_class) + query = query.where(field == val) + query = query.values(is_deleted=False) + await self._execute(query) + + return None diff --git a/src/infrastructure/database/migrations/versions/2023_11_11_19_02-encrypt_workspace_arbitrary_fields.py b/src/infrastructure/database/migrations/versions/2023_11_11_19_02-encrypt_workspace_arbitrary_fields.py new file mode 100644 index 00000000000..800b495c6b7 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_11_11_19_02-encrypt_workspace_arbitrary_fields.py @@ -0,0 +1,105 @@ +"""Encrypt workspace arbitrary fields + +Revision ID: 0242aa768e9d +Revises: 8c59c7363c67 +Create Date: 2023-11-11 19:02:32.433001 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy import Unicode +from sqlalchemy_utils import StringEncryptedType + +from apps.shared.encryption import get_key + +# revision identifiers, used by Alembic. +revision = "0242aa768e9d" +down_revision = "8c59c7363c67" +branch_labels = None +depends_on = None + + +to_encrypt = [ + "database_uri", + "storage_type", + "storage_access_key", + "storage_secret_key", + "storage_region", + "storage_url", + "storage_bucket", +] +table_name = "users_workspaces" + + +def upgrade() -> None: + conn = op.get_bind() + + _cnd = " or ".join([f"{col} is not null" for col in to_encrypt]) + _cols = ", ".join(to_encrypt) + result = conn.execute( + sa.text(f"SELECT id, {_cols} FROM {table_name} WHERE {_cnd}") + ).all() + + for column_name in to_encrypt: + # Changing the field type for encryption with db models + op.alter_column( + table_name, + column_name, + type_=StringEncryptedType(Unicode, get_key), + existing_type=sa.String(), + ) + + # Encrypt with db models + for row in result: + w_id = row.id + data = {} + for col in to_encrypt: + if val := getattr(row, col): + encrypted_val = StringEncryptedType( + Unicode, get_key + ).process_bind_param(val, dialect=conn.dialect) + data[col] = encrypted_val + if data: + upd_cols = ", ".join([f"{col} = :{col}" for col in data.keys()]) + data["id"] = w_id + conn.execute( + sa.text(f"UPDATE {table_name} SET {upd_cols} WHERE id = :id"), + data, + ) + + +def downgrade() -> None: + conn = op.get_bind() + + _cnd = " or ".join([f"{col} is not null" for col in to_encrypt]) + _cols = ", ".join(to_encrypt) + result = conn.execute( + sa.text(f"SELECT id, {_cols} FROM {table_name} WHERE {_cnd}") + ).all() + + for column_name in to_encrypt: + # Changing the field type for encryption with db models + op.alter_column( + table_name, + column_name, + type_=sa.String(), + existing_type=StringEncryptedType(Unicode, get_key), + ) + + # Encrypt with db models + for row in result: + w_id = row.id + data = {} + for col in to_encrypt: + if encrypted_val := getattr(row, col): + val = StringEncryptedType( + Unicode, get_key + ).process_result_value(encrypted_val, dialect=conn.dialect) + data[col] = val + if data: + upd_cols = ", ".join([f"{col} = :{col}" for col in data.keys()]) + data["id"] = w_id + conn.execute( + sa.text(f"UPDATE {table_name} SET {upd_cols} WHERE id = :id"), + data, + ) diff --git a/src/infrastructure/database/migrations/versions/2023_11_12_21_45-add_field_nickname_to_user_applet_.py b/src/infrastructure/database/migrations/versions/2023_11_12_21_45-add_field_nickname_to_user_applet_.py new file mode 100644 index 00000000000..42ffaafd4b8 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_11_12_21_45-add_field_nickname_to_user_applet_.py @@ -0,0 +1,91 @@ +"""Add field nickname to user_applet_accesses + +Revision ID: a7faad5855cc +Revises: 0242aa768e9d +Create Date: 2023-11-12 21:45:42.636562 + +""" +import json + +import sqlalchemy as sa +from alembic import op +from sqlalchemy_utils.types.encrypted.encrypted_type import StringEncryptedType + +from apps.shared.encryption import get_key + +# revision identifiers, used by Alembic. +revision = "a7faad5855cc" +down_revision = "0242aa768e9d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + result = conn.execute( + sa.text( + "SELECT id, meta FROM user_applet_accesses WHERE role='respondent' and meta is NOT NULL" + ) + ) + op.add_column( + "user_applet_accesses", + sa.Column( + "nickname", + StringEncryptedType(sa.Unicode, get_key), + nullable=True, + ), + ) + for row in result: + pk, meta = row + nickname = meta.get("nickname") + if nickname and nickname != "": + encrypted_field = StringEncryptedType( + sa.Unicode, get_key + ).process_bind_param(nickname, dialect=conn.dialect) + meta["nickname"] = None + conn.execute( + sa.text( + f""" + UPDATE user_applet_accesses + SET nickname = :encrypted_field, meta= :meta + WHERE id = :pk + """ + ), + { + "encrypted_field": encrypted_field, + "meta": json.dumps(meta), + "pk": pk, + }, + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + result = conn.execute( + sa.text( + "SELECT id, nickname, meta FROM user_applet_accesses WHERE role='respondent'" + ) + ) + op.drop_column("user_applet_accesses", "nickname") + for row in result: + pk, nickname, meta = row + if nickname is not None: + decrypted_field = StringEncryptedType( + sa.Unicode, get_key + ).process_result_value(nickname, dialect=conn.dialect) + meta["nickname"] = decrypted_field + conn.execute( + sa.text( + f""" + UPDATE user_applet_accesses + SET meta = :decrypted_field + WHERE id = :pk + """ + ), + {"decrypted_field": json.dumps(meta), "pk": pk}, + ) + # ### end Alembic commands ### diff --git a/src/infrastructure/database/migrations/versions/2023_11_16_16_26-add_nickname_encrypted_field_in_.py b/src/infrastructure/database/migrations/versions/2023_11_16_16_26-add_nickname_encrypted_field_in_.py new file mode 100644 index 00000000000..03bceec3c32 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_11_16_16_26-add_nickname_encrypted_field_in_.py @@ -0,0 +1,91 @@ +"""Add nickname encrypted field in invitation + +Revision ID: 93087521e7ee +Revises: a7faad5855cc +Create Date: 2023-11-16 16:26:19.400694 + +""" +import json + +import sqlalchemy as sa +from alembic import op +from sqlalchemy_utils.types.encrypted.encrypted_type import StringEncryptedType + +from apps.shared.encryption import get_key + +# revision identifiers, used by Alembic. +revision = "93087521e7ee" +down_revision = "a7faad5855cc" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + + conn = op.get_bind() + result = conn.execute( + sa.text( + "SELECT id, meta FROM invitations WHERE role='respondent' and meta is NOT NULL" + ) + ) + op.add_column( + "invitations", + sa.Column( + "nickname", + StringEncryptedType(sa.Unicode, get_key), + nullable=True, + ), + ) + for row in result: + pk, meta = row + nickname = meta.get("nickname") + if nickname and nickname != "": + encrypted_field = StringEncryptedType( + sa.Unicode, get_key + ).process_bind_param(nickname, dialect=conn.dialect) + meta.pop("nickname") + conn.execute( + sa.text( + f""" + UPDATE invitations + SET nickname = :encrypted_field, meta= :meta + WHERE id = :pk + """ + ), + { + "encrypted_field": encrypted_field, + "meta": json.dumps(meta), + "pk": pk, + }, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + conn = op.get_bind() + result = conn.execute( + sa.text( + "SELECT id, nickname, meta FROM invitations WHERE role='respondent'" + ) + ) + op.drop_column("invitations", "nickname") + for row in result: + pk, nickname, meta = row + if nickname is not None: + decrypted_field = StringEncryptedType( + sa.Unicode, get_key + ).process_result_value(nickname, dialect=conn.dialect) + meta["nickname"] = decrypted_field + conn.execute( + sa.text( + f""" + UPDATE invitations + SET meta = :decrypted_field + WHERE id = :pk + """ + ), + {"decrypted_field": json.dumps(meta), "pk": pk}, + ) + # ### end Alembic commands ### diff --git a/src/infrastructure/database/migrations/versions/2023_11_28_11_51-creator_id_to_applet_transfer_status_.py b/src/infrastructure/database/migrations/versions/2023_11_28_11_51-creator_id_to_applet_transfer_status_.py new file mode 100644 index 00000000000..eeb23462eb7 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_11_28_11_51-creator_id_to_applet_transfer_status_.py @@ -0,0 +1,146 @@ +"""Creator id to applet, transfer status, email encryption + +Revision ID: 75c9ca1f506b +Revises: 93087521e7ee +Create Date: 2023-11-28 11:51:29.381770 + +""" +import uuid + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql +from sqlalchemy_utils.types.encrypted.encrypted_type import StringEncryptedType + +from apps.shared.encryption import get_key + +# revision identifiers, used by Alembic. +revision = "75c9ca1f506b" +down_revision = "93087521e7ee" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "applets", + sa.Column("creator_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + op.f("fk_applets_creator_id_users"), + "applets", + "users", + ["creator_id"], + ["id"], + ondelete="RESTRICT", + ) + conn = op.get_bind() + result = conn.execute( + sa.text( + f""" + SELECT DISTINCT a.id, uaa.user_id, a.extra_fields->>'creator' FROM applets a + JOIN user_applet_accesses uaa on a.id = uaa.applet_id + WHERE a.is_deleted is false and uaa.role='owner' + and uaa.is_deleted is false; + """ + ) + ) + for row in result: + pk, owner_id, creator_id = row + if creator_id: + creator_id = uuid.UUID(str(creator_id) + "00000000") + conn.execute( + sa.text( + f""" + UPDATE applets + SET creator_id = :creator_id + WHERE id = :pk + """ + ), + {"creator_id": creator_id, "pk": pk}, + ) + else: + if owner_id: + conn.execute( + sa.text( + f""" + UPDATE applets + SET creator_id = :owner_id + WHERE id = :pk + """ + ), + {"owner_id": owner_id, "pk": pk}, + ) + + op.add_column( + "transfer_ownership", + sa.Column( + "status", sa.String(), server_default="pending", nullable=True + ), + ) + + # encrypt email in transfer_ownership table + result_emails = conn.execute( + sa.text( + "SELECT id, email FROM transfer_ownership WHERE email IS NOT NULL" + ) + ) + op.alter_column( + "transfer_ownership", + "email", + type_=StringEncryptedType(sa.Unicode, get_key), + default=None, + ) + for row in result_emails: + pk, email = row + encrypted_field = StringEncryptedType( + sa.Unicode, get_key + ).process_bind_param(email, dialect=conn.dialect) + conn.execute( + sa.text( + f""" + UPDATE transfer_ownership + SET email = :encrypted_field + WHERE id = :pk + """ + ), + {"encrypted_field": encrypted_field, "pk": pk}, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("transfer_ownership", "status") + op.drop_constraint( + op.f("fk_applets_creator_id_users"), "applets", type_="foreignkey" + ) + op.drop_column("applets", "creator_id") + + # decrypt email in transfer_ownership table + conn = op.get_bind() + result_emails = conn.execute( + sa.text( + "SELECT id, email FROM transfer_ownership WHERE email IS NOT NULL" + ) + ) + op.alter_column( + "transfer_ownership", "email", type_=sa.String(), default=None + ) + for row in result_emails: + pk, email = row + decrypted_field = StringEncryptedType( + sa.Unicode, get_key + ).process_result_value(email, dialect=conn.dialect) + conn.execute( + sa.text( + f""" + UPDATE transfer_ownership + SET email = :decrypted_field + WHERE id = :pk + """ + ), + {"decrypted_field": decrypted_field, "pk": pk}, + ) + # ### end Alembic commands ### diff --git a/src/infrastructure/database/migrations/versions/2023_11_29_17_08-cron_removing_expired_blacklisted_tokens.py b/src/infrastructure/database/migrations/versions/2023_11_29_17_08-cron_removing_expired_blacklisted_tokens.py new file mode 100644 index 00000000000..dddf69081ab --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_11_29_17_08-cron_removing_expired_blacklisted_tokens.py @@ -0,0 +1,42 @@ +"""Cron removing expired blacklisted tokens + +Revision ID: 69b1dfaf3c0d +Revises: 75c9ca1f506b +Create Date: 2023-11-29 17:08:41.800439 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy import text + +from config import settings + +# revision identifiers, used by Alembic. +revision = "69b1dfaf3c0d" +down_revision = "75c9ca1f506b" +branch_labels = None +depends_on = None + +task_name = "clear_token_blacklist" +schedule = "0 9 * * *" +query = text( + "delete from token_blacklist " "where \"exp\" < now() at time zone 'utc'" +) + + +def upgrade() -> None: + if settings.env != "testing": + op.execute( + text( + f"SELECT cron.schedule(:task_name, :schedule, $${query}$$);" + ).bindparams(task_name=task_name, schedule=schedule) + ) + + +def downgrade() -> None: + if settings.env != "testing": + op.execute( + text(f"SELECT cron.unschedule(:task_name);").bindparams( + task_name=task_name + ) + ) diff --git a/src/infrastructure/database/migrations/versions/2023_12_04_15_45-remove_nickname_from_guest_account.py b/src/infrastructure/database/migrations/versions/2023_12_04_15_45-remove_nickname_from_guest_account.py new file mode 100644 index 00000000000..50c076b27eb --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_12_04_15_45-remove_nickname_from_guest_account.py @@ -0,0 +1,35 @@ +"""Remove nickname from guest account + +Revision ID: 63a2a290c7e6 +Revises: 69b1dfaf3c0d +Create Date: 2023-12-04 15:45:11.543448 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "63a2a290c7e6" +down_revision = "69b1dfaf3c0d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + result = conn.execute( + sa.text( + f""" + UPDATE user_applet_accesses SET nickname=NULL + WHERE user_id in ( + SELECT id + FROM users + WHERE is_anonymous_respondent=TRUE + ); + """ + ) + ) + + +def downgrade() -> None: + pass diff --git a/src/infrastructure/database/migrations/versions/2023_12_06_13_47-add_performance_task_type_column.py b/src/infrastructure/database/migrations/versions/2023_12_06_13_47-add_performance_task_type_column.py new file mode 100644 index 00000000000..29e4c8c11d4 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_12_06_13_47-add_performance_task_type_column.py @@ -0,0 +1,79 @@ +"""Add performance_task_type to the table + +Revision ID: 186481f0c0cc +Revises: 63a2a290c7e6 +Create Date: 2023-12-06 13:47:49.694746 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "186481f0c0cc" +down_revision = "63a2a290c7e6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "activities", + sa.Column( + "performance_task_type", sa.String(length=255), nullable=True + ), + ) + op.add_column( + "activity_histories", + sa.Column( + "performance_task_type", sa.String(length=255), nullable=True + ), + ) + conn = op.get_bind() + conn.execute( + sa.text( + """ + with + performance as ( + select distinct + activity_id, + case when response_type in ('ABTrails', 'flanker') then response_type + else config->>'user_input_type' + end as performance_task_type + from activity_items + where response_type in ('ABTrails', 'flanker') + or response_type = 'stabilityTracker' and config->>'user_input_type' in ('touch', 'gyroscope') + ) + update activities set performance_task_type = performance.performance_task_type + from performance + where id = performance.activity_id + """ + ) + ) + conn.execute( + sa.text( + """ + with + performance as ( + select distinct + activity_id, + case when response_type in ('ABTrails', 'flanker') then response_type + else config->>'user_input_type' + end as performance_task_type + from activity_item_histories + where response_type in ('ABTrails', 'flanker') + or response_type = 'stabilityTracker' and config->>'user_input_type' in ('touch', 'gyroscope') + ) + update activity_histories set performance_task_type = performance.performance_task_type + from performance + where id_version = performance.activity_id + """ + ) + ) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("activity_histories", "performance_task_type") + op.drop_column("activities", "performance_task_type") + # ### end Alembic commands ### diff --git a/src/infrastructure/database/migrations/versions/2023_12_08_19_55-add_assessment_activity_version_id_on_.py b/src/infrastructure/database/migrations/versions/2023_12_08_19_55-add_assessment_activity_version_id_on_.py new file mode 100644 index 00000000000..36b0be1ba1d --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_12_08_19_55-add_assessment_activity_version_id_on_.py @@ -0,0 +1,40 @@ +"""add assessment activity version id on answers item + +Revision ID: 60528d410fd1 +Revises: 8c59c7363c67 +Create Date: 2023-11-13 19:55:57.797942 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "60528d410fd1" +down_revision = "186481f0c0cc" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "answers_items", + sa.Column("assessment_activity_id", sa.Text(), nullable=True), + ) + op.create_index( + op.f("ix_answers_items_assessment_activity_id"), + "answers_items", + ["assessment_activity_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_answers_items_assessment_activity_id"), + table_name="answers_items", + ) + op.drop_column("answers_items", "assessment_activity_id") + # ### end Alembic commands ### diff --git a/src/infrastructure/database/migrations/versions/2023_12_13_07_14-update_created_at_from_updated_at.py b/src/infrastructure/database/migrations/versions/2023_12_13_07_14-update_created_at_from_updated_at.py new file mode 100644 index 00000000000..6b4de7a7560 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_12_13_07_14-update_created_at_from_updated_at.py @@ -0,0 +1,79 @@ +"""Update created_at from updated_at + +Revision ID: 87d3c8a8de55 +Revises: 60528d410fd1 +Create Date: 2023-12-13 07:14:28.322481 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "87d3c8a8de55" +down_revision = "60528d410fd1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + conn.execute( + sa.text( + """ + update activity_histories + set created_at = updated_at + where migrated_date is not null + """ + ) + ) + conn.execute( + sa.text( + """ + update activity_item_histories + set created_at = updated_at + where migrated_date is not null + """ + ) + ) + + +def downgrade() -> None: + conn = op.get_bind() + conn.execute( + sa.text( + """ + with + applets_created_at as ( + select + id, + created_at + from applets + where migrated_date is not null + ) + update activity_histories + set created_at = applets_created_at.created_at + from applets_created_at + where applets_created_at.id::text = split_part(applet_id, '_', 1) + and migrated_date is not null + """ + ) + ) + conn.execute( + sa.text( + """ + with + activities_created_at as ( + select distinct + id_version, + created_at + from activity_histories + where migrated_date is not null + ) + update activity_item_histories + set created_at = activities_created_at.created_at + from activities_created_at + where activities_created_at.id_version = activity_id + and migrated_date is not null + """ + ) + ) diff --git a/src/infrastructure/database/migrations/versions/2023_12_21_10_30-create_default_theme.py b/src/infrastructure/database/migrations/versions/2023_12_21_10_30-create_default_theme.py new file mode 100644 index 00000000000..3958cf97b76 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_12_21_10_30-create_default_theme.py @@ -0,0 +1,79 @@ +"""Create default theme + +Revision ID: b993457637ad +Revises: 87d3c8a8de55 +Create Date: 2023-12-21 10:30:18.107063 + +""" +import datetime +import uuid + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import Boolean, String, delete, select +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import column, table + +# revision identifiers, used by Alembic. +revision = "b993457637ad" +down_revision = "87d3c8a8de55" +branch_labels = None +depends_on = None + +THEMES_TABLE = table( + "themes", + column("id", UUID), + column("name", String), + column("primary_color", String), + column("secondary_color", String), + column("tertiary_color", String), + column("creator_id", UUID), + column("is_default", Boolean), + column("public", Boolean), + column("allow_rename", Boolean), +) +FIRST_DEFAULT_THEME = { + THEMES_TABLE.c.name: "First default theme", + THEMES_TABLE.c.primary_color: "#FFFFFF", + THEMES_TABLE.c.secondary_color: "#000000", + THEMES_TABLE.c.tertiary_color: "#AAAAAA", + THEMES_TABLE.c.is_default: True, + THEMES_TABLE.c.public: True, + THEMES_TABLE.c.allow_rename: True, +} + + +def upgrade() -> None: + op.alter_column( + "themes", "creator_id", existing_type=UUID(), nullable=True + ) + + conn = op.get_bind() + count_themes_query = select( + select(THEMES_TABLE).where(THEMES_TABLE.c.is_default == True).exists() + ) + is_default_themes_exists = conn.execute(count_themes_query).first()[0] + if not is_default_themes_exists: + first_theme = { + f"{k.name}": v for (k, v) in FIRST_DEFAULT_THEME.items() + } + first_theme[f"{THEMES_TABLE.c.id.name}"] = f"{uuid.uuid4()}" + op.bulk_insert(THEMES_TABLE, rows=[first_theme]) + + +def downgrade() -> None: + conn = op.get_bind() + first_theme_where = [k == v for (k, v) in FIRST_DEFAULT_THEME.items()] + first_default_theme_in_db: tuple | None = conn.execute( + select(column("id", UUID)) + .select_from(THEMES_TABLE) + .where(*first_theme_where) + ).first() + + if first_default_theme_in_db: + theme_id = first_default_theme_in_db[0] + conn.execute(delete(THEMES_TABLE).where(THEMES_TABLE.c.id == theme_id)) + + op.alter_column( + "themes", "creator_id", existing_type=UUID(), nullable=False + ) diff --git a/src/infrastructure/database/migrations/versions/2023_12_21_17_25-userid_emails_to_userid_uuid.py b/src/infrastructure/database/migrations/versions/2023_12_21_17_25-userid_emails_to_userid_uuid.py new file mode 100644 index 00000000000..353fb368a32 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_12_21_17_25-userid_emails_to_userid_uuid.py @@ -0,0 +1,44 @@ +"""UserId emails to UserId uuid + +Revision ID: 5130eba9f698 +Revises: 87d3c8a8de55 +Create Date: 2023-12-21 17:25:42.256018 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5130eba9f698" +down_revision = "b993457637ad" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + conn.execute( + sa.text( + """ + with + uuid_email as ( + select distinct + email, + id::text + from users + ) + update notification_logs + set user_id = ue.id + from uuid_email ue + where ue.email = encode(sha224(user_id::bytea), 'hex'); + """ + ) + ) + # Delete non-existing emails + conn.execute( + sa.text("""delete from notification_logs where user_id like '%@%'""") + ) + + +def downgrade() -> None: + pass diff --git a/src/infrastructure/database/migrations/versions/2023_12_26_14_51-add_user_id_column_to_invitations.py b/src/infrastructure/database/migrations/versions/2023_12_26_14_51-add_user_id_column_to_invitations.py new file mode 100644 index 00000000000..2c1a6390595 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_12_26_14_51-add_user_id_column_to_invitations.py @@ -0,0 +1,42 @@ +"""Add user_id column to invitations + +Revision ID: 3fb536a58c94 +Revises: 5130eba9f698 +Create Date: 2023-12-26 14:51:42.568199 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "3fb536a58c94" +down_revision = "5130eba9f698" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "invitations", + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + op.f("fk_invitations_user_id_users"), + "invitations", + "users", + ["user_id"], + ["id"], + ondelete="RESTRICT", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("fk_invitations_user_id_users"), "invitations", type_="foreignkey" + ) + op.drop_column("invitations", "user_id") + # ### end Alembic commands ### diff --git a/src/infrastructure/database/migrations_arbitrary/env.py b/src/infrastructure/database/migrations_arbitrary/env.py index edf602d7ac8..449be84d145 100644 --- a/src/infrastructure/database/migrations_arbitrary/env.py +++ b/src/infrastructure/database/migrations_arbitrary/env.py @@ -1,13 +1,18 @@ import asyncio +import logging import os +import uuid +from logging import getLogger from logging.config import fileConfig from alembic import context from alembic.config import Config -from sqlalchemy import MetaData, engine_from_config, pool, text +from sqlalchemy import MetaData, Unicode, engine_from_config, pool, text from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlalchemy_utils import StringEncryptedType +from apps.shared.encryption import get_key from config import settings from infrastructure.database.migrations.base import Base @@ -20,47 +25,58 @@ # Override alembic.ini option config.set_main_option("sqlalchemy.url", settings.database.url) -arbitrary_urls = [] +arbitrary_data = [] + +migration_log = getLogger("alembic.arbitrary") +migration_log.level = logging.INFO async def get_all_servers(connection): try: query = text( """ - SELECT uw.database_uri + SELECT uw.database_uri, uw.user_id FROM users_workspaces as uw - WHERE uw.database_uri is not null + WHERE uw.database_uri is not null and uw.database_uri <> '' """ ) rows = await connection.execute(query) - urls = list(map(lambda r: r[0], rows.fetchall())) + rows = rows.fetchall() + data = [] + for row in rows: + url = StringEncryptedType(Unicode, get_key).process_result_value( + row[0], dialect=connection.dialect + ) + data.append((url, row[1])) + except Exception as ex: print(ex) - urls = [] + data = [] if os.environ.get("PYTEST_APP_TESTING"): arbitrary_db_name = os.environ["ARBITRARY_DB"] url = settings.database.url.replace("/test", f"/{arbitrary_db_name}") - urls.append(url) - return urls + data.append((url, uuid.uuid4())) + return data async def get_urls(): - global arbitrary_urls + global arbitrary_data connectable = create_async_engine(url=settings.database.url) async with connectable.connect() as connection: - arbitrary_urls = await get_all_servers(connection) + arbitrary_data = await get_all_servers(connection) await connectable.dispose() async def migrate_arbitrary(): - global arbitrary_urls + global arbitrary_data arbitrary_meta = MetaData() arbitrary_tables = [ Base.metadata.tables["answers"], Base.metadata.tables["answers_items"], ] arbitrary_meta.tables = arbitrary_tables - for url in arbitrary_urls: + for url, owner_id in arbitrary_data: + migration_log.info(f"Migrating server for owner: {owner_id}") config.set_main_option("sqlalchemy.url", url) connectable = AsyncEngine( engine_from_config( diff --git a/src/infrastructure/database/migrations_arbitrary/versions/2023_11_13_19_55-add_assessment_activity_version_id_on_.py b/src/infrastructure/database/migrations_arbitrary/versions/2023_11_13_19_55-add_assessment_activity_version_id_on_.py new file mode 100644 index 00000000000..c10475d2b02 --- /dev/null +++ b/src/infrastructure/database/migrations_arbitrary/versions/2023_11_13_19_55-add_assessment_activity_version_id_on_.py @@ -0,0 +1,40 @@ +"""add assessment activity version id on answers item + +Revision ID: 60528d410fd1 +Revises: 8c59c7363c67 +Create Date: 2023-11-13 19:55:57.797942 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "60528d410fd1" +down_revision = "016848d34c04" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "answers_items", + sa.Column("assessment_activity_id", sa.Text(), nullable=True), + ) + op.create_index( + op.f("ix_answers_items_assessment_activity_id"), + "answers_items", + ["assessment_activity_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_answers_items_assessment_activity_id"), + table_name="answers_items", + ) + op.drop_column("answers_items", "assessment_activity_id") + # ### end Alembic commands ### diff --git a/src/infrastructure/dependency/cdn.py b/src/infrastructure/dependency/cdn.py index 0bf7cfee93e..404afdb11f6 100644 --- a/src/infrastructure/dependency/cdn.py +++ b/src/infrastructure/dependency/cdn.py @@ -18,6 +18,7 @@ async def get_media_bucket() -> CDNClient: config = CdnConfig( + endpoint_url=settings.cdn.endpoint_url, region=settings.cdn.region, bucket=settings.cdn.bucket, secret_key=settings.cdn.secret_key, @@ -30,6 +31,9 @@ async def get_media_bucket() -> CDNClient: async def get_log_bucket() -> CDNClient: config = CdnConfig( + endpoint_url=settings.cdn.endpoint_url, + access_key=settings.cdn.access_key, + secret_key=settings.cdn.secret_key, region=settings.cdn.region, bucket=settings.cdn.bucket_answer, ttl_signed_urls=settings.cdn.ttl_signed_urls, diff --git a/src/infrastructure/http/execeptions.py b/src/infrastructure/http/execeptions.py index b395dd9ffeb..ab9cd4703f9 100644 --- a/src/infrastructure/http/execeptions.py +++ b/src/infrastructure/http/execeptions.py @@ -1,4 +1,3 @@ -import logging import traceback from fastapi.encoders import jsonable_encoder @@ -9,8 +8,7 @@ from apps.shared.domain import ErrorResponse, ErrorResponseMulti from apps.shared.exception import BaseError - -logger = logging.getLogger("mindlogger_backend") +from infrastructure.logger import logger def custom_base_errors_handler(_: Request, error: BaseError) -> JSONResponse: @@ -25,8 +23,6 @@ def custom_base_errors_handler(_: Request, error: BaseError) -> JSONResponse: ] ) - logger.error(response) - return JSONResponse( response.dict(by_alias=True), status_code=error.status_code, @@ -41,7 +37,11 @@ def python_base_error_handler(_: Request, error: Exception) -> JSONResponse: result=[ErrorResponse(message=f"Unhandled error: {error_message}")] ) - logger.error(response) + # NOTE: replace error with warning because application can still work + # Also it stops sending duplicate of error to the sentry. + # (Default logging level for sending events to the sentry is ERROR. + # It means that each logger.error sends additional event to the sentry). + logger.warning(response) return JSONResponse( content=jsonable_encoder(response.dict(by_alias=True)), @@ -64,8 +64,6 @@ def pydantic_validation_errors_handler( ] ) - logger.error(response) - return JSONResponse( content=jsonable_encoder(response.dict(by_alias=True)), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/src/infrastructure/utility/cdn_client.py b/src/infrastructure/utility/cdn_client.py index 3b337641fcd..2b7cadb3484 100644 --- a/src/infrastructure/utility/cdn_client.py +++ b/src/infrastructure/utility/cdn_client.py @@ -31,6 +31,7 @@ def configure_client(self, config): if config.access_key and config.secret_key: return boto3.client( "s3", + endpoint_url=config.endpoint_url, region_name=config.region, aws_access_key_id=config.access_key, aws_secret_access_key=config.secret_key, diff --git a/src/infrastructure/utility/cdn_config.py b/src/infrastructure/utility/cdn_config.py index dd4654cdb27..30b4c2742a4 100644 --- a/src/infrastructure/utility/cdn_config.py +++ b/src/infrastructure/utility/cdn_config.py @@ -2,6 +2,7 @@ class CdnConfig(BaseSettings): + endpoint_url: str | None = None region: str | None bucket: str | None secret_key: str | None diff --git a/src/middlewares/__init__.py b/src/middlewares/__init__.py index 5b2f27e3764..9b1922cc1af 100644 --- a/src/middlewares/__init__.py +++ b/src/middlewares/__init__.py @@ -1,3 +1,6 @@ +from middlewares.content_length import ( # noqa: F401, F403 + ContentLengthLimitMiddleware, +) from middlewares.cors import * # noqa: F401, F403 from middlewares.domain import * # noqa: F401, F403 from middlewares.internalization import * # noqa: F401, F403 diff --git a/src/middlewares/content_length.py b/src/middlewares/content_length.py new file mode 100644 index 00000000000..222a65fe300 --- /dev/null +++ b/src/middlewares/content_length.py @@ -0,0 +1,49 @@ +from fastapi import HTTPException +from starlette import status +from starlette.types import ASGIApp + + +class ContentLengthLimitMiddleware: + def __init__( + self, + app: ASGIApp, + content_length_limit: int | None = None, + methods: list | None = None, + ): + self.app = app + self.content_length_limit = content_length_limit + self.methods = methods + + def method_matches(self, method): + if self.methods: + return method in self.methods + return True + + async def __call__(self, scope, receive, send): + if not ( + scope["type"] == "http" + and self.method_matches(scope.get("method")) + and self.content_length_limit is not None + ): + await self.app(scope, receive, send) + return + + def _receiver(): + read_length: int = 0 + + async def _receive(): + nonlocal read_length, receive + + message = await receive() + if message["type"] == "http.request": + read_length += len(message.get("body", b"")) + if read_length > self.content_length_limit: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE # noqa: E501 + ) + return message + + return _receive + + _receive = _receiver() + await self.app(scope, _receive, send) diff --git a/src/middlewares/exception.py b/src/middlewares/exception.py deleted file mode 100644 index 3dbec1a59bc..00000000000 --- a/src/middlewares/exception.py +++ /dev/null @@ -1,75 +0,0 @@ -import gettext -import logging -import traceback - -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse -from pydantic import ValidationError -from starlette import status -from starlette.requests import Request - -from apps.shared.domain.response.errors import ( - ErrorResponse, - ErrorResponseMulti, -) -from apps.shared.exception import BaseError -from config import settings - -logger = logging.getLogger("mindlogger_backend") -gettext.bindtextdomain(gettext.textdomain(), settings.locale_dir) - - -def _custom_base_errors_handler(_: Request, error: BaseError) -> JSONResponse: - """This function is called if the BaseError was raised.""" - response = ErrorResponseMulti( - result=[ - ErrorResponse( - message=error.error, - type=error.type, - path=getattr(error, "path", []), - ) - ] - ) - - return JSONResponse( - response.dict(by_alias=True), - status_code=error.status_code, - ) - - -def _python_base_error_handler(_: Request, error: Exception) -> JSONResponse: - """This function is called if the Exception was raised.""" - - error_message = "".join(traceback.format_tb(error.__traceback__)) - response = ErrorResponseMulti( - result=[ErrorResponse(message=f"Unhandled error: {error_message}")] - ) - - logger.error(response) - - return JSONResponse( - content=jsonable_encoder(response.dict(by_alias=True)), - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - ) - - -def _pydantic_validation_errors_handler( - _: Request, error: ValidationError -) -> JSONResponse: - """This function is called if the Pydantic validation error was raised.""" - - response = ErrorResponseMulti( - result=[ - ErrorResponse( - message=err["msg"], - path=list(err["loc"]), - type=err["type"], - ) - for err in error.errors() - ] - ) - - return JSONResponse( - content=jsonable_encoder(response.dict(by_alias=True)), - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - ) diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d