diff --git a/CHANGE-LOG.md b/CHANGE-LOG.md index 0335c5bc..34adf325 100644 --- a/CHANGE-LOG.md +++ b/CHANGE-LOG.md @@ -1,5 +1,48 @@ # OpenStudyBuilder (OSB) Commits changelog +## V 2.9 + +New Features and Enhancements +============ + +### Fixes and Enhancements + +- Many fixes in the Standard data model endpoints (SDTM). Non-exhaustive list : unstable or wrong ordering of elements, missing domains, duplicates in the results,... +Libraries are now managed in a way that truly enables having multiple sponsor libraries +- The ICH M11 preview under Studies, View Specifications is updated to the final M11 19 November 2025 version, with some improved display options. + +### New Feature + +- Added a display page for Sponsor Models. API to retrieve information are now in a first working state +- When adding a new study, you can now choose “Create a study with default settings.” This option copies study visits, epochs, and activities from a template study. This option is available only if a template study has been defined previously in Library, Admin Definitions, Template Study. Please note, that the studies that can be used as templates and are listed in the Template Study list are locked. +- E2E tests for "archived" library implemented. + +### Performance Improvements +- Faster batch creation of study activities when selecting 'Select from Library' path. +- Faster editing of a single study activity. +- Faster batch editing of study activities in SoA, e.g. moving a couple of study activities to different SoA Group and assigning a few schedules to each study activity. + +### End-to-End Automated test enhancements +- Various code improvements to ensure easier maintenance and overall tests stability. +- Administration > Feature Flags: Adjusted tests to the changes in the Feature Flags page. +- Library > Concepts > Activities > Activity Instances: Implemented tests for Events Activity Instance Class. +- Library > Concepts > Activities > Activity Instances: Adjusted tests to the changes in the Overview Page. +- Library > Concepts > Activities > Activity: Implemented tests for checking Study Linkage on the Activity Placeholder Overview Page. +- Library > Data Collection Standards > CRF Builder > CRF Items: Improved and implemented tests for Activity Instance linkage. +- Library > Data Collection Standards > CRF Builder > CRF Tree: Improved and implemented tests for Activity Instance linkage. +- Studies > Define Study > Study Activities > Schedule of Activities > Protocol - Lab Table: Defined and Implemented tests. +- Studies > Define Study > Study Interventions: Refactorization of Study Compounds and Study Compounds Dosings tests to use API calls for data creation. + +Solved Bugs +============ + +### Studies + + **> Define Studies > Activity Instances** + +- Fix StudyActivityInstance creation when ActivityPlaceholder is exchanged for multiple StudyActivities + + ## V 2.8 New Features and Enhancements diff --git a/clinical-mdr-api/.azuredevops/dependabot.yml b/clinical-mdr-api/.azuredevops/dependabot.yml new file mode 100644 index 00000000..b5618ba0 --- /dev/null +++ b/clinical-mdr-api/.azuredevops/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + time: "09:00" + timezone: "Europe/Paris" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "python" + groups: + dev-dependencies: + dependency-type: "development" + update-types: + - "minor" + - "patch" + prod-dependencies: + dependency-type: "production" + update-types: + - "minor" + - "patch" diff --git a/clinical-mdr-api/.claude/rules/code-standards.md b/clinical-mdr-api/.claude/rules/code-standards.md index ecf1ae0e..55ce1fdd 100644 --- a/clinical-mdr-api/.claude/rules/code-standards.md +++ b/clinical-mdr-api/.claude/rules/code-standards.md @@ -8,6 +8,7 @@ - **Naming**: Follow PEP 8, public API = no leading underscore - **Disabled Pylint Checks**: Missing docstrings, fixme, too-few-public-methods, too-many-ancestors, cyclic-import, etc. (see `pyproject.toml`) - **Descriptive variable names over clever abbreviations** +- **Imports always at the top of the file** - never use inline/local imports inside functions or test methods ## FastAPI Best Practices diff --git a/clinical-mdr-api/.claude/rules/testing.md b/clinical-mdr-api/.claude/rules/testing.md index 86be59e5..0a808afb 100644 --- a/clinical-mdr-api/.claude/rules/testing.md +++ b/clinical-mdr-api/.claude/rules/testing.md @@ -21,4 +21,11 @@ paths: - Shared utilities in `clinical_mdr_api/tests/utils/` - Integration tests run in parallel with `-n 4` flag +## Writing Tests + +- **Use real workflows, not shortcuts** - tests should trigger the actual code paths users hit, not manually arrange the end state +- **Use `TestUtils`** - check `clinical_mdr_api/tests/integration/utils/utils.py` before writing test setup from scratch +- **Verify the test fails without the fix** - before committing a bug fix test, confirm it actually fails on the old code +- **Search for existing test patterns** - look at similar test files in the same directory before writing a new test + diff --git a/clinical-mdr-api/Pipfile b/clinical-mdr-api/Pipfile index d5917cc0..74d70744 100644 --- a/clinical-mdr-api/Pipfile +++ b/clinical-mdr-api/Pipfile @@ -18,7 +18,7 @@ python-docx = "~=1.1.2" colour = "~=0.1.5" authlib = "~=1.6.5" pyjwt = "~=2.12.0" -cryptography = "~=46.0.5" +cryptography = "~=46.0" httpx = "~=0.27.2" starlette-context = "==0.4.0" python-multipart = "~=0.0.12" @@ -44,7 +44,7 @@ pyasn1 = "~=0.6.3" pytest = "~=8.4.1" pytest-bdd = "~=7.3.0" pytest-cov = "~=6.0.0" -pylint = "~=3.3.1" +pylint = "~=4.0.5" rich = "~=14.0.0" flake8 = "~=7.1.1" pep8 = "~=1.7.1" @@ -91,7 +91,7 @@ format = """sh -c " && python -m black clinical_mdr_api consumer_api common extensions \ " """ -audit = "python -m pip_audit --ignore-vuln CVE-2026-4539" +audit = "python -m pip_audit --ignore-vuln CVE-2026-4539 --ignore-vuln GHSA-jj8c-mmj3-mmgv --ignore-vuln CVE-2025-71176 --ignore-vuln CVE-2026-40347" openapi = "python generate_openapi_json.py" schemathesis = """ schemathesis diff --git a/clinical-mdr-api/Pipfile.lock b/clinical-mdr-api/Pipfile.lock index 6ab72d58..79b44291 100644 --- a/clinical-mdr-api/Pipfile.lock +++ b/clinical-mdr-api/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4695f36491a01fd3b75aa5cf4050ce72043a343738c3156f07853956df03a013" + "sha256": "b2051a7730892c800fd66b79ed2c1360d6d739b34dbb6e446f1e771cafa6e901" }, "pipfile-spec": 6, "requires": { @@ -307,146 +307,146 @@ }, "charset-normalizer": { "hashes": [ - "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", - "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", - "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", - "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", - "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", - "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", - "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", - "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", - "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", - "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8", - "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264", - "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", - "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", - "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", - "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", - "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", - "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa", - "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", - "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", - "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297", - "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", - "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e", - "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", - "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8", - "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", - "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", - "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", - "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", - "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", - "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", - "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7", - "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", - "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b", - "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", - "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687", - "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9", - "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14", - "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", - "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", - "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", - "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", - "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a", - "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", - "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", - "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", - "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", - "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", - "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", - "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", - "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532", - "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", - "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae", - "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", - "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64", - "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", - "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", - "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", - "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", - "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", - "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", - "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", - "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", - "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597", - "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", - "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", - "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", - "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54", - "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", - "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", - "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4", - "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", - "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", - "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", - "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", - "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", - "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", - "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", - "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", - "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", - "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", - "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", - "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", - "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", - "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", - "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", - "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", - "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc", - "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", - "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", - "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", - "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", - "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", - "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", - "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", - "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237", - "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", - "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778", - "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", - "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", - "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", - "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", - "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f", - "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5", - "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611", - "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", - "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", - "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", - "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", - "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", - "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e", - "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", - "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", - "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", - "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", - "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", - "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe", - "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", - "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17", - "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833", - "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", - "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", - "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", - "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2", - "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", - "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982", - "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", - "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", - "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104", - "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659" + "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", + "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", + "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", + "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", + "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", + "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", + "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", + "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", + "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", + "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", + "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01", + "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", + "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", + "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", + "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", + "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83", + "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5", + "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", + "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", + "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", + "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", + "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", + "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", + "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", + "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", + "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207", + "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", + "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734", + "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", + "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", + "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", + "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", + "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", + "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", + "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", + "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", + "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", + "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", + "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", + "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", + "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", + "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", + "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", + "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", + "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", + "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", + "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", + "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", + "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", + "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", + "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", + "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", + "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", + "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", + "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", + "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", + "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", + "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", + "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", + "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", + "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", + "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", + "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", + "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", + "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", + "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", + "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545", + "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706", + "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", + "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", + "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", + "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", + "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", + "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", + "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", + "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", + "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", + "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", + "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", + "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", + "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", + "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", + "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", + "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", + "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0", + "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686", + "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", + "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", + "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", + "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", + "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", + "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60", + "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", + "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", + "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", + "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", + "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", + "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f", + "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", + "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", + "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", + "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", + "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", + "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", + "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", + "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", + "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", + "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3", + "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7", + "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", + "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", + "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", + "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", + "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259", + "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", + "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", + "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30", + "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", + "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", + "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", + "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", + "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", + "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", + "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", + "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", + "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", + "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", + "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", + "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464" ], "markers": "python_version >= '3.7'", - "version": "==3.4.6" + "version": "==3.4.7" }, "click": { "hashes": [ - "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", - "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" + "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", + "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" ], "markers": "python_version >= '3.10'", - "version": "==8.3.1" + "version": "==8.3.2" }, "colour": { "hashes": [ @@ -458,59 +458,59 @@ }, "cryptography": { "hashes": [ - "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", - "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", - "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", - "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", - "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", - "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", - "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", - "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", - "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", - "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", - "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", - "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", - "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", - "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", - "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", - "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", - "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", - "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", - "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", - "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", - "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", - "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", - "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", - "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", - "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", - "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", - "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", - "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", - "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", - "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", - "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", - "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", - "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", - "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", - "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", - "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", - "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", - "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", - "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", - "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", - "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", - "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", - "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", - "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", - "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", - "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", - "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", - "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", - "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4" + "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", + "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", + "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", + "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", + "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", + "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", + "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", + "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", + "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", + "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", + "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", + "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", + "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", + "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", + "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", + "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", + "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", + "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", + "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", + "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", + "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", + "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", + "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", + "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", + "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", + "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", + "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", + "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", + "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", + "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", + "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", + "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", + "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", + "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", + "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", + "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", + "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", + "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", + "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", + "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", + "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", + "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", + "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", + "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", + "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", + "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", + "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", + "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", + "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce" ], "index": "pypi", "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==46.0.6" + "version": "==46.0.7" }, "cssselect2": { "hashes": [ @@ -639,27 +639,27 @@ }, "google-api-core": { "hashes": [ - "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", - "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5" + "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", + "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b" ], "markers": "python_version >= '3.9'", - "version": "==2.30.0" + "version": "==2.30.3" }, "google-auth": { "hashes": [ - "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", - "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7" + "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", + "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5" ], "markers": "python_version >= '3.8'", - "version": "==2.49.1" + "version": "==2.49.2" }, "googleapis-common-protos": { "hashes": [ - "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", - "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8" + "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", + "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5" ], - "markers": "python_version >= '3.7'", - "version": "==1.73.0" + "markers": "python_version >= '3.9'", + "version": "==1.74.0" }, "h11": { "hashes": [ @@ -954,11 +954,11 @@ }, "msal": { "hashes": [ - "sha256:70cac18ab80a053bff86219ba64cfe3da1f307c74b009e2da57ef040eb1b5656", - "sha256:8f4e82f34b10c19e326ec69f44dc6b30171f2f7098f3720ea8a9f0c11832caa3" + "sha256:36ecac30e2ff4322d956029aabce3c82301c29f0acb1ad89b94edcabb0e58ec4", + "sha256:3f6a4af2b036b476a4215111c4297b4e6e236ed186cd804faefba23e4990978b" ], "markers": "python_version >= '3.8'", - "version": "==1.35.1" + "version": "==1.36.0" }, "msal-extensions": { "hashes": [ @@ -1020,81 +1020,81 @@ }, "numpy": { "hashes": [ - "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", - "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", - "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", - "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", - "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", - "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", - "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", - "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", - "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", - "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", - "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", - "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", - "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", - "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", - "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", - "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", - "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", - "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", - "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", - "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", - "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", - "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", - "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", - "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", - "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", - "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", - "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", - "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", - "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", - "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", - "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", - "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", - "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", - "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", - "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", - "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", - "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", - "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", - "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", - "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", - "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", - "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", - "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", - "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", - "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", - "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", - "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", - "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", - "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", - "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", - "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", - "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", - "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", - "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", - "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", - "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", - "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", - "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", - "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", - "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", - "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", - "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", - "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", - "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", - "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", - "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", - "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", - "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", - "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", - "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", - "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", - "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67" + "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", + "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", + "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", + "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", + "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", + "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", + "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", + "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", + "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", + "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", + "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", + "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", + "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", + "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", + "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", + "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", + "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", + "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", + "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", + "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", + "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", + "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", + "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", + "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", + "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", + "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", + "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", + "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", + "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", + "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", + "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", + "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", + "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", + "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", + "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", + "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", + "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", + "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", + "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", + "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", + "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", + "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", + "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", + "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", + "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", + "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", + "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", + "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", + "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", + "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", + "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", + "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", + "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", + "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", + "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", + "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", + "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", + "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", + "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", + "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", + "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", + "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", + "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", + "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", + "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", + "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", + "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", + "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", + "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", + "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", + "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", + "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e" ], "markers": "python_version >= '3.11'", - "version": "==2.4.3" + "version": "==2.4.4" }, "opencensus": { "hashes": [ @@ -1138,178 +1138,176 @@ }, "pandas": { "hashes": [ - "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", - "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", - "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", - "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", - "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", - "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", - "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", - "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", - "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", - "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", - "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", - "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", - "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", - "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", - "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", - "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", - "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", - "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", - "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", - "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", - "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", - "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", - "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", - "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", - "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", - "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", - "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", - "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", - "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", - "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", - "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", - "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", - "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", - "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", - "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", - "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", - "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", - "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", - "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", - "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", - "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", - "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", - "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", - "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", - "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", - "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", - "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", - "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a" + "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", + "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", + "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", + "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", + "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", + "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", + "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", + "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", + "sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb", + "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", + "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", + "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", + "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", + "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", + "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", + "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", + "sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa", + "sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76", + "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", + "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", + "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", + "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", + "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", + "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", + "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", + "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", + "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", + "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0", + "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", + "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", + "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", + "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", + "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", + "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", + "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", + "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", + "sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e", + "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", + "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", + "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", + "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c", + "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", + "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", + "sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df", + "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", + "sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f", + "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", + "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab" ], "markers": "python_version >= '3.11'", - "version": "==3.0.1" + "version": "==3.0.2" }, "pillow": { "hashes": [ - "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", - "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", - "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", - "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", - "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", - "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", - "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", - "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", - "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", - "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", - "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", - "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", - "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", - "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", - "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", - "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", - "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", - "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", - "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", - "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", - "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", - "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", - "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", - "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", - "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", - "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", - "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", - "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", - "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", - "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", - "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", - "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", - "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", - "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", - "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", - "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", - "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", - "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", - "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", - "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", - "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", - "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", - "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", - "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", - "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", - "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", - "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", - "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", - "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", - "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", - "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", - "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", - "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", - "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", - "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", - "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", - "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", - "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", - "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", - "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", - "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", - "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", - "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", - "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", - "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", - "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", - "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", - "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", - "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", - "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", - "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", - "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", - "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", - "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", - "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", - "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", - "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", - "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", - "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", - "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", - "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", - "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", - "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", - "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", - "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", - "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", - "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", - "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", - "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", - "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", - "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289" + "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", + "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", + "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", + "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", + "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", + "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", + "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", + "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", + "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", + "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", + "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", + "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", + "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", + "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", + "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", + "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", + "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", + "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", + "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", + "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", + "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", + "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", + "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", + "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", + "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", + "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", + "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", + "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", + "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", + "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", + "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", + "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", + "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", + "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", + "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", + "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", + "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", + "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", + "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", + "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", + "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", + "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", + "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", + "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", + "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", + "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", + "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", + "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", + "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", + "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", + "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", + "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", + "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", + "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", + "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", + "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", + "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", + "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", + "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", + "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", + "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", + "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", + "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", + "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", + "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", + "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", + "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", + "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", + "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", + "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", + "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", + "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", + "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", + "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", + "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", + "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", + "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", + "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", + "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", + "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", + "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", + "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", + "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", + "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", + "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", + "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", + "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", + "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", + "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", + "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", + "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5" ], "markers": "python_version >= '3.10'", - "version": "==12.1.1" + "version": "==12.2.0" }, "proto-plus": { "hashes": [ - "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", - "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc" + "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", + "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24" ], - "markers": "python_version >= '3.7'", - "version": "==1.27.1" + "markers": "python_version >= '3.9'", + "version": "==1.27.2" }, "protobuf": { "hashes": [ - "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", - "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", - "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", - "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", - "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", - "sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e", - "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", - "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", - "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", - "sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf" + "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", + "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", + "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", + "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", + "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", + "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", + "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", + "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c" ], - "markers": "python_version >= '3.9'", - "version": "==6.33.6" + "markers": "python_version >= '3.10'", + "version": "==7.34.1" }, "psutil": { "hashes": [ @@ -1564,12 +1562,12 @@ }, "python-multipart": { "hashes": [ - "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", - "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58" + "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", + "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==0.0.22" + "version": "==0.0.24" }, "pytz": { "hashes": [ @@ -1660,12 +1658,12 @@ }, "requests": { "hashes": [ - "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", - "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652" + "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", + "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==2.33.0" + "version": "==2.33.1" }, "six": { "hashes": [ @@ -1864,11 +1862,11 @@ }, "astroid": { "hashes": [ - "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", - "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec" + "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", + "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0" ], - "markers": "python_full_version >= '3.9.0'", - "version": "==3.3.11" + "markers": "python_full_version >= '3.10.0'", + "version": "==4.0.4" }, "attrs": { "hashes": [ @@ -2056,146 +2054,146 @@ }, "charset-normalizer": { "hashes": [ - "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", - "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", - "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", - "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", - "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", - "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", - "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", - "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", - "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", - "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8", - "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264", - "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", - "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", - "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", - "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", - "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", - "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa", - "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", - "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", - "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297", - "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", - "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e", - "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", - "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8", - "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", - "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", - "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", - "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", - "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", - "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", - "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7", - "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", - "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b", - "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", - "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687", - "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9", - "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14", - "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", - "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", - "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", - "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", - "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a", - "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", - "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", - "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", - "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", - "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", - "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", - "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", - "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532", - "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", - "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae", - "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", - "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64", - "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", - "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", - "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", - "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", - "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", - "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", - "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", - "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", - "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597", - "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", - "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", - "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", - "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54", - "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", - "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", - "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4", - "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", - "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", - "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", - "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", - "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", - "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", - "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", - "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", - "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", - "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", - "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", - "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", - "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", - "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", - "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", - "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", - "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc", - "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", - "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", - "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", - "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", - "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", - "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", - "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", - "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237", - "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", - "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778", - "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", - "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", - "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", - "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", - "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f", - "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5", - "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611", - "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", - "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", - "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", - "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", - "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", - "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e", - "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", - "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", - "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", - "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", - "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", - "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe", - "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", - "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17", - "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833", - "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", - "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", - "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", - "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2", - "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", - "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982", - "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", - "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", - "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104", - "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659" + "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", + "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", + "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", + "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", + "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", + "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", + "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", + "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", + "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", + "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", + "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01", + "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", + "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", + "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", + "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", + "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83", + "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5", + "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", + "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", + "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", + "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", + "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", + "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", + "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", + "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", + "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207", + "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", + "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734", + "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", + "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", + "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", + "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", + "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", + "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", + "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", + "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", + "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", + "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", + "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", + "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", + "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", + "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", + "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", + "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", + "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", + "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", + "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", + "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", + "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", + "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", + "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", + "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", + "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", + "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", + "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", + "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", + "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", + "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", + "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", + "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", + "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", + "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", + "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", + "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", + "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", + "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", + "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545", + "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706", + "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", + "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", + "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", + "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", + "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", + "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", + "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", + "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", + "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", + "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", + "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", + "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", + "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", + "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", + "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", + "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", + "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0", + "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686", + "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", + "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", + "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", + "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", + "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", + "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60", + "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", + "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", + "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", + "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", + "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", + "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f", + "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", + "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", + "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", + "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", + "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", + "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", + "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", + "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", + "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", + "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3", + "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7", + "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", + "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", + "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", + "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", + "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259", + "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", + "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", + "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30", + "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", + "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", + "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", + "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", + "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", + "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", + "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", + "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", + "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", + "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", + "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", + "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464" ], "markers": "python_version >= '3.7'", - "version": "==3.4.6" + "version": "==3.4.7" }, "click": { "hashes": [ - "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", - "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" + "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", + "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" ], "markers": "python_version >= '3.10'", - "version": "==8.3.1" + "version": "==8.3.2" }, "colorama": { "hashes": [ @@ -2322,59 +2320,59 @@ }, "cryptography": { "hashes": [ - "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", - "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", - "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", - "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", - "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", - "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", - "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", - "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", - "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", - "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", - "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", - "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", - "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", - "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", - "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", - "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", - "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", - "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", - "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", - "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", - "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", - "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", - "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", - "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", - "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", - "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", - "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", - "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", - "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", - "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", - "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", - "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", - "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", - "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", - "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", - "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", - "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", - "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", - "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", - "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", - "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", - "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", - "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", - "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", - "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", - "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", - "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", - "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", - "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4" + "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", + "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", + "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", + "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", + "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", + "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", + "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", + "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", + "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", + "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", + "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", + "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", + "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", + "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", + "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", + "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", + "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", + "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", + "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", + "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", + "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", + "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", + "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", + "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", + "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", + "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", + "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", + "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", + "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", + "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", + "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", + "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", + "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", + "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", + "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", + "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", + "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", + "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", + "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", + "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", + "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", + "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", + "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", + "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", + "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", + "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", + "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", + "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", + "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce" ], "index": "pypi", "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==46.0.6" + "version": "==46.0.7" }, "cyclonedx-python-lib": { "hashes": [ @@ -2442,27 +2440,27 @@ }, "google-api-core": { "hashes": [ - "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", - "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5" + "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", + "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b" ], "markers": "python_version >= '3.9'", - "version": "==2.30.0" + "version": "==2.30.3" }, "google-auth": { "hashes": [ - "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", - "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7" + "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", + "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5" ], "markers": "python_version >= '3.8'", - "version": "==2.49.1" + "version": "==2.49.2" }, "googleapis-common-protos": { "hashes": [ - "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", - "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8" + "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", + "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5" ], - "markers": "python_version >= '3.7'", - "version": "==1.73.0" + "markers": "python_version >= '3.9'", + "version": "==1.74.0" }, "graphql-core": { "hashes": [ @@ -3195,11 +3193,11 @@ }, "platformdirs": { "hashes": [ - "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", - "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868" + "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", + "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917" ], "markers": "python_version >= '3.10'", - "version": "==4.9.4" + "version": "==4.9.6" }, "pluggy": { "hashes": [ @@ -3348,27 +3346,25 @@ }, "proto-plus": { "hashes": [ - "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", - "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc" + "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", + "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24" ], - "markers": "python_version >= '3.7'", - "version": "==1.27.1" + "markers": "python_version >= '3.9'", + "version": "==1.27.2" }, "protobuf": { "hashes": [ - "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", - "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", - "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", - "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", - "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", - "sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e", - "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", - "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", - "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", - "sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf" + "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", + "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", + "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", + "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", + "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", + "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", + "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", + "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c" ], - "markers": "python_version >= '3.9'", - "version": "==6.33.6" + "markers": "python_version >= '3.10'", + "version": "==7.34.1" }, "py-serializable": { "hashes": [ @@ -3421,20 +3417,20 @@ }, "pygments": { "hashes": [ - "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", - "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", + "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" ], - "markers": "python_version >= '3.8'", - "version": "==2.19.2" + "markers": "python_version >= '3.9'", + "version": "==2.20.0" }, "pylint": { "hashes": [ - "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7", - "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a" + "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", + "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c" ], "index": "pypi", - "markers": "python_full_version >= '3.9.0'", - "version": "==3.3.9" + "markers": "python_full_version >= '3.10.0'", + "version": "==4.0.5" }, "pyparsing": { "hashes": [ @@ -3525,11 +3521,11 @@ }, "python-discovery": { "hashes": [ - "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", - "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1" + "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", + "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a" ], "markers": "python_version >= '3.8'", - "version": "==1.2.0" + "version": "==1.2.2" }, "python-dotenv": { "hashes": [ @@ -3688,12 +3684,12 @@ }, "requests": { "hashes": [ - "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", - "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652" + "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", + "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==2.33.0" + "version": "==2.33.1" }, "rfc3339-validator": { "hashes": [ @@ -3984,11 +3980,11 @@ }, "tzdata": { "hashes": [ - "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", - "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7" + "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", + "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98" ], "markers": "python_version >= '2'", - "version": "==2025.3" + "version": "==2026.1" }, "uri-template": { "hashes": [ @@ -4072,11 +4068,11 @@ }, "virtualenv": { "hashes": [ - "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", - "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f" + "sha256:b66ffe81301766c0d5e2208fc3576652c59d44e7b731fc5f5ed701c9b537fa78", + "sha256:bd16b49c53562b28cf1a3ad2f36edb805ad71301dee70ddc449e5c88a9f919a2" ], "markers": "python_version >= '3.8'", - "version": "==21.2.0" + "version": "==21.2.1" }, "watchfiles": { "hashes": [ @@ -4270,11 +4266,11 @@ }, "werkzeug": { "hashes": [ - "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f", - "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351" + "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", + "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44" ], "markers": "python_version >= '3.9'", - "version": "==3.1.7" + "version": "==3.1.8" }, "yarl": { "hashes": [ diff --git a/clinical-mdr-api/apiVersion b/clinical-mdr-api/apiVersion index bd5605d8..1116fe8f 100644 --- a/clinical-mdr-api/apiVersion +++ b/clinical-mdr-api/apiVersion @@ -1 +1 @@ -3.0.642 +3.0.657 diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/concepts/activities/activity_instance_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/concepts/activities/activity_instance_repository.py index 5db99260..b3fd69c4 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/concepts/activities/activity_instance_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/concepts/activities/activity_instance_repository.py @@ -1,7 +1,7 @@ import datetime from typing import Any -from neomodel import NodeClassNotDefined, db +from neomodel import DoesNotExist, NodeClassNotDefined, db from clinical_mdr_api.domain_repositories.concepts.concept_generic_repository import ( ConceptGenericRepository, @@ -428,13 +428,20 @@ def _create_aggregate_root_instance_from_version_root_relationship_and_value( # ActivityInstance can only link to a single Activity node then it's safe to take a activity_name # from the random ActivityValue node related to any ActivityGroupings node linked to ActivityInstance activity_name = activity_value_node.name + # Prefer the Final version of each linked entity. If no Final version exists, fall back to the highest version. + # The sort key (is_final, version_tuple) ensures Final always ranks above Draft/Retired, + # and within the same status the highest version number wins. # Activity activity_root = activity_value_node.has_version.single() all_activity_rels = activity_value_node.has_version.all_relationships( activity_root ) latest_activity = max( - all_activity_rels, key=lambda r: version_string_to_tuple(r.version) + all_activity_rels, + key=lambda r: ( + r.status == LibraryItemStatus.FINAL.value, + version_string_to_tuple(r.version), + ), ) # ActivityGroup activity_group_value = activity_grouping.has_selected_group.get() @@ -443,7 +450,11 @@ def _create_aggregate_root_instance_from_version_root_relationship_and_value( activity_group_root ) latest_group = max( - all_group_rels, key=lambda r: version_string_to_tuple(r.version) + all_group_rels, + key=lambda r: ( + r.status == LibraryItemStatus.FINAL.value, + version_string_to_tuple(r.version), + ), ) # ActivitySubGroup activity_subgroup_value = activity_grouping.has_selected_subgroup.get() @@ -452,7 +463,11 @@ def _create_aggregate_root_instance_from_version_root_relationship_and_value( activity_subgroup_root ) latest_subgroup = max( - all_subgroup_rels, key=lambda r: version_string_to_tuple(r.version) + all_subgroup_rels, + key=lambda r: ( + r.status == LibraryItemStatus.FINAL.value, + version_string_to_tuple(r.version), + ), ) activity_groupings.append( @@ -968,14 +983,14 @@ def get_all_activity_instances_for_activity_grouping( filter_by_boolean_flags: bool = False, ) -> list[tuple[ActivityInstanceRoot, ActivityInstanceValue]]: query = """ - MATCH (activity_instance_root:ActivityInstanceRoot)-[:LATEST]->(activity_instance_value:ActivityInstanceValue) + MATCH (:ActivityRoot {uid:$activity_uid})-[:LATEST_FINAL]->(:ActivityValue)-[:HAS_GROUPING]->(activity_grouping:ActivityGrouping) + MATCH (activity_grouping)-[:HAS_SELECTED_SUBGROUP]->(:ActivitySubGroupValue)<-[:HAS_VERSION]-(:ActivitySubGroupRoot {uid:$activity_subgroup_uid}) + MATCH (activity_grouping)-[:HAS_SELECTED_GROUP]->(:ActivityGroupValue)<-[:HAS_VERSION]-(:ActivityGroupRoot {uid:$activity_group_uid}) + MATCH (activity_grouping)<-[:HAS_ACTIVITY]-(activity_instance_groupings_value:ActivityInstanceGroupingValue)<-[:LATEST_FINAL]-(activity_instance_groupings_root:ActivityInstanceGroupingRoot) + MATCH (activity_instance_groupings_root)<-[:HAS_GROUPING_ROOT]-(activity_instance_root:ActivityInstanceRoot)-[:LATEST]->(activity_instance_value:ActivityInstanceValue) MATCH (activity_instance_root)-[:LATEST_FINAL]->(activity_instance_value) OPTIONAL MATCH (activity_instance_root)-[retired:HAS_VERSION {status: "Retired"}]->(activity_instance_value) WHERE retired.end_date IS NULL WITH activity_instance_root, activity_instance_value WHERE retired IS NULL - MATCH (activity_instance_root)-[:HAS_GROUPING_ROOT]->(activity_instance_groupings_root:ActivityInstanceGroupingRoot)-[:LATEST_FINAL]->(activity_instance_groupings_value:ActivityInstanceGroupingValue) - MATCH (activity_instance_groupings_value)-[:HAS_ACTIVITY]->(activity_grouping:ActivityGrouping)<-[:HAS_GROUPING]-(:ActivityValue)<-[:HAS_VERSION]-(:ActivityRoot {uid:$activity_uid}) - MATCH (activity_grouping)-[:HAS_SELECTED_SUBGROUP]->(:ActivitySubGroupValue)<-[:HAS_VERSION]-(:ActivitySubGroupRoot {uid:$activity_subgroup_uid}) - MATCH (activity_grouping)-[:HAS_SELECTED_GROUP]->(:ActivityGroupValue)<-[:HAS_VERSION]-(:ActivityGroupRoot {uid:$activity_group_uid}) WITH DISTINCT activity_instance_root, activity_instance_value ORDER BY activity_instance_value.is_required_for_activity DESC, activity_instance_value.is_defaulted_for_activity DESC RETURN activity_instance_root as root, activity_instance_value as value @@ -1840,8 +1855,13 @@ def _get_root_and_library( self, uid: str ) -> tuple[VersionRoot | None, Library | None]: try: - parent_root = self.parent_root_class.nodes.get_or_none(uid=uid) + parent_root = self.parent_root_class.nodes.get(uid=uid) root: VersionRoot | None = parent_root.has_grouping_root.get() + except DoesNotExist as exc: + raise NotFoundException( + "ActivityInstance", + uid, + ) from exc except NodeClassNotDefined as exc: raise NotFoundException( msg="Resource doesn't exist - it was likely deleted in a concurrent transaction." @@ -2030,13 +2050,20 @@ def _create_aggregate_root_instance_from_version_root_relationship_and_value( # ActivityInstance can only link to a single Activity node then it's safe to take a activity_name # from the random ActivityValue node related to any ActivityGroupings node linked to ActivityInstance activity_name = activity_value_node.name + # Prefer the Final version of each linked entity. If no Final version exists, fall back to the highest version. + # The sort key (is_final, version_tuple) ensures Final always ranks above Draft/Retired, + # and within the same status the highest version number wins. # Activity activity_root = activity_value_node.has_version.single() all_activity_rels = activity_value_node.has_version.all_relationships( activity_root ) latest_activity = max( - all_activity_rels, key=lambda r: version_string_to_tuple(r.version) + all_activity_rels, + key=lambda r: ( + r.status == LibraryItemStatus.FINAL.value, + version_string_to_tuple(r.version), + ), ) # ActivityGroup activity_group_value = activity_grouping.has_selected_group.get() @@ -2045,7 +2072,11 @@ def _create_aggregate_root_instance_from_version_root_relationship_and_value( activity_group_root ) latest_group = max( - all_group_rels, key=lambda r: version_string_to_tuple(r.version) + all_group_rels, + key=lambda r: ( + r.status == LibraryItemStatus.FINAL.value, + version_string_to_tuple(r.version), + ), ) # ActivitySubGroup activity_subgroup_value = activity_grouping.has_selected_subgroup.get() @@ -2054,7 +2085,11 @@ def _create_aggregate_root_instance_from_version_root_relationship_and_value( activity_subgroup_root ) latest_subgroup = max( - all_subgroup_rels, key=lambda r: version_string_to_tuple(r.version) + all_subgroup_rels, + key=lambda r: ( + r.status == LibraryItemStatus.FINAL.value, + version_string_to_tuple(r.version), + ), ) activity_groupings.append( diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/concepts/concept_generic_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/concepts/concept_generic_repository.py index f6f7f10b..458412ea 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/concepts/concept_generic_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/concepts/concept_generic_repository.py @@ -319,6 +319,21 @@ def format_filter_sort_keys_for_headers_lite(cls, key: str): """ return key.replace(".", "_") + def find_by_uid( + self, + uid: str, + *, + version: str | None = None, + ) -> _AggregateRootType | None: + """Single-query read-only lookup by UID. + + Uses the Cypher-based find_all path (one DB round-trip) instead of + the neomodel ORM find_by_uid_2 path which issues many separate queries. + Not suitable for write operations — use find_by_uid_2(for_update=True) for those. + """ + results, _ = self.find_all(uids=[uid], version=version) + return results[0] if results else None + def find_all( self, library: str | None = None, diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/controlled_terminologies/ct_codelist_aggregated_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/controlled_terminologies/ct_codelist_aggregated_repository.py index 7eef78e6..78587e27 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/controlled_terminologies/ct_codelist_aggregated_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/controlled_terminologies/ct_codelist_aggregated_repository.py @@ -86,7 +86,9 @@ class CTCodelistAggregatedRepository: author_username: coalesce(name_author.username, rel_data_name.author_id) } AS rel_data_name, head([(codelist_root)-[:PAIRED_CODE_CODELIST]->(paired_codes_cl_root:CTCodelistRoot) | paired_codes_cl_root.uid]) AS paired_codes_codelist_uid, - head([(codelist_root)<-[:PAIRED_CODE_CODELIST]-(paired_names_cl_root:CTCodelistRoot) | paired_names_cl_root.uid]) AS paired_names_codelist_uid + head([(codelist_root)<-[:PAIRED_CODE_CODELIST]-(paired_names_cl_root:CTCodelistRoot) | paired_names_cl_root.uid]) AS paired_names_codelist_uid, + head([(codelist_root)-[:PAIRED_CODE_CODELIST]->(pcr:CTCodelistRoot)-[:HAS_ATTRIBUTES_ROOT]->(:CTCodelistAttributesRoot)-[:LATEST]->(pcv:CTCodelistAttributesValue) | pcv.name]) AS paired_codes_codelist_name, + head([(codelist_root)<-[:PAIRED_CODE_CODELIST]-(pnr:CTCodelistRoot)-[:HAS_ATTRIBUTES_ROOT]->(:CTCodelistAttributesRoot)-[:LATEST]->(pnv:CTCodelistAttributesValue) | pnv.name]) AS paired_names_codelist_name """ generic_alias_clause = f""" DISTINCT codelist_root, codelist_name_root, codelist_name_value, codelist_attributes_root, codelist_attributes_value @@ -151,6 +153,8 @@ def _create_codelist_aggregate_instances_from_cypher_result( paired_codelists = CTPairedCodelists( paired_names_codelist_uid=codelist_dict.get("paired_names_codelist_uid"), paired_codes_codelist_uid=codelist_dict.get("paired_codes_codelist_uid"), + paired_names_codelist_name=codelist_dict.get("paired_names_codelist_name"), + paired_codes_codelist_name=codelist_dict.get("paired_codes_codelist_name"), ) return codelist_name_ar, codelist_attributes_ar, paired_codelists diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/controlled_terminologies/ct_codelist_generic_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/controlled_terminologies/ct_codelist_generic_repository.py index 0c66e578..688faf5f 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/controlled_terminologies/ct_codelist_generic_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/controlled_terminologies/ct_codelist_generic_repository.py @@ -467,36 +467,47 @@ def add_term( exceptions.ValidationException.raise_if( ct_term_node is None, msg=f"Term with UID '{term_uid}' doesn't exist." ) - new_term_name_node = ( - ct_term_node.has_name_root.single().has_latest_value.single() - ) - - for ct_codelist_term_node in ct_codelist_node.has_term.all(): - # Check if the has_term relationship has an end date. - # If so, it means the term was removed rom this codelist and we can skip the following checks. - has_term_rel = ct_codelist_node.has_term.relationship(ct_codelist_term_node) - if has_term_rel.end_date is not None: - continue - # Check if the same term is already added to the codelist - ct_term_end_node = ct_codelist_term_node.has_term_root.single() - exceptions.AlreadyExistsException.raise_if( - ct_term_end_node.uid == term_uid, - msg=f"Codelist with UID '{codelist_uid}' already has a Term with UID '{term_uid}'.", - ) - # Check if a term with the same submission value is already added to the codelist - if ct_codelist_term_node.submission_value == submission_value: + # Single Cypher query to check all duplicate conditions at once, + # replacing the N+1 loop that made 3 DB calls per existing term. + duplicate_check_query = """ + MATCH (new_term:CTTermRoot {uid: $term_uid})-[:HAS_NAME_ROOT]->()-[:LATEST]->(new_name_val) + WITH new_term, new_name_val + MATCH (codelist:CTCodelistRoot {uid: $codelist_uid})-[ht:HAS_TERM]->(clt:CTCodelistTerm)-[:HAS_TERM_ROOT]->(existing_term:CTTermRoot) + WHERE ht.end_date IS NULL + MATCH (existing_term)-[:HAS_NAME_ROOT]->()-[:LATEST]->(existing_name_val) + WITH new_term, new_name_val, clt, existing_term, existing_name_val, + CASE WHEN existing_term.uid = $term_uid THEN 'uid' ELSE NULL END AS uid_match, + CASE WHEN clt.submission_value = $submission_value THEN 'submission_value' ELSE NULL END AS sv_match, + CASE WHEN existing_name_val.name = new_name_val.name THEN 'name' ELSE NULL END AS name_match + WHERE uid_match IS NOT NULL OR sv_match IS NOT NULL OR name_match IS NOT NULL + RETURN + coalesce(uid_match, sv_match, name_match) AS match_type, + clt.submission_value AS existing_submission_value, + existing_name_val.name AS existing_name + LIMIT 1 + """ + results, _ = db.cypher_query( + duplicate_check_query, + { + "codelist_uid": codelist_uid, + "term_uid": term_uid, + "submission_value": submission_value, + }, + ) + if results: + match_type, existing_sv, existing_name = results[0] + if match_type == "uid": raise exceptions.AlreadyExistsException( - msg=f"Codelist with UID '{codelist_uid}' already has a Term with submission value '{submission_value}'." + msg=f"Codelist with UID '{codelist_uid}' already has a Term with UID '{term_uid}'.", ) - - # Check if a term with the same name is already added to the codelist - existing_ct_term_name_node = ( - ct_term_end_node.has_name_root.single().has_latest_value.single() - ) - if new_term_name_node.name == existing_ct_term_name_node.name: + if match_type == "submission_value": + raise exceptions.AlreadyExistsException( + msg=f"Codelist with UID '{codelist_uid}' already has a Term with submission value '{existing_sv}'.", + ) + if match_type == "name": raise exceptions.AlreadyExistsException( - msg=f"Codelist with UID '{codelist_uid}' already has a Term with name '{new_term_name_node.name}'." + msg=f"Codelist with UID '{codelist_uid}' already has a Term with name '{existing_name}'.", ) ct_codelist_term_node = ct_term_node.has_term_root.get_or_none( @@ -812,20 +823,15 @@ def get_paired_codelist_uid(self, codelist_uid: str) -> str | None: :param codelist_uid: The UID of the codelist :return: Paired codelist UID or None """ - codelist_root = CTCodelistRoot.nodes.get_or_none(uid=codelist_uid) - if not codelist_root: - return None - - # Check if this codelist has a paired code codelist (outgoing relationship) - paired_code = codelist_root.has_paired_code_codelist.get_or_none() - if paired_code: - return paired_code.uid - - # Check if this codelist has a paired name codelist (incoming relationship) - paired_name = codelist_root.has_paired_name_codelist.get_or_none() - if paired_name: - return paired_name.uid - + query = """ + MATCH (codelist:CTCodelistRoot {uid: $codelist_uid}) + OPTIONAL MATCH (codelist)-[:PAIRED_CODE_CODELIST]->(paired_code:CTCodelistRoot) + OPTIONAL MATCH (codelist)<-[:PAIRED_CODE_CODELIST]-(paired_name:CTCodelistRoot) + RETURN coalesce(paired_code.uid, paired_name.uid) AS paired_uid + """ + results, _ = db.cypher_query(query, {"codelist_uid": codelist_uid}) + if results and results[0][0] is not None: + return results[0][0] return None def is_term_in_codelist(self, term_uid: str, codelist_uid: str) -> bool: @@ -835,23 +841,12 @@ def is_term_in_codelist(self, term_uid: str, codelist_uid: str) -> bool: :param codelist_uid: The UID of the codelist :return: True if the term is in the codelist, False otherwise """ - codelist_root = CTCodelistRoot.nodes.get_or_none(uid=codelist_uid) - if not codelist_root: - return False - - term_root = CTTermRoot.nodes.get_or_none(uid=term_uid) - if not term_root: - return False - - # Check if there's an active HAS_TERM relationship - for codelist_term in codelist_root.has_term.all(): - term_from_codelist = codelist_term.has_term_root.get_or_none() - if term_from_codelist and term_from_codelist.uid == term_uid: - has_term_rel = codelist_root.has_term.relationship(codelist_term) - if has_term_rel.end_date is None: - return True - - return False + results = CTCodelistRoot.nodes.filter( + uid=codelist_uid, + has_term__has_term_root__uid=term_uid, + **{"has_term|end_date__isnull": True}, + )[:1] + return len(results) > 0 def get_or_create_selected_term( self, diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/feature_flag_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/feature_flag_repository.py index 56ef447f..45b83dda 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/feature_flag_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/feature_flag_repository.py @@ -12,6 +12,8 @@ class FeatureFlagRepository: def _transform_to_model(self, item: FeatureFlagNode) -> FeatureFlag: return FeatureFlag( sn=item.sn, + section=item.section, + feature=item.feature, name=item.name, enabled=item.enabled, description=item.description, @@ -64,6 +66,8 @@ def retrieve_feature_flag(self, sn: int) -> FeatureFlag: def create_feature_flag( self, + section: str, + feature: str, name: str, enabled: bool, description: str | None, @@ -82,6 +86,8 @@ def create_feature_flag( CREATE (n:FeatureFlag) SET n.sn = $sn, + n.section = $section, + n.feature = $feature, n.name = $name, n.enabled = $enabled, n.description = $description @@ -89,6 +95,8 @@ def create_feature_flag( """, params={ "sn": sn, + "section": section, + "feature": feature, "name": name, "enabled": enabled, "description": description, @@ -98,14 +106,37 @@ def create_feature_flag( return self._transform_to_model(rs[0][0][0]) - def update_feature_flag(self, sn: int, enabled: bool) -> FeatureFlag: - rs = db.cypher_query( - """ - MATCH (n:FeatureFlag {sn: $sn}) - SET n.enabled = $enabled + def update_feature_flag(self, sn: int, **updates) -> FeatureFlag: + """ + Update a feature flag with only the provided fields. + + Args: + sn: Serial number of the feature flag + **updates: Fields to update (section, feature, enabled) + + Returns: + Updated FeatureFlag + """ + if not updates: + # If no updates provided, just retrieve and return the existing flag + return self.retrieve_feature_flag(sn) + + # Build SET clause dynamically for only the provided fields + set_clauses = [f"n.{key} = ${key}" for key in updates] + set_clause = ", ".join(set_clauses) + + query = f""" + MATCH (n:FeatureFlag {{sn: $sn}}) + SET + {set_clause} RETURN n - """, - params={"sn": sn, "enabled": enabled}, + """ + + params = {"sn": sn, **updates} + + rs = db.cypher_query( + query, + params=params, resolve_objects=True, ) diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/feature_flag.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/feature_flag.py index 4ab6ef38..ad223dfe 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/feature_flag.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/feature_flag.py @@ -4,6 +4,8 @@ class FeatureFlag(ClinicalMdrNode): sn = IntegerProperty(unique_index=True) + section = StringProperty() + feature = StringProperty() name = StringProperty() enabled = BooleanProperty() description = StringProperty() diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/study_selections.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/study_selections.py index fa879968..48ab5bd8 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/study_selections.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/study_selections.py @@ -829,3 +829,45 @@ class StudyDefinitionDocument(StudySelection): study_value = RelationshipFrom( STUDY_VALUE_CLASS_NAME, "HAS_STUDY_DEFINITION_DOCUMENT", model=ClinicalMdrRel ) + + +# All domain classes that inherit StudySelection (excluding the abstract base). +STUDY_SELECTION_CONCRETE_LABELS: frozenset[str] = frozenset( + { + "StudyDataSupplier", + "StudyObjective", + "StudyEndpoint", + "StudyCompound", + "StudyCriteria", + "StudySoAGroup", + "StudyActivitySubGroup", + "StudyActivityGroup", + "StudyActivity", + "StudyActivityInstance", + "StudyActivitySchedule", + "StudyDesignCell", + "StudyArm", + "StudyElement", + "StudyActivityInstruction", + "StudyBranchArm", + "StudyCohort", + "StudyCompoundDosing", + "StudySoAFootnote", + "StudyDesignClass", + "StudySourceVariable", + "StudyVersion", + "StudyDefinitionDocument", + "StudyEpoch", + "StudyVisit", + "StudyDiseaseMilestone", + "StudyStandardVersion", + } +) + +# Omitted from clone/source-vs-target containment checks: footnote rows depend on +# which selection subgraph was copied, so counts are not comparable to the source. +STUDY_SELECTION_LABELS_EXCLUDED_FROM_CONTAINMENT: frozenset[str] = frozenset( + { + "StudySoAFootnote", + } +) diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/study_template.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/study_template.py new file mode 100644 index 00000000..9c54dc42 --- /dev/null +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/models/study_template.py @@ -0,0 +1,32 @@ +from neomodel import RelationshipTo + +from clinical_mdr_api.domain_repositories.models.generic import ( + ClinicalMdrRel, + VersionRelationship, + VersionRoot, + VersionValue, +) +from common.neomodel import StringProperty + + +class StudyTemplateValue(VersionValue): + study_uid = StringProperty() + study_value_version = StringProperty() + + +class StudyTemplateRoot(VersionRoot): + has_version = RelationshipTo( + StudyTemplateValue, "HAS_VERSION", model=VersionRelationship + ) + has_latest_value = RelationshipTo( + StudyTemplateValue, "LATEST", model=ClinicalMdrRel + ) + latest_draft = RelationshipTo( + StudyTemplateValue, "LATEST_DRAFT", model=ClinicalMdrRel + ) + latest_final = RelationshipTo( + StudyTemplateValue, "LATEST_FINAL", model=ClinicalMdrRel + ) + latest_retired = RelationshipTo( + StudyTemplateValue, "LATEST_RETIRED", model=ClinicalMdrRel + ) diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/standard_data_models/sponsor_model_dataset_variable_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/standard_data_models/sponsor_model_dataset_variable_repository.py index ef642c52..15ca6403 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/standard_data_models/sponsor_model_dataset_variable_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/standard_data_models/sponsor_model_dataset_variable_repository.py @@ -326,22 +326,24 @@ def _get_or_create_instance( self._db_save_node(new_instance) # Connect with Codelists & Terms - for codelist_uid in ar.sponsor_model_dataset_variable_vo.references_codelists: + for codelist_uid in ( + ar.sponsor_model_dataset_variable_vo.references_codelists or [] + ): codelist_node = CTCodelistRoot.nodes.get_or_none(uid=codelist_uid) BusinessLogicException.raise_if_not( codelist_node, msg=f"Could not find codelist with uid '{codelist_uid}'.", ) new_instance.references_codelist.connect(codelist_node) - for term_uid in ar.sponsor_model_dataset_variable_vo.references_terms: + for term_uid in ar.sponsor_model_dataset_variable_vo.references_terms or []: term_node = CTTermRoot.nodes.get_or_none(uid=term_uid) BusinessLogicException.raise_if_not( term_node, msg=f"Could not find term with uid '{term_uid}'.", ) - for ( - codelist_uid - ) in ar.sponsor_model_dataset_variable_vo.references_codelists: + for codelist_uid in ( + ar.sponsor_model_dataset_variable_vo.references_codelists or [] + ): codelist_node = CTCodelistRoot.nodes.get_or_none(uid=codelist_uid) term_context = CTTermContext() self._db_save_node(term_context) diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/studies/study_template_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/studies/study_template_repository.py new file mode 100644 index 00000000..217bd937 --- /dev/null +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/studies/study_template_repository.py @@ -0,0 +1,126 @@ +from typing import Any, cast + +from clinical_mdr_api.domain_repositories.library_item_repository import ( + LibraryItemRepositoryImplBase, +) +from clinical_mdr_api.domain_repositories.models.generic import ( + Library, + VersionRelationship, + VersionRoot, + VersionValue, +) +from clinical_mdr_api.domain_repositories.models.study_template import ( + StudyTemplateRoot, + StudyTemplateValue, +) +from clinical_mdr_api.domains.enums import LibraryItemStatus +from clinical_mdr_api.domains.study_definition_aggregates.study_template import ( + StudyTemplateAR, + StudyTemplateValueVO, +) +from clinical_mdr_api.domains.versioned_object_aggregate import LibraryItemMetadataVO + + +class StudyTemplateRepository(LibraryItemRepositoryImplBase): + root_class = StudyTemplateRoot + value_class = StudyTemplateValue + user: str + has_library = False + + def generate_uid(self) -> str: + return self.root_class.get_next_free_uid_and_increment_counter() + + def _create_aggregate_root_instance_from_version_root_relationship_and_value( + self, + root: VersionRoot, + library: Library, + relationship: VersionRelationship, + value: VersionValue, + **_kwargs, + ) -> StudyTemplateAR: + ar_root = cast(StudyTemplateRoot, root) + ar_value = cast(StudyTemplateValue, value) + return StudyTemplateAR.from_repository_values( + uid=ar_root.uid, + item_metadata=self._library_item_metadata_vo_from_relation(relationship), + study_template_value=StudyTemplateValueVO.from_repository_values( + study_uid=ar_value.study_uid, + study_value_version=ar_value.study_value_version, + ), + ) + + def _are_changes_possible( + self, + versioned_object: Any, + previous_versioned_object: Any, + ) -> bool: + """ + Allow persisting PATCHed template target during FINAL/RETIRED states. + + The base `LibraryItemRepositoryImplBase` only allows value-node changes for + DRAFT->DRAFT transitions. For Study templates, PATCH creates a new version + and then approves back to FINAL, while the template item's status ends up + FINAL->FINAL. We must allow a new value node when the target study reference + (uid/version) changes. + """ + + new_status = versioned_object.item_metadata.status + prev_status = previous_versioned_object.item_metadata.status + + if ( + prev_status == LibraryItemStatus.DRAFT + and new_status == LibraryItemStatus.DRAFT + ): + return True + + if new_status == LibraryItemStatus.FINAL and prev_status in [ + LibraryItemStatus.FINAL, + LibraryItemStatus.RETIRED, + ]: + prev_value = cast(StudyTemplateAR, previous_versioned_object).value + new_value = cast(StudyTemplateAR, versioned_object).value + return ( + prev_value.study_uid != new_value.study_uid + or prev_value.study_value_version != new_value.study_value_version + ) + + return False + + def _maintain_parameters( + self, + versioned_object: Any, + root: VersionRoot, + value: VersionValue, + ) -> None: + pass + + def _get_or_create_value( + self, root: VersionRoot, ar: StudyTemplateAR, force_new_value_node: bool = False + ) -> VersionValue: + value = StudyTemplateValue( + study_uid=ar.value.study_uid, + study_value_version=ar.value.study_value_version, + ) + self._db_save_node(node=value) + return value + + def _is_new_version_necessary( + self, ar: StudyTemplateAR, value: VersionValue + ) -> bool: + template_value = cast(StudyTemplateValue, value) + return ( + template_value.study_uid != ar.value.study_uid + or template_value.study_value_version != ar.value.study_value_version + ) + + def _create(self, item: StudyTemplateAR) -> StudyTemplateAR: + relation_data: LibraryItemMetadataVO = item.item_metadata + root = self.root_class(uid=item.uid) + self._db_save_node(root) + + value = self._get_or_create_value(root=root, ar=item) + root, value, _, _, _ = self._db_create_and_link_nodes( + root, value, self._library_item_metadata_vo_to_datadict(relation_data) + ) + self._maintain_parameters(item, root, value) + return item diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_definitions/study_definition_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_definitions/study_definition_repository.py index 1515cb49..ec62fac7 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_definitions/study_definition_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_definitions/study_definition_repository.py @@ -4,7 +4,6 @@ from typing import Any, Sequence from neomodel import db -from neomodel.sync_.match import Collect, NodeNameResolver, Path, Size from neomodel.sync_.node import NodeMeta from clinical_mdr_api.domain_repositories.generic_repository import ( @@ -18,6 +17,10 @@ StudyArrayField, StudyBooleanField, ) +from clinical_mdr_api.domain_repositories.models.study_selections import ( + STUDY_SELECTION_CONCRETE_LABELS, + STUDY_SELECTION_LABELS_EXCLUDED_FROM_CONTAINMENT, +) from clinical_mdr_api.domains.study_definition_aggregates.root import ( StudyDefinitionAR, StudyDefinitionSnapshot, @@ -262,103 +265,154 @@ def get_study_structure_overview_headers( ) def get_study_structure_statistics(self, uid: str) -> dict[str, int] | None: - result = ( - StudyValue.nodes.filter(latest_value__uid=uid) - .traverse( - Path( - value="has_study_arm", - optional=True, - include_nodes_in_return=False, - include_rels_in_return=False, - ), - Path( - value="has_study_branch_arm", - optional=True, - include_nodes_in_return=False, - include_rels_in_return=False, - ), - Path( - value="has_study_element", - optional=True, - include_nodes_in_return=False, - include_rels_in_return=False, - ), - Path( - value="has_study_cohort", - optional=True, - include_nodes_in_return=False, - include_rels_in_return=False, - ), - Path( - value="has_study_epoch", - optional=True, - include_nodes_in_return=False, - include_rels_in_return=False, - ), - Path( - value="has_study_footnote__references_study_epoch", - optional=True, - include_nodes_in_return=False, - include_rels_in_return=False, - ), - Path( - value="has_study_visit", - optional=True, - include_nodes_in_return=False, - include_rels_in_return=False, - ), - Path( - value="has_study_footnote__references_study_visit", - optional=True, - include_nodes_in_return=False, - include_rels_in_return=False, - ), - ) - .annotate( - arm_count=Size( - Collect(NodeNameResolver("has_study_arm"), distinct=True) - ), - branch_count=Size( - Collect(NodeNameResolver("has_study_branch_arm"), distinct=True) - ), - element_count=Size( - Collect(NodeNameResolver("has_study_element"), distinct=True) - ), - cohort_count=Size( - Collect(NodeNameResolver("has_study_cohort"), distinct=True) - ), - epoch_count=Size( - Collect(NodeNameResolver("has_study_epoch"), distinct=True) - ), - epoch_footnote_count=Size( - Collect( - NodeNameResolver("has_study_footnote__references_study_epoch"), - distinct=True, - ) - ), - visit_count=Size( - Collect(NodeNameResolver("has_study_visit"), distinct=True) - ), - visit_footnote_count=Size( - Collect( - NodeNameResolver("has_study_footnote__references_study_visit"), - distinct=True, - ) - ), - ) - .all() - ) + query = """ +MATCH (sr:StudyRoot {uid: $uid})-[:LATEST]->(sv:StudyValue) +RETURN + SIZE([(sv)-[:HAS_STUDY_ARM]->(:StudyArm) | 1]) AS arm_count, + SIZE([(sv)-[:HAS_STUDY_BRANCH_ARM]->(:StudyBranchArm) | 1]) AS branch_count, + SIZE([(sv)-[:HAS_STUDY_ELEMENT]->(:StudyElement) | 1]) AS element_count, + SIZE([(sv)-[:HAS_STUDY_COHORT]->(:StudyCohort) | 1]) AS cohort_count, + SIZE([(sv)-[:HAS_STUDY_EPOCH]->(:StudyEpoch) | 1]) AS epoch_count, + SIZE([(sv)-[:HAS_STUDY_FOOTNOTE]->(:StudyFootnote)-[:REFERENCES_STUDY_EPOCH]->(:StudyEpoch) | 1]) AS epoch_footnote_count, + SIZE([(sv)-[:HAS_STUDY_VISIT]->(:StudyVisit) | 1]) AS visit_count, + SIZE([(sv)-[:HAS_STUDY_FOOTNOTE]->(:StudyFootnote)-[:REFERENCES_STUDY_VISIT]->(:StudyVisit) | 1]) AS visit_footnote_count, + SIZE([(sv)-[:HAS_STUDY_ACTIVITY]->(:StudyActivity) | 1]) AS study_activity_count, + SIZE([(sv)-[:HAS_STUDY_ACTIVITY_SCHEDULE]->(:StudyActivitySchedule) | 1]) AS study_activity_schedule_count +""" + result, _ = db.cypher_query(query=query, params={"uid": uid}) + if not result: return None + + counts = result[0] + return { + "arm_count": counts[0], + "branch_count": counts[1], + "element_count": counts[2], + "cohort_count": counts[3], + "epoch_count": counts[4], + "epoch_footnote_count": counts[5], + "visit_count": counts[6], + "visit_footnote_count": counts[7], + "study_activity_count": counts[8], + "study_activity_schedule_count": counts[9], + } + + def get_study_selection_labels_for_study(self, study_uid: str) -> list[str]: + """ + Distinct concrete study-selection labels present on the study's latest value + (only labels in :data:`STUDY_SELECTION_CONCRETE_LABELS`). + """ + query = """ + MATCH (sr:StudyRoot {uid: $study_uid})-[:LATEST]->(sv:StudyValue)-[rel]->(ss:StudySelection) + WHERE type(rel) <> 'HAS_PROTOCOL_SOA_CELL' AND type(rel) <> 'HAS_PROTOCOL_SOA_FOOTNOTE' + UNWIND labels(ss) AS lb + WITH lb + WHERE lb IN $known_labels + RETURN DISTINCT lb AS label + ORDER BY label + """ + rows, _ = db.cypher_query( + query, + { + "study_uid": study_uid, + "known_labels": list(STUDY_SELECTION_CONCRETE_LABELS), + }, + ) + return [str(r[0]) for r in rows] + + def get_study_selection_statistics_for_labels( + self, study_uid: str, labels: list[str] + ) -> dict[str, dict[str, int]]: + """ + Per-label selection counts and distinct CT term root counts (via CTTermContext) + for the given labels on the study's latest value. + """ + if not labels: + return {} + query = """ + UNWIND $labels AS want + OPTIONAL MATCH (sr:StudyRoot {uid: $study_uid})-[:LATEST]->(sv:StudyValue)-[rel]->(ss:StudySelection) + WHERE type(rel) <> 'HAS_PROTOCOL_SOA_CELL' AND type(rel) <> 'HAS_PROTOCOL_SOA_FOOTNOTE' + AND want IN labels(ss) + OPTIONAL MATCH (ss)-[]->(ctx:CTTermContext)-[:HAS_SELECTED_TERM]->(tr:CTTermRoot) + RETURN want, + count(DISTINCT ss) AS selection_count, + count(DISTINCT tr) AS distinct_ct_term_root_count + ORDER BY want + """ + rows, _ = db.cypher_query(query, {"study_uid": study_uid, "labels": labels}) + out: dict[str, dict[str, int]] = {} + for row in rows: + want, sel_count, term_count = row[0], int(row[1]), int(row[2]) + out[str(want)] = { + "selection_count": sel_count, + "distinct_ct_term_root_count": term_count, + } + return out + + def get_study_selection_containment( + self, source_study_uid: str, target_study_uid: str + ) -> dict[str, Any]: + """ + Whether **target** selection statistics (counts only for labels that appear on + **target**) are numerically contained in **source**: + for each such label, target counts must be <= source counts. + + Used to compare a clone (target) against its source study when only a subset + of labels may have been copied, or to verify full clone equality (then counts + match and containment holds). + + ``StudySoAFootnote`` is excluded: clone copies footnotes conditionally, so + footnote counts are not compared on source or target. + """ + labels = [ + lb + for lb in self.get_study_selection_labels_for_study(target_study_uid) + if lb not in STUDY_SELECTION_LABELS_EXCLUDED_FROM_CONTAINMENT + ] + if not labels: + return { + "target_contained_in_source": True, + "labels_from_target": [], + "per_label": [], + } + src_stats = self.get_study_selection_statistics_for_labels( + source_study_uid, labels + ) + tgt_stats = self.get_study_selection_statistics_for_labels( + target_study_uid, labels + ) + per_label: list[dict[str, Any]] = [] + all_contained = True + for lb in labels: + s = src_stats.get( + lb, + {"selection_count": 0, "distinct_ct_term_root_count": 0}, + ) + t = tgt_stats.get( + lb, + {"selection_count": 0, "distinct_ct_term_root_count": 0}, + ) + sc, stc = s["selection_count"], s["distinct_ct_term_root_count"] + tc, ttc = t["selection_count"], t["distinct_ct_term_root_count"] + label_ok = tc <= sc and ttc <= stc + if not label_ok: + all_contained = False + per_label.append( + { + "label": lb, + "target_selection_count": tc, + "source_selection_count": sc, + "target_distinct_ct_term_root_count": ttc, + "source_distinct_ct_term_root_count": stc, + "label_contained": label_ok, + } + ) return { - "arm_count": result[0][3], - "branch_count": result[0][4], - "element_count": result[0][5], - "cohort_count": result[0][6], - "epoch_count": result[0][7], - "epoch_footnote_count": result[0][8], - "visit_count": result[0][9], - "visit_footnote_count": result[0][10], + "target_contained_in_source": all_contained, + "labels_from_target": labels, + "per_label": per_label, } def copy_study_items( @@ -369,27 +423,22 @@ def copy_study_items( author_id: str, ) -> dict[str, int] | None: parameters: dict[str, str | list[str] | datetime.datetime] = {} - exclusions = """ - NOT EXISTS((selection_src)--(:StudyActivity)) - AND NOT EXISTS((selection_src)--(:StudyActivitySubGroup)) - AND NOT EXISTS((selection_src)--(:StudyActivityGroup)) - AND NOT EXISTS((selection_src:StudySoAFootnote)--(:StudyActivitySchedule)) - AND NOT EXISTS((selection_src)--(:StudyActivityInstance)) - AND NOT EXISTS((selection_src)--(:StudySoAGroup)) - """ - if ( - "StudySoAFootnote" in list_of_items_to_copy - and "StudyVisit" not in list_of_items_to_copy - ): - exclusions += """ - AND NOT ((selection_src:StudySoAFootnote)--(:StudyVisit) AND NOT (selection_src:StudySoAFootnote)--(:StudyVisit)--(:Delete)) - """ - if ( - "StudySoAFootnote" in list_of_items_to_copy - and "StudyEpoch" not in list_of_items_to_copy + exclusions = "TRUE" + for label in ( + "StudyVisit", + "StudyEpoch", + "StudyActivity", + "StudyActivitySubGroup", + "StudyActivityGroup", + "StudySoAGroup", + "StudyActivitySchedule", ): - exclusions += """ - AND NOT ((selection_src:StudySoAFootnote)--(:StudyEpoch) AND NOT (selection_src:StudySoAFootnote)--(:StudyEpoch)--(:Delete)) + if ( + "StudySoAFootnote" in list_of_items_to_copy + and label not in list_of_items_to_copy + ): + exclusions += f""" + AND NOT ((selection_src:StudySoAFootnote)--(:{label})) """ # COPY NODES AND OUTBOUND RELATIONSHIPS @@ -872,7 +921,8 @@ def where_stmt(): COALESCE(author.username, current_version.author_id) as author, latest_locked_version, latest_released_version, - [(sr)-[:HAS_COMPLETENESS_TAG]->(t:DataCompletenessTag) | t.name] as data_completeness_tags + [(sr)-[:HAS_COMPLETENESS_TAG]->(t:DataCompletenessTag) | t.name] as data_completeness_tags, + sv.description ORDER BY uid """ rs = db.cypher_query(query) @@ -897,6 +947,7 @@ def where_stmt(): "latest_locked_version": row[16], "latest_released_version": row[17], "data_completeness_tags": row[18], + "description": row[19], } for row in rs[0] ] @@ -1204,22 +1255,39 @@ def get_audit_trail_by_uid( @abstractmethod def _retrieve_study_subpart_with_history( - self, uid: str, is_subpart: bool = False, study_value_version: str | None = None - ): + self, + uid: str, + is_subpart: bool = False, + study_value_version: str | None = None, + page_number: int = 1, + page_size: int = 0, + total_count: bool = False, + ) -> GenericFilteringReturn: """ Private method to retrieve an audit trail for a study's subparts by UID. - :return: A list of Study subpart audit trail objects. + :return: A GenericFilteringReturn containing Study subpart audit trail objects. """ def get_subpart_audit_trail_by_uid( - self, uid: str, is_subpart: bool = False, study_value_version: str | None = None - ) -> list[Any]: + self, + uid: str, + is_subpart: bool = False, + study_value_version: str | None = None, + page_number: int = 1, + page_size: int = 0, + total_count: bool = False, + ) -> GenericFilteringReturn: """ Public method which is to retrieve the audit trail for a given study identified by UID. - :return: A list of retrieved data in a form StudyAuditTrailAR instances. + :return: A GenericFilteringReturn containing StudySubpartAuditTrail instances. """ return self._retrieve_study_subpart_with_history( - uid, is_subpart, study_value_version=study_value_version + uid, + is_subpart, + study_value_version=study_value_version, + page_number=page_number, + page_size=page_size, + total_count=total_count, ) @abstractmethod diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_definitions/study_definition_repository_impl.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_definitions/study_definition_repository_impl.py index f514ddeb..e4a67fab 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_definitions/study_definition_repository_impl.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_definitions/study_definition_repository_impl.py @@ -86,7 +86,7 @@ from common import exceptions from common.config import settings from common.telemetry import trace_calls -from common.utils import convert_to_datetime +from common.utils import convert_to_datetime, validate_max_skip_clause MAINTAIN_RELATIONSHIPS_FOR_NEW_STUDY_VALUE = { "belongs_to_study_parent_part", @@ -2922,12 +2922,18 @@ def get_latest_released_version_from_specific_datetime( return latest_version_relationship[2].version def _retrieve_study_subpart_with_history( - self, uid: str, is_subpart: bool = False, study_value_version: str | None = None - ) -> list[Any]: + self, + uid: str, + is_subpart: bool = False, + study_value_version: str | None = None, + page_number: int = 1, + page_size: int = 0, + total_count: bool = False, + ) -> GenericFilteringReturn: """ returns the audit trail for all study subparts of the study """ - params: dict[str, str | list[str]] = {} + params: dict[str, str | list[str] | int] = {} if not is_subpart: params = {"study_uid": uid} if study_value_version: @@ -2960,14 +2966,27 @@ def _retrieve_study_subpart_with_history( else: parent_in_version = "" - rs = db.cypher_query( - f""" + base_query = f""" MATCH (ssr:StudyRoot)-[h_rel:HAS_VERSION]->(ssv:StudyValue) {parent_in_version} WHERE ssr.uid IN $subpart_uids OPTIONAL MATCH (ssv)<-[:AFTER]-(asa:StudyAction) OPTIONAL MATCH (ssv)<-[:BEFORE]-(bsa:StudyAction) OPTIONAL MATCH (ssv)<-[:HAS_STUDY_SUBPART]-(psv:StudyValue)<-[p_h_rel:HAS_VERSION]-(psr:StudyRoot) + """ + + if page_size > 0: + validate_max_skip_clause(page_number=page_number, page_size=page_size) + skip = (page_number - 1) * page_size + params["skip"] = skip + params["limit"] = page_size + pagination_clause = "SKIP $skip LIMIT $limit" + else: + pagination_clause = "" + + rs = db.cypher_query( + f""" + {base_query} RETURN DISTINCT psr.uid AS parent_uid, ssr.uid AS subpart_uid, @@ -2979,12 +2998,34 @@ def _retrieve_study_subpart_with_history( h_rel.end_date AS end_date, labels(asa) AS change_type ORDER BY start_date DESC + {pagination_clause} """, params=params, ) rs = utils.db_result_to_list(rs) rs.reverse() + total = -1 + if total_count: + count_result = db.cypher_query( + f""" + {base_query} + WITH DISTINCT + psr.uid AS parent_uid, + ssr.uid AS subpart_uid, + ssv.subpart_id AS subpart_id, + ssv.study_acronym AS study_acronym, + ssv.study_subpart_acronym AS study_subpart_acronym, + h_rel.author_id AS author_id, + h_rel.start_date AS start_date, + h_rel.end_date AS end_date, + labels(asa) AS change_type + RETURN count(*) AS total + """, + params=params, + ) + total = count_result[0][0][0] + result = [] if not is_subpart: subpart_status = set() @@ -3060,7 +3101,8 @@ def _retrieve_study_subpart_with_history( result.reverse() - return calculate_diffs(result, StudySubpartAuditTrail) + items = calculate_diffs(result, StudySubpartAuditTrail) + return GenericFilteringReturn(items=items, total=total) @staticmethod def get_soa_preferences( diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/base.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/base.py index f73117db..34e21964 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/base.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/base.py @@ -1,10 +1,12 @@ import datetime from dataclasses import dataclass +from neomodel import db + from clinical_mdr_api.domain_repositories.generic_repository import ( _manage_versioning_with_relations, ) -from clinical_mdr_api.domain_repositories.models.study import StudyRoot, StudyValue +from clinical_mdr_api.domain_repositories.models.study import StudyValue from clinical_mdr_api.domain_repositories.models.study_audit_trail import ( Create, Delete, @@ -33,7 +35,7 @@ class StudySelectionRepository: We handle common operations here. """ - def _from_repository_values(self, study_uid: str, selection): + def _from_repository_values(self, study_uid: str, selection, selection_vo=None): """Must be defined by subclasses.""" raise NotImplementedError @@ -51,13 +53,15 @@ def perform_save( raise NotImplementedError def save(self, selection_vo, author_id: str): - study_root_node = StudyRoot.nodes.get_or_none(uid=selection_vo.study_uid) - - NotFoundException.raise_if( - study_root_node is None, "Study", selection_vo.study_uid + # Single Cypher to fetch StudyRoot + latest StudyValue (replaces 2 neomodel lookups) + results, _ = db.cypher_query( + "MATCH (sr:StudyRoot {uid: $uid})-[:LATEST]->(sv:StudyValue) RETURN sr, sv", + {"uid": selection_vo.study_uid}, + resolve_objects=True, ) + NotFoundException.raise_if(not results, "Study", selection_vo.study_uid) + study_root_node, latest_study_value_node = results[0] - latest_study_value_node = study_root_node.latest_value.single() new_selection = self.perform_save( latest_study_value_node, selection_vo, author_id ) @@ -83,7 +87,9 @@ def save(self, selection_vo, author_id: str): author_id=author_id, ) - return self._from_repository_values(selection_vo.study_uid, new_selection) + return self._from_repository_values( + selection_vo.study_uid, new_selection, selection_vo=selection_vo + ) def get_study_selection(self, study_value_node: StudyValue, selection_uid: str): """Must be defined by subclasses.""" @@ -91,17 +97,22 @@ def get_study_selection(self, study_value_node: StudyValue, selection_uid: str): @trace_calls(args=[1, 2], kwargs=["study_uid", "selection_uid"]) def delete(self, study_uid: str, selection_uid: str, author_id: str) -> None: - study_root_node = StudyRoot.nodes.get_or_none(uid=study_uid) - - NotFoundException.raise_if(study_root_node is None, "Study", study_uid) + # Single Cypher to fetch StudyRoot + latest StudyValue (replaces 2 neomodel lookups) + results, _ = db.cypher_query( + "MATCH (sr:StudyRoot {uid: $uid})-[:LATEST]->(sv:StudyValue) RETURN sr, sv", + {"uid": study_uid}, + resolve_objects=True, + ) + NotFoundException.raise_if(not results, "Study", study_uid) + study_root_node, latest_study_value_node = results[0] - latest_study_value_node = study_root_node.latest_value.single() selection = self.get_study_selection(latest_study_value_node, selection_uid) selection_vo = self._from_repository_values(study_uid, selection) new_selection = self.perform_save( latest_study_value_node, selection_vo, author_id ) - # Audit trail + # Audit trail — _manage_versioning_with_relations also disconnects `selection` + # from the StudyValue, so we only need to disconnect `new_selection` here. _manage_versioning_with_relations( study_root=study_root_node, action_type=Delete, @@ -111,8 +122,6 @@ def delete(self, study_uid: str, selection_uid: str, author_id: str) -> None: author_id=author_id, ) new_selection.study_value.disconnect(latest_study_value_node) - # Delete relation - selection.study_value.disconnect(latest_study_value_node) def _get_selection_with_history( self, study_uid: str, selection_uid: str | None = None diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_base_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_base_repository.py index 7f719235..6d8662f5 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_base_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_base_repository.py @@ -1,5 +1,6 @@ import abc import datetime +from dataclasses import replace from typing import Any, Generic, TypeVar from neomodel import db @@ -277,9 +278,13 @@ def save( closure_data = study_selection.repository_closure_data closure_data_length = len(closure_data) - # getting the latest study value node - study_root_node: StudyRoot = StudyRoot.nodes.get(uid=study_selection.study_uid) - latest_study_value_node: StudyValue = study_root_node.latest_value.get_or_none() + # Single Cypher to fetch StudyRoot + latest StudyValue (replaces 2 neomodel lookups) + results, _ = db.cypher_query( + "MATCH (sr:StudyRoot {uid: $uid})-[:LATEST]->(sv:StudyValue) RETURN sr, sv", + {"uid": study_selection.study_uid}, + resolve_objects=True, + ) + study_root_node, latest_study_value_node = results[0] # process new/changed/deleted elements for each activity selections_to_remove = [] @@ -362,6 +367,7 @@ def save( ) # loop through and add selections + order_by_uid: dict[str, int] = {} for order, selection in selections_to_add: last_study_selection_node = None if selection.study_selection_uid in audit_trail_nodes: @@ -392,6 +398,19 @@ def save( last_study_selection_node, False, ) + order_by_uid[selection.study_selection_uid] = order + + # Write the DB-assigned order back into the frozen VOs so callers can read the + # correct order directly from the aggregate without a second find_by_study round-trip. + if order_by_uid and self.is_repository_based_on_ordered_selection(): + study_selection.study_objects_selection = [ + ( + replace(sel, order=order_by_uid[sel.study_selection_uid]) + if sel.study_selection_uid in order_by_uid + else sel + ) + for sel in study_selection.study_objects_selection + ] @trace_calls def _get_selection_with_history( diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_group_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_group_repository.py index b137dc42..593fb7bb 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_group_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_group_repository.py @@ -24,7 +24,6 @@ StudySelectionActivityGroupAR, StudySelectionActivityGroupVO, ) -from clinical_mdr_api.utils import unpack_list_of_lists from common.telemetry import trace_calls from common.utils import convert_to_datetime @@ -306,22 +305,26 @@ def get_all_study_activity_groups_for_study_activity( return study_activity_groups[0] return [] - def find_study_activity_group_with_same_groupings( + @trace_calls + def find_study_activity_group_vo_with_same_groupings( self, study_uid: str, activity_group_uid: str, soa_group_term_uid: str, sync_latest_version: bool = False, - ) -> StudyActivityGroup | None: + ) -> StudySelectionActivityGroupVO | None: + """Returns a fully-populated VO for the existing StudyActivityGroup matching the given + groupings, or None if not found. Avoids a full AR load.""" query = """ MATCH (activity_group_root:ActivityGroupRoot)-[:HAS_VERSION]->(activity_group_value:ActivityGroupValue) - <-[:HAS_SELECTED_ACTIVITY_GROUP]-(study_activity_group:StudyActivityGroup)<-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_GROUP] + <-[:HAS_SELECTED_ACTIVITY_GROUP]-(sag:StudyActivityGroup)<-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_GROUP] -(study_activity:StudyActivity)<-[:HAS_STUDY_ACTIVITY]-(:StudyValue)<-[:LATEST]-(:StudyRoot {uid:$study_uid}) - MATCH (study_activity)-[:STUDY_ACTIVITY_HAS_STUDY_SOA_GROUP]->(:StudySoAGroup)-[:HAS_FLOWCHART_GROUP]->(:CTTermContext)-[:HAS_SELECTED_TERM]->(flowchart_group_term:CTTermRoot) - WHERE NOT (study_activity_group)<-[:BEFORE]-() AND NOT (study_activity_group)<-[]-(:Delete) + MATCH (study_activity)-[:STUDY_ACTIVITY_HAS_STUDY_SOA_GROUP]->(soag:StudySoAGroup) + -[:HAS_FLOWCHART_GROUP]->(:CTTermContext)-[:HAS_SELECTED_TERM]->(flowchart_group_term:CTTermRoot) + WHERE NOT (sag)<-[:BEFORE]-() AND NOT (sag)<-[]-(:Delete) AND activity_group_root.uid=$activity_group_uid AND flowchart_group_term.uid=$soa_group_term_uid - WITH DISTINCT study_activity_group, activity_group_root, activity_group_value, $sync_latest_version AS sync_latest_version + WITH DISTINCT sag, activity_group_root, activity_group_value, soag, $sync_latest_version AS sync_latest_version CALL apoc.do.case([ sync_latest_version=true, 'WHERE (activity_group_root)-[:LATEST]->(activity_group_value) RETURN *' @@ -333,9 +336,33 @@ def find_study_activity_group_with_same_groupings( sync_latest_version: sync_latest_version }) YIELD value - RETURN DISTINCT study_activity_group, activity_group_value + WITH DISTINCT sag, activity_group_root, activity_group_value, soag + CALL { + WITH activity_group_root, activity_group_value + MATCH (activity_group_root)-[ver:HAS_VERSION]-(activity_group_value) + WHERE ver.status IN ['Final', 'Retired'] + WITH ver + ORDER BY [i IN split(ver.version, '.') | toInteger(i)] DESC, + ver.end_date DESC, ver.start_date DESC + LIMIT 1 + RETURN ver.version AS activity_group_version + } + MATCH (sag)<-[:AFTER]-(after_action:StudyAction) + RETURN DISTINCT + sag.uid AS study_selection_uid, + coalesce(sag.show_activity_group_in_protocol_flowchart, false) AS show_activity_group_in_protocol_flowchart, + sag.order AS order, + coalesce(sag.accepted_version, false) AS accepted_version, + activity_group_root.uid AS activity_group_uid, + activity_group_value.name AS activity_group_name, + activity_group_version, + soag.uid AS study_soa_group_uid, + after_action.date AS start_date, + after_action.author_id AS author_id + ORDER BY after_action.date DESC + LIMIT 1 """ - study_activity_groups, _ = db.cypher_query( + results, keys = db.cypher_query( query, params={ "study_uid": study_uid, @@ -343,8 +370,22 @@ def find_study_activity_group_with_same_groupings( "soa_group_term_uid": soa_group_term_uid, "sync_latest_version": sync_latest_version, }, - resolve_objects=True, ) - if len(study_activity_groups) > 0: - return unpack_list_of_lists(study_activity_groups)[0] - return None + if not results: + return None + row = dict(zip(keys, results[0])) + return StudySelectionActivityGroupVO.from_input_values( + study_uid=study_uid, + study_selection_uid=row["study_selection_uid"], + activity_group_uid=row["activity_group_uid"], + activity_group_name=row["activity_group_name"], + activity_group_version=row["activity_group_version"], + show_activity_group_in_protocol_flowchart=row[ + "show_activity_group_in_protocol_flowchart" + ], + order=row["order"], + study_soa_group_uid=row["study_soa_group_uid"], + start_date=convert_to_datetime(value=row["start_date"]), + author_id=row["author_id"], + accepted_version=row["accepted_version"], + ) diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_instance_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_instance_repository.py index fb702764..898b2c62 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_instance_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_instance_repository.py @@ -724,95 +724,80 @@ def _add_new_selection( last_study_selection_node: StudyActivityInstance, for_deletion: bool = False, ): - # Fetch nodes referenced by uids - query = [ + # Build a single Cypher statement that MATCHes all dependency nodes, CREATEs the + # StudyActivityInstance node, and CREATEs all its core relationships in one round-trip. + # Optional relationships (baseline visits, supplier, origin_type/source) are handled + # below via separate queries because they require conditional validation logic. + match_lines = [ "MATCH (study_activity:StudyActivity {uid:$study_activity_uid}) WHERE NOT (study_activity)-[:BEFORE]-()", + "MATCH (study_value:StudyValue) WHERE elementId(study_value) = $study_value_eid", ] - params = { + params: dict[str, Any] = { "study_activity_uid": selection.study_activity_uid, + "study_value_eid": latest_study_value_node.element_id, + "sai_uid": selection.study_selection_uid, + "sai_show": selection.show_activity_instance_in_protocol_flowchart, + "sai_keep_old": selection.keep_old_version, + "sai_keep_old_date": selection.keep_old_version_date, + "sai_is_reviewed": selection.is_reviewed, + "sai_is_important": selection.is_important, + "sai_accepted": selection.accepted_version, } - returns = ["study_activity"] + + optional_matches: list[str] = [] + create_instance_rels: list[str] = [] + if selection.activity_instance_uid: if selection.activity_instance_version: - query.append( - """MATCH (instance_root:ActivityInstanceRoot {uid: $activity_instance_uid}) - -[:HAS_VERSION {version: $activity_instance_version}]->(latest_activity_instance_value:ActivityInstanceValue) WITH * LIMIT 1""" + optional_matches.append( + "MATCH (instance_root:ActivityInstanceRoot {uid: $activity_instance_uid})" + "-[:HAS_VERSION {version: $activity_instance_version}]->(latest_activity_instance_value:ActivityInstanceValue)" + " WITH * LIMIT 1" ) params["activity_instance_version"] = ( selection.activity_instance_version ) else: - query.append( + optional_matches.append( "MATCH (instance_root:ActivityInstanceRoot {uid: $activity_instance_uid})-[:LATEST]->(latest_activity_instance_value:ActivityInstanceValue)" ) params["activity_instance_uid"] = selection.activity_instance_uid - returns.append("latest_activity_instance_value") - if selection.study_data_supplier_uid: - query.append( - "MATCH (study_data_supplier:StudyDataSupplier {uid: $study_data_supplier_uid}) WHERE NOT (study_data_supplier)-[:BEFORE]-()" - ) - params["study_data_supplier_uid"] = selection.study_data_supplier_uid - returns.append("study_data_supplier") - if selection.origin_type_uid: - query.append( - "OPTIONAL MATCH (origin_type_root:CTTermRoot {uid: $origin_type_uid})" - ) - params["origin_type_uid"] = selection.origin_type_uid - returns.append("origin_type_root") - if selection.origin_source_uid: - query.append( - "OPTIONAL MATCH (origin_source_root:CTTermRoot {uid: $origin_source_uid})" + create_instance_rels.append( + "CREATE (sai)-[:HAS_SELECTED_ACTIVITY_INSTANCE]->(latest_activity_instance_value)" ) - params["origin_source_uid"] = selection.origin_source_uid - returns.append("origin_source_root") - query.append(f"RETURN {', '.join(returns)}") - query_str = "\n".join(query) - results, keys = db.cypher_query(query_str, params, resolve_objects=True) - if len(results) != 1: - raise exceptions.BusinessLogicException( - msg=f"There should be one row returned with dependencies for StudyActivityInstance '{selection.study_selection_uid}'." - ) - - nodes = dict(zip(keys, results[0])) - latest_activity_instance_value_node: ActivityInstanceValue | None = nodes.get( - "latest_activity_instance_value" + create_node = ( + "CREATE (sai:StudyActivityInstance:StudySelection" + " {uid: $sai_uid," + " show_activity_instance_in_protocol_flowchart: $sai_show," + " keep_old_version: $sai_keep_old, keep_old_version_date: $sai_keep_old_date," + " is_reviewed: $sai_is_reviewed, is_important: $sai_is_important," + " accepted_version: $sai_accepted})" ) - study_activity_node: StudyActivity = nodes["study_activity"] - study_data_supplier_node: StudyDataSupplier | None = nodes.get( - "study_data_supplier" - ) - origin_type_root: CTTermRoot | None = nodes.get("origin_type_root") - origin_source_root: CTTermRoot | None = nodes.get("origin_source_root") - - # Create new activity selection - study_activity_instance_selection_node = StudyActivityInstance( - uid=selection.study_selection_uid, - show_activity_instance_in_protocol_flowchart=selection.show_activity_instance_in_protocol_flowchart, - keep_old_version=selection.keep_old_version, - keep_old_version_date=selection.keep_old_version_date, - is_reviewed=selection.is_reviewed, - is_important=selection.is_important, - accepted_version=selection.accepted_version, - ).save() + create_core_rels = [ + "CREATE (study_activity)-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_INSTANCE]->(sai)", + ] if not for_deletion: - # Connect new node with study value - latest_study_value_node.has_study_activity_instance.connect( - study_activity_instance_selection_node + create_core_rels.append( + "CREATE (study_value)-[:HAS_STUDY_ACTIVITY_INSTANCE]->(sai)" ) - if selection.activity_instance_uid and latest_activity_instance_value_node: - # Connect new node with Activity value - study_activity_instance_selection_node.has_selected_activity_instance.connect( - latest_activity_instance_value_node - ) - - # Connect StudyActivityInstance with StudyActivity node - study_activity_instance_selection_node.study_activity_has_study_activity_instance.connect( - study_activity_node + all_lines = ( + match_lines + + optional_matches + + [create_node] + + create_core_rels + + create_instance_rels + + ["RETURN sai"] ) + results, _ = db.cypher_query("\n".join(all_lines), params, resolve_objects=True) + if not results or not results[0]: + raise exceptions.BusinessLogicException( + msg=f"Failed to create StudyActivityInstance node for '{selection.study_selection_uid}'." + ) + study_activity_instance_selection_node: StudyActivityInstance = results[0][0] - # Handle baseline visit relationships + # Handle baseline visit relationships (require per-visit validation) for baseline_visit in selection.study_activity_instance_baseline_visits or []: baseline_visit_uid = baseline_visit["uid"] baseline_visit_node = latest_study_value_node.has_study_visit.get_or_none( @@ -845,13 +830,27 @@ def _add_new_selection( ) # Connect StudyDataSupplier if provided - if selection.study_data_supplier_uid and study_data_supplier_node: - study_activity_instance_selection_node.has_study_data_supplier.connect( - study_data_supplier_node + if selection.study_data_supplier_uid: + supplier_results, _ = db.cypher_query( + "MATCH (study_data_supplier:StudyDataSupplier {uid: $uid}) WHERE NOT (study_data_supplier)-[:BEFORE]-() RETURN study_data_supplier", + {"uid": selection.study_data_supplier_uid}, + resolve_objects=True, ) + if supplier_results: + study_activity_instance_selection_node.has_study_data_supplier.connect( + supplier_results[0][0] + ) # Connect Origin Type CT term if provided if selection.origin_type_uid: + origin_type_results, _ = db.cypher_query( + "OPTIONAL MATCH (origin_type_root:CTTermRoot {uid: $uid}) RETURN origin_type_root", + {"uid": selection.origin_type_uid}, + resolve_objects=True, + ) + origin_type_root: CTTermRoot | None = None + if origin_type_results and origin_type_results[0][0]: + origin_type_root = origin_type_results[0][0] ValidationException.raise_if( origin_type_root is None, msg=f"Origin Type Term with UID '{selection.origin_type_uid}' doesn't exist.", @@ -868,6 +867,14 @@ def _add_new_selection( # Connect Origin Source CT term if provided if selection.origin_source_uid: + origin_source_results, _ = db.cypher_query( + "OPTIONAL MATCH (origin_source_root:CTTermRoot {uid: $uid}) RETURN origin_source_root", + {"uid": selection.origin_source_uid}, + resolve_objects=True, + ) + origin_source_root: CTTermRoot | None = None + if origin_source_results and origin_source_results[0][0]: + origin_source_root = origin_source_results[0][0] ValidationException.raise_if( origin_source_root is None, msg=f"Origin Source Term with UID '{selection.origin_source_uid}' doesn't exist.", @@ -917,6 +924,49 @@ def get_all_study_activity_instances_for_study_activity( ).distinct() return study_activity_instances + def find_selection_vo_by_uid( + self, + study_uid: str, + study_selection_uid: str, + ) -> StudySelectionActivityInstanceVO | None: + """Fetch a single fully-populated VO by its study_selection_uid. + + Uses the same MATCH/CALL blocks as the listing query but filtered to + one StudyActivityInstance node, avoiding a full aggregate load. + """ + additional_match = self._additional_match() + # Inject the UID filter into the existing WHERE clause that follows + # "WITH sr, sv, sa, study_activity WHERE sa IS NOT NULL" + additional_match = additional_match.replace( + "WHERE sa IS NOT NULL", + "WHERE sa IS NOT NULL AND sa.uid = $study_selection_uid", + 1, + ) + query = ( + "MATCH (sr:StudyRoot {uid: $study_uid})-[:LATEST]->(sv:StudyValue)" + + additional_match + + self._order_by_query() + + self._return_clause() + ) + results = db.cypher_query( + query, + { + "study_uid": study_uid, + "study_selection_uid": study_selection_uid, + "uids": study_uid, + }, + ) + from clinical_mdr_api import ( + utils as _utils, # local to avoid circular at module level + ) + + rows = _utils.db_result_to_list(results) + if not rows: + return None + selection = rows[0] + acv = selection.get("accepted_version", False) or False + return self._create_value_object_from_repository(selection=selection, acv=acv) + def get_all_study_activity_instances_impacted_by_schedule_deletion( self, study_uid: str, schedule_uid: str ) -> list[StudyActivityInstance]: diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_instruction_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_instruction_repository.py index f6a8604a..731df836 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_instruction_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_instruction_repository.py @@ -17,7 +17,7 @@ class StudyActivityInstructionRepository(base.StudySelectionRepository): def _from_repository_values( - self, study_uid: str, selection: StudyActivityInstruction + self, study_uid: str, selection: StudyActivityInstruction, selection_vo=None ) -> StudyActivityInstructionVO: study_action = selection.has_after.all()[0] study_activity = selection.study_activity.single() diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_repository.py index 998a883e..49931f3a 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_repository.py @@ -382,86 +382,80 @@ def _add_new_selection( last_study_selection_node: StudyActivity, for_deletion: bool = False, ): - # Fetch nodes referenced by uids - query = [ - "MATCH (activity_root:ActivityRoot {uid: $activity_uid})-[:HAS_VERSION {version: $activity_version}]->(latest_activity_value:ActivityValue) WITH * LIMIT 1", + # Build a single Cypher statement that MATCHes all dependency nodes, CREATEs the + # StudyActivity node, and CREATEs all its relationships in one round-trip. + match_lines = [ + "MATCH (activity_root:ActivityRoot {uid: $activity_uid})" + "-[:HAS_VERSION {version: $activity_version}]->(latest_activity_value:ActivityValue)" + " WITH * LIMIT 1", + "MATCH (study_value:StudyValue) WHERE elementId(study_value) = $study_value_eid", "MATCH (study_soa_group:StudySoAGroup {uid:$study_soa_group_uid}) WHERE NOT (study_soa_group)-[:BEFORE]-()", ] - params = { - "study_uid": selection.study_uid, + params: dict[str, Any] = { + "sa_uid": selection.study_selection_uid, + "sa_order": order, + "sa_show_activity": selection.show_activity_in_protocol_flowchart, + "sa_keep_old": selection.keep_old_version, + "sa_keep_old_date": selection.keep_old_version_date, + "sa_accepted": selection.accepted_version, "activity_uid": selection.activity_uid, "activity_version": selection.activity_version, "study_soa_group_uid": selection.study_soa_group_uid, + "study_value_eid": latest_study_value_node.element_id, } - returns = ["latest_activity_value", "study_soa_group"] + + optional_matches: list[str] = [] + create_rels: list[str] = [] + if selection.study_activity_subgroup_uid: - query.append( - "MATCH (study_activity_subgroup:StudyActivitySubGroup {uid: $study_activity_subgroup_uid}) WHERE NOT (study_activity_subgroup)-[:BEFORE]-()" + optional_matches.append( + "MATCH (study_activity_subgroup:StudyActivitySubGroup {uid: $study_activity_subgroup_uid})" + " WHERE NOT (study_activity_subgroup)-[:BEFORE]-()" ) params["study_activity_subgroup_uid"] = ( selection.study_activity_subgroup_uid ) - returns.append("study_activity_subgroup") + create_rels.append( + "CREATE (sa)-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_SUBGROUP]->(study_activity_subgroup)" + ) + if selection.study_activity_group_uid: - query.append( - "MATCH (study_activity_group:StudyActivityGroup {uid: $study_activity_group_uid}) WHERE NOT (study_activity_group)-[:BEFORE]-()" + optional_matches.append( + "MATCH (study_activity_group:StudyActivityGroup {uid: $study_activity_group_uid})" + " WHERE NOT (study_activity_group)-[:BEFORE]-()" ) params["study_activity_group_uid"] = selection.study_activity_group_uid - returns.append("study_activity_group") - - query.append(f"RETURN {', '.join(returns)}") - query_str = "\n".join(query) - results, keys = db.cypher_query(query_str, params, resolve_objects=True) - if len(results) != 1: - raise exceptions.BusinessLogicException( - msg=f"There should be one row returned with dependencies for StudyActivity '{selection.study_selection_uid}'." + create_rels.append( + "CREATE (sa)-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_GROUP]->(study_activity_group)" ) - nodes = dict(zip(keys, results[0])) - latest_activity_value_node: ActivityValue = nodes["latest_activity_value"] - study_soa_group_node: StudySoAGroup = nodes["study_soa_group"] - study_activity_subgroup_node: StudyActivitySubGroup | None = nodes.get( - "study_activity_subgroup" - ) - study_activity_group_node: StudyActivityGroup | None = nodes.get( - "study_activity_group" + create_node = ( + "CREATE (sa:StudyActivity:StudySelection {uid: $sa_uid, order: $sa_order," + " show_activity_in_protocol_flowchart: $sa_show_activity," + " keep_old_version: $sa_keep_old, keep_old_version_date: $sa_keep_old_date," + " accepted_version: $sa_accepted})" ) - - # Create new activity selection - study_activity_selection_node = StudyActivity( - uid=selection.study_selection_uid, - order=order, - show_activity_in_protocol_flowchart=selection.show_activity_in_protocol_flowchart, - keep_old_version=selection.keep_old_version, - keep_old_version_date=selection.keep_old_version_date, - accepted_version=selection.accepted_version, - ).save() + create_core_rels = [ + "CREATE (sa)-[:HAS_SELECTED_ACTIVITY]->(latest_activity_value)", + "CREATE (sa)-[:STUDY_ACTIVITY_HAS_STUDY_SOA_GROUP]->(study_soa_group)", + ] if not for_deletion: - # Connect new node with study value - latest_study_value_node.has_study_activity.connect( - study_activity_selection_node - ) - - # Connect new node with Activity value - study_activity_selection_node.has_selected_activity.connect( - latest_activity_value_node - ) - # Connect StudyActivity with StudySoAGroup node - study_activity_selection_node.has_soa_group_selection.connect( - study_soa_group_node + create_core_rels.append("CREATE (study_value)-[:HAS_STUDY_ACTIVITY]->(sa)") + + all_lines = ( + match_lines + + optional_matches + + [create_node] + + create_core_rels + + create_rels + + ["RETURN sa"] ) - - if selection.study_activity_subgroup_uid: - # Connect StudyActivity with StudyActivitySubGroup node - study_activity_selection_node.study_activity_has_study_activity_subgroup.connect( - study_activity_subgroup_node - ) - - if selection.study_activity_group_uid: - # Connect StudyActivity with StudyActivityGroup node - study_activity_selection_node.study_activity_has_study_activity_group.connect( - study_activity_group_node + results, _ = db.cypher_query("\n".join(all_lines), params, resolve_objects=True) + if not results or not results[0]: + raise exceptions.BusinessLogicException( + msg=f"Failed to create StudyActivity node for '{selection.study_selection_uid}'." ) + study_activity_selection_node: StudyActivity = results[0][0] _manage_versioning_with_relations( study_root=study_root, diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_schedule_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_schedule_repository.py index 881c316b..d8094f94 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_schedule_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_schedule_repository.py @@ -4,8 +4,14 @@ from neomodel import RelationshipDefinition, StructuredNode, db from clinical_mdr_api import utils +from clinical_mdr_api.domain_repositories.generic_repository import ( + _manage_versioning_with_relations, +) from clinical_mdr_api.domain_repositories.models._utils import ListDistinct from clinical_mdr_api.domain_repositories.models.study import StudyValue +from clinical_mdr_api.domain_repositories.models.study_audit_trail import ( + Delete as DeleteAction, +) from clinical_mdr_api.domain_repositories.models.study_selections import ( StudyActivity, StudyActivitySchedule, @@ -33,8 +39,18 @@ class SelectionHistory(base.SelectionHistory): class StudyActivityScheduleRepository(base.StudySelectionRepository): def _from_repository_values( - self, study_uid: str, selection: StudyActivitySchedule + self, study_uid: str, selection: StudyActivitySchedule, selection_vo=None ) -> StudyActivityScheduleVO: + # When selection_vo is provided (from save/delete), use its fields directly + # instead of doing 3 separate .single() neomodel lookups. + if selection_vo is not None: + return StudyActivityScheduleVO( + uid=selection.uid, + study_uid=study_uid, + study_activity_uid=selection_vo.study_activity_uid, + study_activity_instance_uid=selection_vo.study_activity_instance_uid, + study_visit_uid=selection_vo.study_visit_uid, + ) study_activity = selection.study_activity.single() study_visit = selection.study_visit.single() @@ -61,7 +77,7 @@ def perform_save( study_value_node: StudyValue, selection_vo: StudyActivityScheduleVO, author_id: str, - ) -> StudyActivityScheduleVO: + ) -> StudyActivitySchedule: # Detach previous node from study if selection_vo.uid is not None: self._remove_old_selection_if_exists(selection_vo.study_uid, selection_vo) @@ -76,34 +92,36 @@ def perform_save( msg=f"There already exist a schedule for the same Activity and Visit in the Study with UID '{selection_vo.study_uid}'", ) - # Create new node - schedule = StudyActivitySchedule(uid=selection_vo.uid).save() - - study_activity_node = study_value_node.has_study_activity.get_or_none( - uid=selection_vo.study_activity_uid - ) - - NotFoundException.raise_if( - study_activity_node is None, - "Study Activity", - selection_vo.study_activity_uid, - ) - - schedule.study_activity.connect(study_activity_node) - - study_visit_node = study_value_node.has_study_visit.get_or_none( - uid=selection_vo.study_visit_uid + # Pre-generate UID so we can CREATE node + all relationships in one round-trip. + uid = ( + selection_vo.uid + or StudyActivitySchedule.get_next_free_uid_and_increment_counter() ) - - NotFoundException.raise_if( - study_visit_node is None, "Study Visit", selection_vo.study_visit_uid + results, _ = db.cypher_query( + """ + MATCH (study_value:StudyValue) WHERE elementId(study_value) = $study_value_eid + MATCH (study_value)-[:HAS_STUDY_ACTIVITY]->(study_activity:StudyActivity {uid: $study_activity_uid}) + MATCH (study_value)-[:HAS_STUDY_VISIT]->(study_visit:StudyVisit {uid: $study_visit_uid}) + CREATE (schedule:StudyActivitySchedule:StudySelection {uid: $uid}) + CREATE (study_value)-[:HAS_STUDY_ACTIVITY_SCHEDULE]->(schedule) + CREATE (study_activity)-[:STUDY_ACTIVITY_HAS_SCHEDULE]->(schedule) + CREATE (study_visit)-[:STUDY_VISIT_HAS_SCHEDULE]->(schedule) + RETURN schedule + """, + { + "study_value_eid": study_value_node.element_id, + "study_activity_uid": selection_vo.study_activity_uid, + "study_visit_uid": selection_vo.study_visit_uid, + "uid": uid, + }, + resolve_objects=True, ) - - # Create relations - schedule.study_visit.connect(study_visit_node) - study_value_node.has_study_activity_schedule.connect(schedule) - - return schedule + if not results or not results[0]: + raise BusinessLogicException( + msg=f"Failed to create StudyActivitySchedule for activity '{selection_vo.study_activity_uid}' " + f"and visit '{selection_vo.study_visit_uid}'." + ) + return results[0][0] def find_schedule_for_study_visit_and_study_activity( self, study_uid: str, study_activity_uid: str, study_visit_uid: str @@ -150,6 +168,54 @@ def get_study_selection( def generate_uid(self) -> str: return StudyActivity.get_next_free_uid_and_increment_counter() + @trace_calls(args=[1, 2], kwargs=["study_uid", "selection_uid"]) + def delete(self, study_uid: str, selection_uid: str, author_id: str) -> None: + # Single Cypher to fetch StudyRoot + latest StudyValue + results, _ = db.cypher_query( + "MATCH (sr:StudyRoot {uid: $uid})-[:LATEST]->(sv:StudyValue) RETURN sr, sv", + {"uid": study_uid}, + resolve_objects=True, + ) + NotFoundException.raise_if(not results, "Study", study_uid) + study_root_node, latest_study_value_node = results[0] + + selection = self.get_study_selection(latest_study_value_node, selection_uid) + + # Single Cypher to fetch related UIDs (replaces 3 .single() calls) + uid_results, _ = db.cypher_query( + """ + MATCH (sas:StudyActivitySchedule) WHERE elementId(sas) = $eid + MATCH (sas)<-[:STUDY_ACTIVITY_HAS_SCHEDULE]-(sa:StudyActivity) + MATCH (sas)<-[:STUDY_VISIT_HAS_SCHEDULE]-(sv:StudyVisit) + OPTIONAL MATCH (sa)-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_INSTANCE]->(sai:StudyActivityInstance) + RETURN sa.uid AS sa_uid, sv.uid AS sv_uid, sai.uid AS sai_uid + """, + {"eid": selection.element_id}, + ) + sa_uid, sv_uid, sai_uid = uid_results[0] if uid_results else (None, None, None) + assert isinstance(sa_uid, str) + selection_vo = StudyActivityScheduleVO( + uid=selection.uid, + study_uid=study_uid, + study_activity_uid=sa_uid, + study_activity_instance_uid=sai_uid, + study_visit_uid=sv_uid, + ) + + new_selection = self.perform_save( + latest_study_value_node, selection_vo, author_id + ) + # Audit trail — _manage_versioning_with_relations also disconnects `selection` + _manage_versioning_with_relations( + study_root=study_root_node, + action_type=DeleteAction, + before=selection, + after=new_selection, + exclude_relationships=self.exclude_relationships(), + author_id=author_id, + ) + new_selection.study_value.disconnect(latest_study_value_node) + def _get_selection_with_history( self, study_uid: str, selection_uid: str | None = None ): diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_subgroup_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_subgroup_repository.py index 94c21ca7..10046152 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_subgroup_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_activity_subgroup_repository.py @@ -23,7 +23,6 @@ StudySelectionActivitySubGroupAR, StudySelectionActivitySubGroupVO, ) -from clinical_mdr_api.utils import unpack_list_of_lists from common.telemetry import trace_calls from common.utils import convert_to_datetime @@ -300,40 +299,68 @@ def get_all_study_activity_subgroups_for_study_activity( return study_activity_subgroups[0] return [] - def find_study_activity_subgroup_with_same_groupings( + @trace_calls + def find_study_activity_subgroup_vo_with_same_groupings( self, study_uid: str, activity_subgroup_uid: str, activity_group_uid: str, soa_group_term_uid: str, sync_latest_version: bool = False, - ) -> StudyActivitySubGroup | None: + ) -> StudySelectionActivitySubGroupVO | None: + """Returns a fully-populated VO for the existing StudyActivitySubGroup matching the given + groupings, or None if not found. Avoids a full AR load.""" query = """ - MATCH (activity_subgroup_root:ActivitySubGroupRoot)-[:HAS_VERSION]->(activity_sub_group_value:ActivitySubGroupValue) - <-[:HAS_SELECTED_ACTIVITY_SUBGROUP]-(study_activity_subgroup:StudyActivitySubGroup)<-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_SUBGROUP] + MATCH (activity_subgroup_root:ActivitySubGroupRoot)-[:HAS_VERSION]->(activity_subgroup_value:ActivitySubGroupValue) + <-[:HAS_SELECTED_ACTIVITY_SUBGROUP]-(sasg:StudyActivitySubGroup)<-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_SUBGROUP] -(study_activity:StudyActivity)<-[:HAS_STUDY_ACTIVITY]-(:StudyValue)<-[:LATEST]-(:StudyRoot {uid:$study_uid}) - MATCH (study_activity)-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_GROUP]->(:StudyActivityGroup) + MATCH (study_activity)-[:STUDY_ACTIVITY_HAS_STUDY_ACTIVITY_GROUP]->(sag:StudyActivityGroup) -[:HAS_SELECTED_ACTIVITY_GROUP]->(:ActivityGroupValue)<-[:HAS_VERSION]-(activity_group_root:ActivityGroupRoot) - MATCH (study_activity)-[:STUDY_ACTIVITY_HAS_STUDY_SOA_GROUP]->(:StudySoAGroup)-[:HAS_FLOWCHART_GROUP]->(:CTTermContext)-[:HAS_SELECTED_TERM]->(flowchart_group_term:CTTermRoot) - WHERE NOT (study_activity_subgroup)<-[:BEFORE]-() AND NOT (study_activity_subgroup)<-[]-(:Delete) + MATCH (study_activity)-[:STUDY_ACTIVITY_HAS_STUDY_SOA_GROUP]->(:StudySoAGroup) + -[:HAS_FLOWCHART_GROUP]->(:CTTermContext)-[:HAS_SELECTED_TERM]->(flowchart_group_term:CTTermRoot) + WHERE NOT (sasg)<-[:BEFORE]-() AND NOT (sasg)<-[]-(:Delete) AND activity_subgroup_root.uid=$activity_subgroup_uid AND activity_group_root.uid=$activity_group_uid AND flowchart_group_term.uid=$soa_group_term_uid - WITH DISTINCT study_activity_subgroup, activity_subgroup_root, activity_sub_group_value, $sync_latest_version AS sync_latest_version + WITH DISTINCT sasg, activity_subgroup_root, activity_subgroup_value, sag, $sync_latest_version AS sync_latest_version CALL apoc.do.case([ sync_latest_version=true, - 'WHERE (activity_subgroup_root)-[:LATEST]->(activity_sub_group_value) RETURN *' + 'WHERE (activity_subgroup_root)-[:LATEST]->(activity_subgroup_value) RETURN *' ], '', { activity_subgroup_root: activity_subgroup_root, - activity_sub_group_value: activity_sub_group_value, + activity_subgroup_value: activity_subgroup_value, sync_latest_version: sync_latest_version }) YIELD value - RETURN DISTINCT study_activity_subgroup, activity_sub_group_value + WITH DISTINCT sasg, activity_subgroup_root, activity_subgroup_value, sag + CALL { + WITH activity_subgroup_root, activity_subgroup_value + MATCH (activity_subgroup_root)-[ver:HAS_VERSION]-(activity_subgroup_value) + WHERE ver.status IN ['Final', 'Retired'] + WITH ver + ORDER BY [i IN split(ver.version, '.') | toInteger(i)] DESC, + ver.end_date DESC, ver.start_date DESC + LIMIT 1 + RETURN ver.version AS activity_subgroup_version + } + MATCH (sasg)<-[:AFTER]-(after_action:StudyAction) + RETURN DISTINCT + sasg.uid AS study_selection_uid, + COALESCE(sasg.show_activity_subgroup_in_protocol_flowchart, false) AS show_activity_subgroup_in_protocol_flowchart, + sasg.order AS order, + coalesce(sasg.accepted_version, false) AS accepted_version, + activity_subgroup_root.uid AS activity_subgroup_uid, + activity_subgroup_value.name AS activity_subgroup_name, + activity_subgroup_version, + sag.uid AS study_activity_group_uid, + after_action.date AS start_date, + after_action.author_id AS author_id + ORDER BY after_action.date DESC + LIMIT 1 """ - study_activity_subgroups, _ = db.cypher_query( + results, keys = db.cypher_query( query, params={ "study_uid": study_uid, @@ -342,8 +369,22 @@ def find_study_activity_subgroup_with_same_groupings( "soa_group_term_uid": soa_group_term_uid, "sync_latest_version": sync_latest_version, }, - resolve_objects=True, ) - if len(study_activity_subgroups) > 0: - return unpack_list_of_lists(study_activity_subgroups)[0] - return None + if not results: + return None + row = dict(zip(keys, results[0])) + return StudySelectionActivitySubGroupVO.from_input_values( + study_uid=study_uid, + study_selection_uid=row["study_selection_uid"], + activity_subgroup_uid=row["activity_subgroup_uid"], + activity_subgroup_name=row["activity_subgroup_name"], + activity_subgroup_version=row["activity_subgroup_version"], + show_activity_subgroup_in_protocol_flowchart=row[ + "show_activity_subgroup_in_protocol_flowchart" + ], + order=row["order"], + study_activity_group_uid=row["study_activity_group_uid"], + start_date=convert_to_datetime(value=row["start_date"]), + author_id=row["author_id"], + accepted_version=row["accepted_version"], + ) diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_definition_document_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_definition_document_repository.py index 4b2cc0dd..9e658bb6 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_definition_document_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_definition_document_repository.py @@ -15,6 +15,7 @@ ) from clinical_mdr_api.services._utils import ensure_transaction from common.auth.user import user +from common.config import settings class StudyDefinitionDocumentRepository: @@ -69,6 +70,28 @@ def get_latest_protocol_header_version( study_definition_document = result[0][0] return f"{study_definition_document.protocol_header_major_version}.{study_definition_document.protocol_header_minor_version}" + def has_final_protocol_locked_version( + self, study_uid: str, study_value_version: str | None = None + ) -> bool: + params: dict[str, str | int | None] = { + "study_uid": study_uid, + "final_protocol_submval": settings.final_protocol_term_submval, + } + if study_value_version: + params["version"] = study_value_version + query = "MATCH (:StudyRoot {uid: $study_uid})-[:HAS_VERSION{status:'LOCKED',version:$version}]->(:StudyValue)" + else: + query = "MATCH (:StudyRoot {uid: $study_uid})-[:HAS_VERSION{status:'LOCKED'}]->(:StudyValue)" + query += """ + -[:HAS_STUDY_VERSION]->(sv:StudyVersion) + -[:HAS_REASON_FOR_LOCK]->(:CTTermContext)-[:HAS_SELECTED_TERM]->(term_root:CTTermRoot) + <-[:HAS_TERM_ROOT]-(codelist_term:CTCodelistTerm) + WHERE codelist_term.submission_value = $final_protocol_submval + RETURN count(sv) > 0 AS has_final_protocol + """ + result, _ = db.cypher_query(query, params=params) + return bool(result and result[0] and result[0][0]) + @ensure_transaction(db) def create_or_update_study_definition_document( self, diff --git a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_visit_repository.py b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_visit_repository.py index 71fe7ea9..cb3ba5bf 100644 --- a/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_visit_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/domain_repositories/study_selections/study_visit_repository.py @@ -728,10 +728,24 @@ def list_all_visits_query( query.append(dedent(""" RETURN - study_visit { .*, + { + uid: study_visit.uid, + show_visit: coalesce(study_visit.show_visit, false), + is_global_anchor_visit: coalesce(study_visit.is_global_anchor_visit, false), + is_soa_milestone: coalesce(study_visit.is_soa_milestone, false), + visit_number: study_visit.visit_number, + unique_visit_number: toInteger(study_visit.unique_visit_number), + visit_class: study_visit.visit_class, + visit_subclass: study_visit.visit_subclass, + short_visit_label: study_visit.short_visit_label, + visit_name_label: study_visit.visit_name_label, + description: study_visit.description, + start_rule: study_visit.start_rule, + end_rule: study_visit.end_rule, + status: study_visit.status, consecutive_visit_group: CASE group.visit_group.group_format - WHEN "range" THEN head(group.consecutive_visits).vis.short_visit_label + "-" + last(group.consecutive_visits).vis.short_visit_label - WHEN "list" THEN apoc.text.join([visit in group.consecutive_visits | visit.vis.short_visit_label], ',') + WHEN "range" THEN head(group.consecutive_visits).vis.short_visit_label + "-" + last(group.consecutive_visits).vis.short_visit_label + WHEN "list" THEN apoc.text.join([visit in group.consecutive_visits | visit.vis.short_visit_label], ',') ELSE null END, consecutive_visit_group_uid: group.visit_group.uid, visit_short_name: study_visit.short_visit_label, diff --git a/clinical-mdr-api/clinical_mdr_api/domains/controlled_terminologies/ct_codelist_attributes.py b/clinical-mdr-api/clinical_mdr_api/domains/controlled_terminologies/ct_codelist_attributes.py index 3a3e6ece..2a5f4b11 100644 --- a/clinical-mdr-api/clinical_mdr_api/domains/controlled_terminologies/ct_codelist_attributes.py +++ b/clinical-mdr-api/clinical_mdr_api/domains/controlled_terminologies/ct_codelist_attributes.py @@ -21,11 +21,13 @@ @dataclass(frozen=True) class CTPairedCodelists: """ - Small class to hold the paired codelists UIDs + Small class to hold the paired codelists UIDs and names """ paired_names_codelist_uid: str | None paired_codes_codelist_uid: str | None + paired_names_codelist_name: str | None = None + paired_codes_codelist_name: str | None = None @dataclass(frozen=True) diff --git a/clinical-mdr-api/clinical_mdr_api/domains/study_definition_aggregates/study_template.py b/clinical-mdr-api/clinical_mdr_api/domains/study_definition_aggregates/study_template.py new file mode 100644 index 00000000..aa9bc81f --- /dev/null +++ b/clinical-mdr-api/clinical_mdr_api/domains/study_definition_aggregates/study_template.py @@ -0,0 +1,124 @@ +from dataclasses import dataclass, field +from typing import Any, Self + +from clinical_mdr_api.domains.versioned_object_aggregate import ( + LibraryItemMetadataVO, + ObjectAction, + VersioningActionMixin, +) +from common.exceptions import BusinessLogicException + + +@dataclass(frozen=True) +class StudyTemplateValueVO: + study_uid: str + study_value_version: str + + @classmethod + def from_input_values( + cls, + *, + study_uid: str, + study_value_version: str, + ) -> Self: + return cls.from_repository_values( + study_uid=study_uid, + study_value_version=study_value_version, + ) + + @classmethod + def from_repository_values( + cls, + *, + study_uid: str, + study_value_version: str, + ) -> Self: + return cls( + study_uid=study_uid, + study_value_version=study_value_version, + ) + + +@dataclass +class StudyTemplateAR(VersioningActionMixin): + _value: StudyTemplateValueVO + _item_metadata: LibraryItemMetadataVO + _uid: str | None = None + _is_deleted: bool = field(init=False, default=False) + repository_closure_data: Any = field( + init=False, compare=False, repr=True, default=None + ) + + def get_possible_actions(self) -> set[ObjectAction]: + raise NotImplementedError("Possible actions retrieval not implemented.") + + @property + def item_metadata(self) -> LibraryItemMetadataVO: + return self._item_metadata + + @property + def value(self) -> StudyTemplateValueVO: + return self._value + + @property + def uid(self) -> str | None: + return self._uid + + @property + def is_deleted(self) -> bool: + return self._is_deleted + + def create_new_version(self, author_id: str): + super()._create_new_version(author_id) + + def edit_draft( + self, + *, + author_id: str, + change_description: str, + new_study_template_value: StudyTemplateValueVO, + ) -> None: + if self._value != new_study_template_value: + super()._edit_draft( + author_id=author_id, + change_description=change_description, + ) + self._value = new_study_template_value + + @classmethod + def from_input_values( + cls, + *, + author_id: str, + study_template_value: StudyTemplateValueVO, + generate_uid_callback=lambda: None, + ) -> Self: + return cls( + _uid=generate_uid_callback(), + _value=study_template_value, + _item_metadata=LibraryItemMetadataVO.get_initial_item_metadata( + author_id=author_id + ), + ) + + @classmethod + def from_repository_values( + cls, + *, + uid: str, + item_metadata: LibraryItemMetadataVO, + study_template_value: StudyTemplateValueVO, + ) -> Self: + return cls( + _uid=uid, + _item_metadata=item_metadata, + _value=study_template_value, + ) + + def _is_edit_allowed_in_non_editable_library(self): + return True + + def soft_delete(self): + raise BusinessLogicException( + msg="Deleting the Study Template configuration is not supported." + ) diff --git a/clinical-mdr-api/clinical_mdr_api/domains/study_selections/study_visit.py b/clinical-mdr-api/clinical_mdr_api/domains/study_selections/study_visit.py index d68168b8..7db0ac54 100644 --- a/clinical-mdr-api/clinical_mdr_api/domains/study_selections/study_visit.py +++ b/clinical-mdr-api/clinical_mdr_api/domains/study_selections/study_visit.py @@ -200,7 +200,7 @@ def visit_short_name(self): ] return visit_short_name + chosen_letter if self.visit_class in [VisitClass.UNSCHEDULED_VISIT, VisitClass.NON_VISIT]: - return visit_number + return f"V{visit_number}" return visit_short_name return self.vis_short_name diff --git a/clinical-mdr-api/clinical_mdr_api/models/biomedical_concepts/activity_item_class.py b/clinical-mdr-api/clinical_mdr_api/models/biomedical_concepts/activity_item_class.py index 25dcc524..b748132a 100644 --- a/clinical-mdr-api/clinical_mdr_api/models/biomedical_concepts/activity_item_class.py +++ b/clinical-mdr-api/clinical_mdr_api/models/biomedical_concepts/activity_item_class.py @@ -463,8 +463,7 @@ def from_codelist_and_terms( library_name=cl_name_and_attrs.library_name, name=cl_name_and_attrs.name, attributes=cl_name_and_attrs.attributes, - paired_codes_codelist_uid=cl_name_and_attrs.paired_codes_codelist_uid, - paired_names_codelist_uid=cl_name_and_attrs.paired_names_codelist_uid, + paired_codelist=cl_name_and_attrs.paired_codelist, term_uids=term_uids, ) diff --git a/clinical-mdr-api/clinical_mdr_api/models/complexity_score.py b/clinical-mdr-api/clinical_mdr_api/models/complexity_score.py index f8e084c1..8dc3d65b 100644 --- a/clinical-mdr-api/clinical_mdr_api/models/complexity_score.py +++ b/clinical-mdr-api/clinical_mdr_api/models/complexity_score.py @@ -37,6 +37,23 @@ def from_dict(cls, data: Any) -> Self: ) +class ComplexityScoreVisit(BaseModel): + type: Annotated[str, Field()] + count: Annotated[int, Field()] + burden: Annotated[float, Field()] + + +class ComplexityScoreAssessment(BaseModel): + type: Annotated[str, Field()] + count: Annotated[int, Field()] + burden: Annotated[float, Field()] + + +class ComplexityScoreDetails(BaseModel): + visits: Annotated[list[ComplexityScoreVisit], Field()] + assessments: Annotated[list[ComplexityScoreAssessment], Field()] + + class BurdenInput(PostInputModel): burden_id: Annotated[str, Field()] name: Annotated[str, Field()] diff --git a/clinical-mdr-api/clinical_mdr_api/models/concepts/activities/activity.py b/clinical-mdr-api/clinical_mdr_api/models/concepts/activities/activity.py index db854f5b..e761aee9 100644 --- a/clinical-mdr-api/clinical_mdr_api/models/concepts/activities/activity.py +++ b/clinical-mdr-api/clinical_mdr_api/models/concepts/activities/activity.py @@ -62,9 +62,12 @@ def from_activity_uid( if uid is not None: activity = find_activity_by_uid(uid, version=version) if activity is not None: - simple_activity_model = cls(uid=uid, name=activity.concept_vo.name) + resolved_version = version or activity.item_metadata.version + simple_activity_model = cls( + uid=uid, name=activity.concept_vo.name, version=resolved_version + ) else: - simple_activity_model = cls(uid=uid, name=None) + simple_activity_model = cls(uid=uid, name=None, version=version) return simple_activity_model @classmethod diff --git a/clinical-mdr-api/clinical_mdr_api/models/concepts/activities/activity_instance.py b/clinical-mdr-api/clinical_mdr_api/models/concepts/activities/activity_instance.py index ba80c8d5..1420bb34 100644 --- a/clinical-mdr-api/clinical_mdr_api/models/concepts/activities/activity_instance.py +++ b/clinical-mdr-api/clinical_mdr_api/models/concepts/activities/activity_instance.py @@ -316,14 +316,17 @@ def from_activity_ar( activity_group=ActivityHierarchySimpleModel( uid=activity_grouping.activity_group_uid, name=activity_grouping.activity_group_name, + version=activity_grouping.activity_group_version, ), activity_subgroup=ActivityHierarchySimpleModel( uid=activity_grouping.activity_subgroup_uid, name=activity_grouping.activity_subgroup_name, + version=activity_grouping.activity_subgroup_version, ), activity=ActivityHierarchySimpleModel( uid=activity_grouping.activity_uid or "", name=activity_grouping.activity_name, + version=activity_grouping.activity_version, ), ) else: @@ -370,14 +373,17 @@ def from_activity_instance_ar_objects( activity_group=ActivityHierarchySimpleModel( uid=activity_instance_grouping_vo.activity_group_uid, name=activity_instance_grouping_vo.activity_group_name, + version=activity_instance_grouping_vo.activity_group_version, ), activity_subgroup=ActivityHierarchySimpleModel( uid=activity_instance_grouping_vo.activity_subgroup_uid, name=activity_instance_grouping_vo.activity_subgroup_name, + version=activity_instance_grouping_vo.activity_subgroup_version, ), activity=ActivityHierarchySimpleModel( uid=activity_instance_grouping_vo.activity_uid or "", name=activity_instance_grouping_vo.activity_name, + version=activity_instance_grouping_vo.activity_version, ), ) for activity_instance_grouping_vo in activity_instance_ar.concept_vo.activity_groupings @@ -568,14 +574,17 @@ def from_activity_ar( activity_group=ActivityHierarchySimpleModel( uid=activity_grouping.activity_group_uid, name=activity_grouping.activity_group_name, + version=activity_grouping.activity_group_version, ), activity_subgroup=ActivityHierarchySimpleModel( uid=activity_grouping.activity_subgroup_uid, name=activity_grouping.activity_subgroup_name, + version=activity_grouping.activity_subgroup_version, ), activity=ActivityHierarchySimpleModel( uid=activity_grouping.activity_uid or "", name=activity_grouping.activity_name, + version=activity_grouping.activity_version, ), ) else: @@ -690,14 +699,17 @@ def from_activity_instance_ar_objects( activity_group=ActivityHierarchySimpleModel( uid=activity_instance_grouping_vo.activity_group_uid, name=activity_instance_grouping_vo.activity_group_name, + version=activity_instance_grouping_vo.activity_group_version, ), activity_subgroup=ActivityHierarchySimpleModel( uid=activity_instance_grouping_vo.activity_subgroup_uid, name=activity_instance_grouping_vo.activity_subgroup_name, + version=activity_instance_grouping_vo.activity_subgroup_version, ), activity=ActivityHierarchySimpleModel( uid=activity_instance_grouping_vo.activity_uid or "", name=activity_instance_grouping_vo.activity_name, + version=activity_instance_grouping_vo.activity_version, ), ) for activity_instance_grouping_vo in activity_instance_ar.concept_vo.activity_groupings diff --git a/clinical-mdr-api/clinical_mdr_api/models/controlled_terminologies/ct_codelist.py b/clinical-mdr-api/clinical_mdr_api/models/controlled_terminologies/ct_codelist.py index 80c18aba..b2fe7bf4 100644 --- a/clinical-mdr-api/clinical_mdr_api/models/controlled_terminologies/ct_codelist.py +++ b/clinical-mdr-api/clinical_mdr_api/models/controlled_terminologies/ct_codelist.py @@ -140,14 +140,38 @@ class CTCodelistCreateInput(PostInputModel): library_name: Annotated[str, Field(min_length=1)] +class CTCodelistCodelistInformation(PostInputModel): + name: Annotated[str, Field(min_length=1)] + submission_value: Annotated[str, Field(min_length=1)] + nci_preferred_name: Annotated[str | None, Field(min_length=1)] = None + definition: Annotated[str, Field(min_length=1)] + sponsor_preferred_name: Annotated[str, Field(min_length=1)] + + +class CTPairedCodelistCreateInput(PostInputModel): + catalogue_names: Annotated[list[str], Field()] + name_information: Annotated[CTCodelistCodelistInformation, Field()] + code_information: Annotated[CTCodelistCodelistInformation, Field()] + extensible: Annotated[bool, Field()] + is_ordinal: Annotated[bool, Field()] + codelist_type: Annotated[str, Field()] = DEFAULT_CODELIST_TYPE + template_parameter: Annotated[bool, Field()] + parent_codelist_uid: Annotated[str | None, Field(min_length=1)] = None + library_name: Annotated[str, Field(min_length=1)] + + +class CTPairedCodelistInfo(BaseModel): + uid: Annotated[str, Field()] + name: Annotated[str | None, Field(json_schema_extra={"nullable": True})] = None + + class CTCodelistNameAndAttributes(BaseModel): @classmethod def from_ct_codelist_ar( cls, ct_codelist_name_ar: CTCodelistNameAR, ct_codelist_attributes_ar: CTCodelistAttributesAR, - paired_codes_codelist_uid: str | None = None, - paired_names_codelist_uid: str | None = None, + paired_codelist: CTPairedCodelistInfo | None = None, ) -> Self: codelist_name_and_attributes = cls( catalogue_names=ct_codelist_attributes_ar.ct_codelist_vo.catalogue_names, @@ -163,8 +187,7 @@ def from_ct_codelist_ar( attributes=CTCodelistAttributes.from_ct_codelist_ar_without_common_codelist_fields( ct_codelist_attributes_ar ), - paired_codes_codelist_uid=paired_codes_codelist_uid, - paired_names_codelist_uid=paired_names_codelist_uid, + paired_codelist=paired_codelist, ) return codelist_name_and_attributes @@ -174,8 +197,7 @@ def from_name_and_attributes( cls, ct_codelist_name: CTCodelistName, ct_codelist_attributes: CTCodelistAttributes, - paired_codes_codelist_uid: str | None = None, - paired_names_codelist_uid: str | None = None, + paired_codelist: CTPairedCodelistInfo | None = None, ) -> Self: codelist_name_and_attributes = cls( catalogue_names=ct_codelist_attributes.catalogue_names, @@ -185,8 +207,7 @@ def from_name_and_attributes( library_name=ct_codelist_attributes.library_name, name=ct_codelist_name, attributes=ct_codelist_attributes, - paired_codes_codelist_uid=paired_codes_codelist_uid, - paired_names_codelist_uid=paired_names_codelist_uid, + paired_codelist=paired_codelist, ) return codelist_name_and_attributes @@ -211,12 +232,8 @@ def from_name_and_attributes( attributes: Annotated[CTCodelistAttributes, Field()] - paired_codes_codelist_uid: Annotated[ - str | None, Field(json_schema_extra={"nullable": True}) - ] = None - - paired_names_codelist_uid: Annotated[ - str | None, Field(json_schema_extra={"nullable": True}) + paired_codelist: Annotated[ + CTPairedCodelistInfo | None, Field(json_schema_extra={"nullable": True}) ] = None diff --git a/clinical-mdr-api/clinical_mdr_api/models/feature_flag.py b/clinical-mdr-api/clinical_mdr_api/models/feature_flag.py index 81a2c02b..37839f30 100644 --- a/clinical-mdr-api/clinical_mdr_api/models/feature_flag.py +++ b/clinical-mdr-api/clinical_mdr_api/models/feature_flag.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Literal from pydantic import Field @@ -7,6 +7,8 @@ class FeatureFlag(BaseModel): sn: Annotated[int, Field()] + section: Annotated[str, Field()] + feature: Annotated[str, Field()] name: Annotated[str, Field()] enabled: Annotated[bool, Field()] description: Annotated[str | None, Field(json_schema_extra={"nullable": True})] = ( @@ -15,10 +17,14 @@ class FeatureFlag(BaseModel): class FeatureFlagInput(PostInputModel): + section: Annotated[Literal["admin", "library", "studies"], Field()] + feature: Annotated[str, Field(min_length=1)] name: Annotated[str, Field(min_length=1)] enabled: Annotated[bool, Field()] description: Annotated[str | None, Field(min_length=1)] = None class FeatureFlagPatchInput(PatchInputModel): - enabled: Annotated[bool, Field()] = False + section: Annotated[Literal["admin", "library", "studies"] | None, Field()] = None + feature: Annotated[str | None, Field(min_length=1)] = None + enabled: Annotated[bool | None, Field()] = None diff --git a/clinical-mdr-api/clinical_mdr_api/models/study_selections/study.py b/clinical-mdr-api/clinical_mdr_api/models/study_selections/study.py index 14771098..1f15ddf0 100644 --- a/clinical-mdr-api/clinical_mdr_api/models/study_selections/study.py +++ b/clinical-mdr-api/clinical_mdr_api/models/study_selections/study.py @@ -4,7 +4,7 @@ from decimal import Decimal from typing import Annotated, Any, Callable, Collection, Self, overload -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from clinical_mdr_api.descriptions.general import CHANGES_FIELD_DESC from clinical_mdr_api.domain_repositories.controlled_terminologies import ( @@ -18,7 +18,7 @@ ) from clinical_mdr_api.domains.controlled_terminologies.ct_term_name import CTTermNameAR from clinical_mdr_api.domains.dictionaries.dictionary_term import DictionaryTermAR -from clinical_mdr_api.domains.enums import ValidationMode +from clinical_mdr_api.domains.enums import LibraryItemStatus, ValidationMode from clinical_mdr_api.domains.projects.project import ProjectAR from clinical_mdr_api.domains.study_definition_aggregates.registry_identifiers import ( RegistryIdentifiersVO, @@ -40,7 +40,12 @@ SimpleTermModel, ) from clinical_mdr_api.models.study_selections.duration import DurationJsonModel -from clinical_mdr_api.models.utils import BaseModel, PatchInputModel, PostInputModel +from clinical_mdr_api.models.utils import ( + BaseModel, + EditInputModel, + PatchInputModel, + PostInputModel, +) from common.config import settings from common.exceptions import ( BusinessLogicException, @@ -137,6 +142,49 @@ class StudySoaSplit(StudySoaSplitInput): study_uid: Annotated[str, Field(description="Uid of study")] +class StudyTemplateInput(PostInputModel): + study_uid: Annotated[str, Field(min_length=1, description="Uid of template study")] + study_value_version: Annotated[ + str, + Field( + min_length=1, + description="Study value version of template study, e.g. '1.0'", + ), + ] + + +class StudyTemplatePatchInput(EditInputModel): + study_uid: Annotated[ + str, + Field( + description="Uid of template study; empty string clears the configured target", + ), + ] + study_value_version: Annotated[ + str, + Field( + description="Released study value version; required when study_uid is non-empty", + ), + ] + + @model_validator(mode="after") + def _study_version_required_when_study_uid_set(self) -> Self: + if self.study_uid.strip() and not self.study_value_version.strip(): + raise ValueError( + "study_value_version is required when study_uid is non-empty" + ) + return self + + +class StudyTemplate(BaseModel): + uid: Annotated[str, Field(description="Uid of study template root")] + study_uid: Annotated[str, Field(description="Uid of template study")] + study_value_version: Annotated[str, Field(description="Study value version")] + status: Annotated[LibraryItemStatus, Field(description="Template item status")] + version: Annotated[str, Field(description="Template item version")] + change_description: Annotated[str, Field(description="Version change description")] + + class RegistryIdentifiersJsonModel(BaseModel): ct_gov_id: Annotated[str | None, Field(json_schema_extra={"nullable": True})] = None @@ -1771,6 +1819,9 @@ class VersionInfo(BaseModel): ] = None number: Annotated[str | None, Field(json_schema_extra={"nullable": True})] = None title: Annotated[str | None, Field(json_schema_extra={"nullable": True})] = None + description: Annotated[str | None, Field(json_schema_extra={"nullable": True})] = ( + None + ) subpart_id: Annotated[str | None, Field(json_schema_extra={"nullable": True})] = ( None ) @@ -1810,6 +1861,7 @@ def from_input( id=val["id"], main_id=val["main_id"], title=val.get("title"), + description=val.get("description"), subpart_id=val.get("subpart_id"), subpart_acronym=val.get("subpart_acronym"), clinical_programme_name=val["clinical_programme_name"], @@ -1936,6 +1988,70 @@ class StudyStructureStatistics(BaseModel): visit_footnote_count: Annotated[ int, Field(description="Number of connected footnotes (visit level)") ] + study_activity_count: Annotated[ + int, Field(description="Number of connected study activities") + ] + study_activity_schedule_count: Annotated[ + int, Field(description="Number of connected study activity schedules") + ] + + +class StudySelectionContainmentLabelRow(BaseModel): + """Per-label comparison when checking if target is contained in source.""" + + label: Annotated[str, Field(description="Concrete study selection label")] + target_selection_count: Annotated[int, Field(description="Selections on target")] + source_selection_count: Annotated[int, Field(description="Selections on source")] + target_distinct_ct_term_root_count: Annotated[ + int, + Field( + description="Distinct CT term roots on target (via CTTermContext on selections)" + ), + ] + source_distinct_ct_term_root_count: Annotated[ + int, + Field( + description="Distinct CT term roots on source (via CTTermContext on selections)" + ), + ] + label_contained: Annotated[ + bool, + Field( + description="True if target selection and term counts are <= source for this label" + ), + ] + + +class StudySelectionContainmentResult(BaseModel): + """ + Whether the **target** study's selection statistics are contained in the **source**. + + Only labels that appear on the **target** study are considered (except + ``StudySoAFootnote``, which is skipped because SoA footnotes are copied + conditionally). Containment means for each such label, target counts are less + than or equal to source counts (selection nodes and distinct CT term roots via + CTTermContext). + """ + + target_contained_in_source: Annotated[ + bool, + Field( + description="True if containment holds for every compared label present on target" + ), + ] + labels_from_target: Annotated[ + list[str], + Field( + description=( + "Concrete selection labels on the target used in the comparison " + "(excludes StudySoAFootnote)" + ) + ), + ] + per_label: Annotated[ + list[StudySelectionContainmentLabelRow], + Field(description="Breakdown per label"), + ] class StudyCreateInput(PostInputModel): @@ -1958,6 +2074,12 @@ class StudyCloneInput(StudyCreateInput): copy_study_epoch: Annotated[bool, Field()] = False copy_study_epochs_study_footnote: Annotated[bool, Field()] = False copy_study_design_matrix: Annotated[bool, Field()] = False + copy_study_soa_group: Annotated[bool, Field()] = False + copy_study_activity: Annotated[bool, Field()] = False + copy_study_activity_instance: Annotated[bool, Field()] = False + copy_study_activity_group: Annotated[bool, Field()] = False + copy_study_activity_subgroup: Annotated[bool, Field()] = False + copy_study_activity_schedule: Annotated[bool, Field()] = False validation_mode: Annotated[ValidationMode, Field()] = ValidationMode.STRICT @@ -2077,6 +2199,22 @@ def from_study_field_audit_trail_vo( ) +class StudyProtocolHeaderVersion(BaseModel): + protocol_header_version: Annotated[ + str | None, + Field( + description="The latest available protocol header version.", + json_schema_extra={"nullable": True}, + ), + ] = None + has_final_protocol_locked_version: Annotated[ + bool, + Field( + description="Indicates whether the study contains a locked version with 'Final Protocol' as the reason for lock.", + ), + ] = False + + class StudyProtocolTitle(BaseModel): study_uid: Annotated[ str | None, diff --git a/clinical-mdr-api/clinical_mdr_api/models/study_selections/study_selection.py b/clinical-mdr-api/clinical_mdr_api/models/study_selections/study_selection.py index e5400649..64f64fc8 100644 --- a/clinical-mdr-api/clinical_mdr_api/models/study_selections/study_selection.py +++ b/clinical-mdr-api/clinical_mdr_api/models/study_selections/study_selection.py @@ -2718,7 +2718,11 @@ def activity_instance_from_study_activity_instance_vo( test_name_code=study_activity_instance_vo.activity_instance_test_name_code, standard_unit=study_activity_instance_vo.activity_instance_standard_unit, version=study_activity_instance_vo.activity_instance_version, - status=study_activity_instance_vo.activity_instance_status.value, + status=( + study_activity_instance_vo.activity_instance_status.value + if study_activity_instance_vo.activity_instance_status + else None + ), is_default_selected_for_activity=study_activity_instance_vo.activity_instance_is_default_selected_for_activity, is_required_for_activity=study_activity_instance_vo.activity_instance_is_required_for_activity, ) @@ -2736,7 +2740,11 @@ def latest_activity_instance_from_study_activity_instance_vo( name=study_activity_instance_vo.latest_activity_instance_class_name, ), version=study_activity_instance_vo.latest_activity_instance_version, - status=study_activity_instance_vo.latest_activity_instance_status.value, + status=( + study_activity_instance_vo.latest_activity_instance_status.value + if study_activity_instance_vo.latest_activity_instance_status + else None + ), ) @@ -3088,8 +3096,8 @@ def from_study_selection_activity_instance_vo_and_order( [ SimpleStudyVisit( uid=baseline_visit["uid"], - visit_name=baseline_visit["visit_name"], - visit_type_name=baseline_visit["visit_type_name"], + visit_name=baseline_visit.get("visit_name") or "", + visit_type_name=baseline_visit.get("visit_type_name") or "", ) for baseline_visit in ( study_selection.study_activity_instance_baseline_visits or [] diff --git a/clinical-mdr-api/clinical_mdr_api/repositories/ct_catalogues.py b/clinical-mdr-api/clinical_mdr_api/repositories/ct_catalogues.py index 532f0646..c4fb7152 100644 --- a/clinical-mdr-api/clinical_mdr_api/repositories/ct_catalogues.py +++ b/clinical-mdr-api/clinical_mdr_api/repositories/ct_catalogues.py @@ -13,9 +13,7 @@ class CatalogueComparisonType(Enum): - """ - Enum for Type of the catalogue comparison - """ + """Catalogue comparison type.""" ATTRIBUTES_COMPARISON = "attributes" SPONSOR_COMPARISON = "sponsor" @@ -27,19 +25,19 @@ def get_ct_catalogues_changes( catalogue_name: str | None, comparison_type: CatalogueComparisonType, start_datetime: datetime, - end_datetime=datetime, + end_datetime: datetime, ) -> dict[str, Any]: filter_parameters = [] if library_name is not None: filter_by_library_name = """ - $library_name=head([(library:Library)-[:{LIBRARY_CT_REL}]->({CT_OBJECT}) | library.name])""" + EXISTS {{ MATCH (:Library {{name: $library_name}})-[:{LIBRARY_CT_REL}]->({CT_OBJECT}) }}""" filter_parameters.append(filter_by_library_name) if catalogue_name is not None: filter_by_catalogue_name = """ - $catalogue_name IN [(catalogue:CTCatalogue)-[:HAS_CODELIST]->(codelist_root) | catalogue.name]""" + EXISTS {{ MATCH (:CTCatalogue {{name: $catalogue_name}})-[:HAS_CODELIST]->(codelist_root) }}""" filter_parameters.append(filter_by_catalogue_name) filter_statements = ( - "AND " + " AND ".join(filter_parameters) if len(filter_parameters) > 0 else "" + "AND " + " AND ".join(filter_parameters) if filter_parameters else "" ) codelist_filter_statements = filter_statements.format( LIBRARY_CT_REL="CONTAINS_CODELIST", CT_OBJECT="codelist_root" @@ -53,79 +51,52 @@ def get_ct_catalogues_changes( else: relationship_type = "HAS_NAME_ROOT" + # Single-pass: match versions < end_datetime once, split old/new via collected lists. codelist_data_retrieval = f""" MATCH (codelist_root:CTCodelistRoot)-[:{relationship_type}]-> - (old_codelist_ver_root)-[old_versions]->(old_codelist_ver_value) - WHERE old_versions.start_date < datetime($start_datetime) - {codelist_filter_statements} - WITH codelist_root, old_versions, old_codelist_ver_value - ORDER BY old_versions.start_date DESC - WITH codelist_root, - collect(old_codelist_ver_value)[0] AS old_codelist_ver_value, - collect(old_versions.start_date)[0] AS latest_date, - collect(old_versions) AS old_versions_collection - WITH codelist_root, - old_codelist_ver_value, - latest_date, - head([x IN old_versions_collection WHERE x.start_date = latest_date | x ]) AS latest_version - WITH collect(apoc.map.fromValues([codelist_root.uid, {{value_node:old_codelist_ver_value, change_date: latest_date}}])) AS old_items - - MATCH (codelist_root:CTCodelistRoot)-[:{relationship_type}]-> - (new_codelist_ver_root)-[new_versions]->(new_codelist_ver_value) - WHERE new_versions.start_date < datetime($end_datetime) + (ver_root)-[ver]->(ver_value) + WHERE ver.start_date < datetime($end_datetime) {codelist_filter_statements} - WITH old_items, codelist_root, new_versions, new_codelist_ver_value - ORDER BY new_versions.start_date DESC - WITH old_items, - codelist_root, - collect(new_codelist_ver_value)[0] AS new_codelist_ver_value, - collect(new_versions.start_date)[0] AS latest_date, - collect(new_versions) AS new_versions_collection - WITH old_items, - codelist_root, - new_codelist_ver_value, - latest_date, head([x IN new_versions_collection WHERE x.start_date = latest_date | x ]) AS latest_version - WITH old_items, - collect(apoc.map.fromValues([codelist_root.uid, {{value_node:new_codelist_ver_value, change_date: latest_date}}])) AS new_items + WITH codelist_root, ver_value, ver.start_date AS ver_date + ORDER BY ver_date DESC + WITH codelist_root, + collect(ver_value) AS vals, + collect(ver_date) AS dates + WITH codelist_root, vals, dates, + vals[0] AS new_val, dates[0] AS new_date, + head([i IN range(0, size(dates)-1) WHERE dates[i] < datetime($start_datetime)]) AS old_idx + WITH + [x IN collect(CASE WHEN old_idx IS NOT NULL THEN + apoc.map.fromValues([codelist_root.uid, {{value_node: vals[old_idx], change_date: dates[old_idx]}}]) + END) WHERE x IS NOT NULL] AS old_items, + collect( + apoc.map.fromValues([codelist_root.uid, {{value_node: new_val, change_date: new_date}}]) + ) AS new_items """ + # Single-pass term query: same strategy as codelist query. term_data_retrieval = f""" MATCH (codelist_root:CTCodelistRoot)-[:HAS_TERM]->(:CTCodelistTerm)-[:HAS_TERM_ROOT]->(term_root)-[:{relationship_type}]-> - (old_term_ver_root)-[old_versions]->(old_term_ver_value) - WHERE old_versions.start_date < datetime($start_datetime) + (ver_root)-[ver]->(ver_value) + WHERE ver.start_date < datetime($end_datetime) {term_filter_statements} - WITH term_root, old_versions, old_term_ver_value - ORDER BY old_versions.start_date DESC - WITH term_root, - collect(old_term_ver_value)[0] AS old_term_ver_value, - collect(old_versions.start_date)[0] AS latest_date, - collect(old_versions) AS old_versions_collection - WITH term_root, - old_term_ver_value, - latest_date, - head([x IN old_versions_collection WHERE x.start_date = latest_date | x ]) AS latest_version - WITH collect(apoc.map.fromValues([term_root.uid, {{value_node:old_term_ver_value, codelists:[ - (term_root)<-[:HAS_TERM_ROOT]-(:CTCodelistTerm)<-[:HAS_TERM]-(codelist_root) | codelist_root.uid], change_date: latest_date}}])) AS old_items - - MATCH (codelist_root:CTCodelistRoot)-[:HAS_TERM]->(:CTCodelistTerm)-[:HAS_TERM_ROOT]->(term_root)-[:{relationship_type}]-> - (new_term_ver_root)-[new_versions]->(new_term_ver_value) - WHERE new_versions.start_date < datetime($end_datetime) - {term_filter_statements} - WITH old_items, term_root, new_versions, new_term_ver_value - ORDER BY new_versions.start_date DESC - WITH old_items, - term_root, - collect(new_term_ver_value)[0] AS new_term_ver_value, - collect(new_versions.start_date)[0] AS latest_date, - collect(new_versions) AS new_versions_collection - WITH old_items, - term_root, - new_term_ver_value, - latest_date, - head([x IN new_versions_collection WHERE x.start_date = latest_date | x ]) AS latest_version - WITH old_items, - collect(apoc.map.fromValues([term_root.uid, {{value_node:new_term_ver_value, codelists:[ - (term_root)<-[:HAS_TERM_ROOT]-(:CTCodelistTerm)<-[:HAS_TERM]-(codelist_root) | codelist_root.uid], change_date: latest_date}}])) AS new_items + WITH term_root, ver_value, ver.start_date AS ver_date + ORDER BY ver_date DESC + WITH term_root, + collect(ver_value) AS vals, + collect(ver_date) AS dates + WITH term_root, vals, dates, + vals[0] AS new_val, dates[0] AS new_date, + head([i IN range(0, size(dates)-1) WHERE dates[i] < datetime($start_datetime)]) AS old_idx + WITH + [x IN collect(CASE WHEN old_idx IS NOT NULL THEN + apoc.map.fromValues([term_root.uid, {{value_node: vals[old_idx], codelists:[ + (term_root)<-[:HAS_TERM_ROOT]-(:CTCodelistTerm)<-[:HAS_TERM]-(cl) | cl.uid], change_date: dates[old_idx]}}]) + END) WHERE x IS NOT NULL] AS old_items, + collect( + apoc.map.fromValues([term_root.uid, {{value_node: new_val, codelists:[ + (term_root)<-[:HAS_TERM_ROOT]-(:CTCodelistTerm)<-[:HAS_TERM]-(cl) | cl.uid], change_date: new_date}}]) + ) AS new_items """ term_return_clause = """ @@ -153,16 +124,16 @@ def get_ct_catalogues_changes( codelist_ret, _ = db.cypher_query(complete_codelist_query, query_params) output["new_codelists"] = ( sorted(codelist_ret[0][0], key=lambda ct_codelist: ct_codelist["change_date"]) - if len(codelist_ret) > 0 + if codelist_ret else [] ) output["deleted_codelists"] = ( sorted(codelist_ret[0][1], key=lambda ct_codelist: ct_codelist["change_date"]) - if len(codelist_ret) > 0 + if codelist_ret else [] ) - output["updated_codelists"] = codelist_ret[0][2] if len(codelist_ret) > 0 else [] - all_codelists_in_package = codelist_ret[0][3] if len(codelist_ret) > 0 else {} + output["updated_codelists"] = codelist_ret[0][2] if codelist_ret else [] + all_codelists_in_package = codelist_ret[0][3] if codelist_ret else {} # terms query complete_term_query = " ".join( @@ -171,28 +142,24 @@ def get_ct_catalogues_changes( terms_ret, _ = db.cypher_query(complete_term_query, query_params) output["new_terms"] = ( sorted(terms_ret[0][0], key=lambda ct_term: ct_term["change_date"]) - if len(terms_ret) > 0 + if terms_ret else [] ) output["deleted_terms"] = ( sorted(terms_ret[0][1], key=lambda ct_term: ct_term["change_date"]) - if len(terms_ret) > 0 + if terms_ret else [] ) output["updated_terms"] = ( sorted(terms_ret[0][2], key=lambda ct_term: ct_term["change_date"]) - if len(terms_ret) > 0 + if terms_ret else [] ) - # The following section adds codelists that contains some terms from the - # * new_terms - # * deleted_terms - # * updated_terms - # columns to the 'updated_codelists' column to mark given codelist as updated. - updated_codelist_uids = [ + # Add codelists containing changed terms to 'updated_codelists'. + updated_codelist_uids: set[str] = { codelist["uid"] for codelist in output["updated_codelists"] - ] + } for terms in [ output["new_terms"], output["deleted_terms"], @@ -200,15 +167,11 @@ def get_ct_catalogues_changes( ]: for term in terms: for codelist in term["codelists"]: - # we only want to add a codelist to the 'updated_codelists' column if given codelist - # is not already there and this codelist is from the package that we are currently comparing if ( codelist not in updated_codelist_uids and codelist in all_codelists_in_package ): - # updated_codelists_uids is a helper list to track all uids - # of codelists in the package that is being compared - updated_codelist_uids.append(codelist) + updated_codelist_uids.add(codelist) output["updated_codelists"].append( { "uid": codelist, diff --git a/clinical-mdr-api/clinical_mdr_api/routers/admin.py b/clinical-mdr-api/clinical_mdr_api/routers/admin.py index 4de4196d..641d7b11 100644 --- a/clinical-mdr-api/clinical_mdr_api/routers/admin.py +++ b/clinical-mdr-api/clinical_mdr_api/routers/admin.py @@ -69,6 +69,14 @@ def clear_caches() -> list[dict[Any, Any]]: if cache_store is not None: cache_store.clear() + # Clear shared service-level caches + from clinical_mdr_api.services.studies.study_activity_selection_base import ( + StudyActivitySelectionBaseService, + ) + + with StudyActivitySelectionBaseService._shared_terms_date_cache_lock: + StudyActivitySelectionBaseService._shared_terms_date_cache.clear() + return get_caches() diff --git a/clinical-mdr-api/clinical_mdr_api/routers/concepts/activities/activity_instances.py b/clinical-mdr-api/clinical_mdr_api/routers/concepts/activities/activity_instances.py index 6ae289a9..f7deb113 100644 --- a/clinical-mdr-api/clinical_mdr_api/routers/concepts/activities/activity_instances.py +++ b/clinical-mdr-api/clinical_mdr_api/routers/concepts/activities/activity_instances.py @@ -873,25 +873,25 @@ def approve( @router.delete( - "/{activity_instance_uid}/attributes/activations", + "/{activity_instance_uid}/activations", dependencies=[security, rbac.LIBRARY_WRITE], - summary=" Inactivate final version of an activity instance attributes", + summary="Inactivate final version of an activity instance (attributes and groupings)", description=""" State before: - - uid must exist and activity instance must be in status Final. - + - uid must exist and both the activity instance attributes and groupings must be in status Final. + Business logic: - - The latest 'Final' version will remain the same as before. - - The status will be automatically set to 'Retired'. + - The latest 'Final' version will remain the same as before (for both attributes and groupings). + - The status will be automatically set to 'Retired' for both attributes and groupings. - The 'change_description' property will be set automatically. - The 'version' property will remain the same as before. - + State after: - - Activity instance changed status to Retired. - - Audit trail entry must be made with action of inactivating to retired version. - + - Activity instance attributes and groupings changed status to Retired. + - Audit trail entries must be made with action of inactivating to retired version. + Possible errors: - - Invalid uid or status not Final. + - Invalid uid, or attributes/groupings status not Final. """, status_code=200, responses={ @@ -900,7 +900,8 @@ def approve( 400: { "model": ErrorResponse, "description": "Forbidden - Reasons include e.g.: \n" - "- The activity instance is not in final status.", + "- The activity instance attributes are not in final status.\n" + "- The activity instance groupings are not in final status.", }, 404: { "model": ErrorResponse, @@ -916,25 +917,25 @@ def inactivate( @router.post( - "/{activity_instance_uid}/attributes/activations", + "/{activity_instance_uid}/activations", dependencies=[security, rbac.LIBRARY_WRITE], - summary="Reactivate retired version of an activity instance attributes", + summary="Reactivate retired version of an activity instance (attributes and groupings)", description=""" State before: - - uid must exist and activity instance must be in status Retired. - + - uid must exist and both the activity instance attributes and groupings must be in status Retired. + Business logic: - - The latest 'Retired' version will remain the same as before. - - The status will be automatically set to 'Final'. + - The latest 'Retired' version will remain the same as before (for both attributes and groupings). + - The status will be automatically set to 'Final' for both attributes and groupings. - The 'change_description' property will be set automatically. - The 'version' property will remain the same as before. State after: - - Activity instance changed status to Final. - - An audit trail entry must be made with action of reactivating to final version. - + - Activity instance attributes and groupings changed status to Final. + - Audit trail entries must be made with action of reactivating to final version. + Possible errors: - - Invalid uid or status not Retired. + - Invalid uid, or attributes/groupings status not Retired. """, status_code=200, responses={ @@ -943,7 +944,8 @@ def inactivate( 400: { "model": ErrorResponse, "description": "Forbidden - Reasons include e.g.: \n" - "- The activity instance is not in retired status.", + "- The activity instance attributes are not in retired status.\n" + "- The activity instance groupings are not in retired status.", }, 404: { "model": ErrorResponse, @@ -1128,92 +1130,6 @@ def approve_groupings( return activity_instance_service.approve(uid=activity_instance_uid) -@router.delete( - "/{activity_instance_uid}/groupings/activations", - dependencies=[security, rbac.LIBRARY_WRITE], - summary=" Inactivate final version of an activity instance groupings", - description=""" -State before: - - uid must exist and activity instance must be in status Final. - -Business logic: - - The latest 'Final' version will remain the same as before. - - The status will be automatically set to 'Retired'. - - The 'change_description' property will be set automatically. - - The 'version' property will remain the same as before. - -State after: - - Activity instance changed status to Retired. - - Audit trail entry must be made with action of inactivating to retired version. - -Possible errors: - - Invalid uid or status not Final. - """, - status_code=200, - responses={ - 403: _generic_descriptions.ERROR_403, - 200: {"description": "OK."}, - 400: { - "model": ErrorResponse, - "description": "Forbidden - Reasons include e.g.: \n" - "- The activity instance is not in final status.", - }, - 404: { - "model": ErrorResponse, - "description": "Not Found - The activity instance with the specified 'activity_instance_uid' could not be found.", - }, - }, -) -def inactivate_groupings( - activity_instance_uid: Annotated[str, ActivityInstanceUID], -) -> ActivityInstanceGroupings: - activity_instance_service = ActivityInstanceGroupingsService() - return activity_instance_service.inactivate_final(uid=activity_instance_uid) - - -@router.post( - "/{activity_instance_uid}/groupings/activations", - dependencies=[security, rbac.LIBRARY_WRITE], - summary="Reactivate retired version of an activity instance groupings", - description=""" -State before: - - uid must exist and activity instance must be in status Retired. - -Business logic: - - The latest 'Retired' version will remain the same as before. - - The status will be automatically set to 'Final'. - - The 'change_description' property will be set automatically. - - The 'version' property will remain the same as before. - -State after: - - Activity instance changed status to Final. - - An audit trail entry must be made with action of reactivating to final version. - -Possible errors: - - Invalid uid or status not Retired. - """, - status_code=200, - responses={ - 403: _generic_descriptions.ERROR_403, - 200: {"description": "OK."}, - 400: { - "model": ErrorResponse, - "description": "Forbidden - Reasons include e.g.: \n" - "- The activity instance is not in retired status.", - }, - 404: { - "model": ErrorResponse, - "description": "Not Found - The activity instance with the specified 'activity_instance_uid' could not be found.", - }, - }, -) -def reactivate_groupings( - activity_instance_uid: Annotated[str, ActivityInstanceUID], -) -> ActivityInstanceGroupings: - activity_instance_service = ActivityInstanceGroupingsService() - return activity_instance_service.reactivate_retired(uid=activity_instance_uid) - - @router.delete( "/{activity_instance_uid}", dependencies=[security, rbac.LIBRARY_WRITE], diff --git a/clinical-mdr-api/clinical_mdr_api/routers/controlled_terminologies/ct_codelists.py b/clinical-mdr-api/clinical_mdr_api/routers/controlled_terminologies/ct_codelists.py index f9698091..bacfc09b 100644 --- a/clinical-mdr-api/clinical_mdr_api/routers/controlled_terminologies/ct_codelists.py +++ b/clinical-mdr-api/clinical_mdr_api/routers/controlled_terminologies/ct_codelists.py @@ -16,6 +16,7 @@ CTCodelistPairedInput, CTCodelistTerm, CTCodelistTermInput, + CTPairedCodelistCreateInput, CTPairedCodelistTerm, ) from clinical_mdr_api.models.utils import CustomPage @@ -384,6 +385,41 @@ def update_paired_codelist( return results +@router.post( + "/paired-codelists", + dependencies=[security, rbac.LIBRARY_WRITE], + summary="Creates a new set of paired codelists.", + description="""Creates two codelists (one for names, one for codes) and links them as paired codelists. +Each codelist gets its own name, submission_value, nci_preferred_name, definition, and sponsor_preferred_name +via the name_information and code_information fields respectively. +""", + status_code=201, + responses={ + 403: _generic_descriptions.ERROR_403, + 201: { + "description": "Created - The paired codelists were successfully created." + }, + 400: { + "model": ErrorResponse, + "description": "Forbidden - Reasons include e.g.: \n" + "- The catalogue doesn't exist.\n" + "- The library doesn't exist.\n" + "- The library doesn't allow to add new items.\n", + }, + }, +) +def create_paired_codelists( + paired_codelist_input: Annotated[ + CTPairedCodelistCreateInput, + Body( + description="Properties to create a set of paired codelists (names and codes).", + ), + ], +) -> CTCodelistPaired: + ct_codelist_service = CTCodelistService() + return ct_codelist_service.create_paired_codelists(paired_codelist_input) + + @router.get( "/paired-codelists/{codelist_uid}/terms", dependencies=[security, rbac.LIBRARY_READ], diff --git a/clinical-mdr-api/clinical_mdr_api/routers/ddf/study_definitions.py b/clinical-mdr-api/clinical_mdr_api/routers/ddf/study_definitions.py index d0a571cb..3733387c 100644 --- a/clinical-mdr-api/clinical_mdr_api/routers/ddf/study_definitions.py +++ b/clinical-mdr-api/clinical_mdr_api/routers/ddf/study_definitions.py @@ -101,8 +101,17 @@ def get_study_m11_protocol( study_uid, study_value_version=None ) + with open( + str(M11_TEMPLATES_DIR_PATH) + + "/ICH_Step4_M11_Final_TechnicalSpecification_2025_1119.json", + "r", + encoding="utf-8", + ) as f: + specification = f.read() + with trace_block("context_creation", "Creating context for M11 template rendering"): context = { + "specification_20251119": specification, "study_id": study_uid, "study_indications": ddf_study.versions[0].studyDesigns[0].indications, "study_interventions": ddf_study.versions[0].studyInterventions[0], diff --git a/clinical-mdr-api/clinical_mdr_api/routers/studies/studies.py b/clinical-mdr-api/clinical_mdr_api/routers/studies/studies.py index 78eba137..8f1e2377 100644 --- a/clinical-mdr-api/clinical_mdr_api/routers/studies/studies.py +++ b/clinical-mdr-api/clinical_mdr_api/routers/studies/studies.py @@ -12,6 +12,7 @@ StudyCopyComponentEnum, StudyStatus, ) +from clinical_mdr_api.models.complexity_score import ComplexityScoreDetails from clinical_mdr_api.models.study_selections.study import ( CompactStudy, LockReleaseInput, @@ -24,7 +25,9 @@ StudyPatchRequestJsonModel, StudyPreferredTimeUnit, StudyPreferredTimeUnitInput, + StudyProtocolHeaderVersion, StudyProtocolTitle, + StudySelectionContainmentResult, StudySimple, StudySoaPreferences, StudySoaPreferencesInput, @@ -35,11 +38,14 @@ StudySubpartAuditTrail, StudySubpartCreateInput, StudySubpartReorderingInput, + StudyTemplate, + StudyTemplateInput, + StudyTemplatePatchInput, StudyVersionHistory, UnlockInput, ) from clinical_mdr_api.models.study_selections.study_pharma_cm import StudyPharmaCM -from clinical_mdr_api.models.utils import CustomPage +from clinical_mdr_api.models.utils import CustomPage, EditInputModel from clinical_mdr_api.repositories._utils import FilterOperator from clinical_mdr_api.routers import _generic_descriptions, decorators from clinical_mdr_api.routers._generic_descriptions import ( @@ -62,6 +68,10 @@ router = APIRouter() StudyUID = Path(description="The unique id of the study.") +TargetStudyUID = Path( + description="The unique id of the target study (e.g. a clone). " + "Only selection labels that appear on this study are included in the comparison." +) @router.get( @@ -227,6 +237,7 @@ def get_all( "acronym", "number", "title", + "description", "subpart_id", "subpart_acronym", "clinical_programme=clinical_programme_name", @@ -318,6 +329,92 @@ def get_studies_list( ) +@router.get( + "/template", + dependencies=[security, rbac.STUDY_READ], + summary="Returns the active study template, if any.", + status_code=200, + responses={403: _generic_descriptions.ERROR_403}, +) +def get_study_template() -> StudyTemplate | None: + return StudyService().get_study_template() + + +@router.post( + "/template", + dependencies=[security, rbac.STUDY_WRITE], + summary="Creates the study template configuration.", + status_code=201, + responses={ + 403: _generic_descriptions.ERROR_403, + 400: _generic_descriptions.ERROR_400, + }, +) +def create_study_template( + study_template_input: Annotated[ + StudyTemplateInput, Body(description="Study template target study reference.") + ], +) -> StudyTemplate: + return StudyService().create_study_template(study_template_input) + + +@router.patch( + "/template", + dependencies=[security, rbac.STUDY_WRITE], + summary="Updates the study template with a new version.", + status_code=200, + responses={ + 403: _generic_descriptions.ERROR_403, + 400: _generic_descriptions.ERROR_400, + 404: _generic_descriptions.ERROR_404, + }, +) +def patch_study_template( + patch_input: Annotated[ + StudyTemplatePatchInput, Body(description="Updated study template target.") + ], +) -> StudyTemplate: + return StudyService().patch_study_template(patch_input) + + +@router.delete( + "/template/activations", + dependencies=[security, rbac.STUDY_WRITE], + summary="Retires the current study template.", + status_code=200, + responses={ + 403: _generic_descriptions.ERROR_403, + 404: _generic_descriptions.ERROR_404, + }, +) +def retire_study_template( + _: Annotated[ + EditInputModel | None, + Body(description="Optional change description payload.", embed=False), + ] = None, +) -> StudyTemplate: + return StudyService().retire_study_template() + + +@router.post( + "/template/activations", + dependencies=[security, rbac.STUDY_WRITE], + summary="Reactivates a retired study template.", + status_code=200, + responses={ + 403: _generic_descriptions.ERROR_403, + 404: _generic_descriptions.ERROR_404, + }, +) +def reactivate_study_template( + _: Annotated[ + EditInputModel | None, + Body(description="Optional change description payload.", embed=False), + ] = None, +) -> StudyTemplate: + return StudyService().reactivate_study_template() + + @router.get( "/structure-overview", dependencies=[security, rbac.STUDY_READ], @@ -690,6 +787,40 @@ def get_structure_statistics( return study_service.get_study_structure_statistics(study_uid) +@router.get( + "/{study_uid}/study-selection-containment/{target_study_uid}", + dependencies=[security, rbac.STUDY_READ], + summary=( + "Whether target study selection statistics are contained in the source study." + ), + description=( + "Compares selection counts (and distinct CT term roots) for each concrete " + "selection label that appears on **target_study_uid** against **study_uid** " + "(source). Returns whether target counts are <= source for every such label, " + "plus per-label figures. **StudySoAFootnote** is excluded (conditional clone " + "rules make footnote counts incomparable)." + ), + response_model_exclude_unset=True, + status_code=200, + responses={ + 403: _generic_descriptions.ERROR_403, + 404: { + "model": ErrorResponse, + "description": "Not Found - source or target study uid.", + }, + }, +) +def get_study_selection_containment( + study_uid: Annotated[str, StudyUID], + target_study_uid: Annotated[str, TargetStudyUID], +) -> StudySelectionContainmentResult: + study_service = StudyService() + return study_service.get_study_selection_containment( + source_study_uid=study_uid, + target_study_uid=target_study_uid, + ) + + @router.get( "/{study_uid}/pharma-cm", dependencies=[security, rbac.STUDY_READ], @@ -869,7 +1000,7 @@ def get_snapshot_history( @router.get( "/{study_uid}/protocol-header-versions", dependencies=[security, rbac.STUDY_READ], - summary="Returns the latest available protocol header version or the one associated with specified study value version.", + summary="Returns the latest available protocol header version and whether the study contains a 'Final Protocol' locked version.", response_model_exclude_unset=True, status_code=200, responses={ @@ -881,7 +1012,7 @@ def get_snapshot_history( ) def get_protocol_header_version( study_uid: Annotated[str, StudyUID], -) -> str | None: +) -> StudyProtocolHeaderVersion: return StudyService().get_protocol_header_version(study_uid=study_uid) @@ -942,10 +1073,24 @@ def get_study_subpart_audit_trail( study_value_version: Annotated[ str | None, _generic_descriptions.STUDY_VALUE_VERSION_QUERY ] = None, -) -> list[StudySubpartAuditTrail]: + page_number: _generic_descriptions.PAGE_NUMBER_QUERY = settings.default_page_number, + page_size: _generic_descriptions.PAGE_SIZE_QUERY = settings.default_page_size, + total_count: _generic_descriptions.TOTAL_COUNT_QUERY = False, +) -> CustomPage[StudySubpartAuditTrail]: study_service = StudyService() - return study_service.get_subpart_audit_trail_by_uid( - uid=study_uid, is_subpart=is_subpart, study_value_version=study_value_version + result = study_service.get_subpart_audit_trail_by_uid( + uid=study_uid, + is_subpart=is_subpart, + study_value_version=study_value_version, + page_number=page_number, + page_size=page_size, + total_count=total_count, + ) + return CustomPage.create( + items=result.items, + total=result.total, + page=page_number, + size=page_size, ) @@ -1387,3 +1532,35 @@ def get_complexity_score( study_uid=study_uid, study_version_number=study_version_number, ) + + +@router.get( + "/{study_uid}/complexity-score-details", + dependencies=[security, rbac.STUDY_READ], + summary="Get detailed study complexity score breakdown", + response_model=ComplexityScoreDetails, + response_model_by_alias=False, + status_code=200, + responses={ + 404: { + "model": ErrorResponse, + "description": "Not Found - study with the specified 'study_uid' doesn't exist", + }, + }, +) +def get_complexity_score_details( + study_uid: Annotated[str, StudyUID], + study_version_number: Annotated[ + str | None, + Query( + description="Study Version Number", + openapi_examples={"2.1": {"value": "2.1"}}, + alias="study_value_version", + ), + ] = None, +) -> ComplexityScoreDetails: + service = ComplexityScoreService() + return service.get_complexity_score_details( + study_uid=study_uid, + study_version_number=study_version_number, + ) diff --git a/clinical-mdr-api/clinical_mdr_api/routers/studies/study_disease_milestones.py b/clinical-mdr-api/clinical_mdr_api/routers/studies/study_disease_milestones.py index 11f7c8c1..3c388883 100644 --- a/clinical-mdr-api/clinical_mdr_api/routers/studies/study_disease_milestones.py +++ b/clinical-mdr-api/clinical_mdr_api/routers/studies/study_disease_milestones.py @@ -345,16 +345,17 @@ def post_new_disease_milestone_create( }, ) @decorators.validate_if_study_is_not_locked("study_uid") -# pylint: disable=unused-argument def delete_study_disease_milestone( - study_uid: Annotated[str, studyUID], # TODO: Use this argument! + study_uid: Annotated[str, studyUID], study_disease_milestone_uid: Annotated[ str, study_disease_milestone_uid_description ], ): service = StudyDiseaseMilestoneService() - service.delete(study_disease_milestone_uid=study_disease_milestone_uid) + service.delete( + study_uid=study_uid, study_disease_milestone_uid=study_disease_milestone_uid + ) @router.patch( @@ -398,9 +399,8 @@ def delete_study_disease_milestone( }, ) @decorators.validate_if_study_is_not_locked("study_uid") -# pylint: disable=unused-argument def patch_reorder( - study_uid: Annotated[str, studyUID], # TODO: Use this argument! + study_uid: Annotated[str, studyUID], study_disease_milestone_uid: Annotated[ str, study_disease_milestone_uid_description ], @@ -411,6 +411,7 @@ def patch_reorder( ) -> study_disease_milestone.StudyDiseaseMilestone: service = StudyDiseaseMilestoneService() return service.reorder( + study_uid=study_uid, study_disease_milestone_uid=study_disease_milestone_uid, new_order=new_order_input.new_order, ) @@ -461,6 +462,7 @@ def patch_update_disease_milestone( ) -> study_disease_milestone.StudyDiseaseMilestone: service = StudyDiseaseMilestoneService() return service.edit( + study_uid=study_uid, study_disease_milestone_uid=study_disease_milestone_uid, study_disease_milestone_input=selection, ) diff --git a/clinical-mdr-api/clinical_mdr_api/services/_meta_repository.py b/clinical-mdr-api/clinical_mdr_api/services/_meta_repository.py index 77c03725..bd711ff0 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/_meta_repository.py +++ b/clinical-mdr-api/clinical_mdr_api/services/_meta_repository.py @@ -156,6 +156,9 @@ from clinical_mdr_api.domain_repositories.standard_data_models.dataset_variable_repository import ( DatasetVariableRepository, ) +from clinical_mdr_api.domain_repositories.studies.study_template_repository import ( + StudyTemplateRepository, +) from clinical_mdr_api.domain_repositories.study_definitions.study_definition_repository import ( StudyDefinitionRepository, ) @@ -819,6 +822,12 @@ def study_design_class_repository(self) -> StudyDesignClassRepository: def study_source_variable_repository(self) -> StudySourceVariableRepository: return self.get_repository_instance(StudySourceVariableRepository) + @property + def study_template_repository(self) -> StudyTemplateRepository: + return self.get_repository_instance( + StudyTemplateRepository, user=self._author_id + ) + @property def user_repository(self) -> UserRepository: return self.get_repository_instance(UserRepository) diff --git a/clinical-mdr-api/clinical_mdr_api/services/biomedical_concepts/activity_item_class.py b/clinical-mdr-api/clinical_mdr_api/services/biomedical_concepts/activity_item_class.py index 720d27a5..a968e2a6 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/biomedical_concepts/activity_item_class.py +++ b/clinical-mdr-api/clinical_mdr_api/services/biomedical_concepts/activity_item_class.py @@ -37,6 +37,7 @@ ) from clinical_mdr_api.services.controlled_terminologies.ct_codelist import ( CTCodelistService, + _paired_codelist_info_from, ) from common.exceptions import NotFoundException from common.utils import version_string_to_tuple @@ -265,8 +266,7 @@ def get_codelists_of_activity_item_class( CTCodelistNameAndAttributes.from_ct_codelist_ar( name, attrs, - paired_codes_codelist_uid=paired.paired_codes_codelist_uid, - paired_names_codelist_uid=paired.paired_names_codelist_uid, + paired_codelist=_paired_codelist_info_from(paired), ), codelists_and_terms[attrs.uid], ) diff --git a/clinical-mdr-api/clinical_mdr_api/services/concepts/activities/activity_instance_service.py b/clinical-mdr-api/clinical_mdr_api/services/concepts/activities/activity_instance_service.py index 146d967c..4bcf17c6 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/concepts/activities/activity_instance_service.py +++ b/clinical-mdr-api/clinical_mdr_api/services/concepts/activities/activity_instance_service.py @@ -1,6 +1,8 @@ import datetime from typing import Any +from neomodel import db + from clinical_mdr_api.domain_repositories.concepts.activities.activity_instance_repository import ( ActivityInstanceAttributesRepository, ActivityInstanceGroupingsRepository, @@ -20,7 +22,10 @@ CTCodelistItem, CTTermItem, ) -from clinical_mdr_api.domains.versioned_object_aggregate import LibraryVO +from clinical_mdr_api.domains.versioned_object_aggregate import ( + LibraryItemStatus, + LibraryVO, +) from clinical_mdr_api.models.concepts.activities.activity_instance import ( ActivityInstance, ActivityInstanceAttributes, @@ -38,6 +43,7 @@ CompactUnitDefinition, ) from clinical_mdr_api.models.utils import BaseModel +from clinical_mdr_api.services._utils import ensure_transaction from clinical_mdr_api.services.concepts import constants from clinical_mdr_api.services.concepts.concept_generic_service import ( ConceptGenericService, @@ -543,6 +549,100 @@ def _edit_aggregate( ) return item + @ensure_transaction(db) + def inactivate_final( + self, + uid: str, + cascade_inactivate: bool = False, + force_new_value_node: bool = False, + ) -> BaseModel: + """ + Inactivates both the attributes and the groupings tracks of an + activity instance. Both tracks must currently be in Final status. + """ + attributes_item = self._find_by_uid_or_raise_not_found(uid, for_update=True) + + groupings_repository = self._repos.activity_instance_groupings_repository + groupings_item = groupings_repository.find_by_uid_2(uid=uid, for_update=True) + NotFoundException.raise_if( + groupings_item is None, + msg=(f"Activity Instance Groupings with UID '{uid}' doesn't exist."), + ) + + BusinessLogicException.raise_if( + attributes_item.item_metadata.status != LibraryItemStatus.FINAL, + msg=( + "Cannot inactivate: activity instance attributes are not in" + " Final status." + ), + ) + BusinessLogicException.raise_if( + groupings_item.item_metadata.status != LibraryItemStatus.FINAL, + msg=( + "Cannot inactivate: activity instance groupings are not in" + " Final status." + ), + ) + + attributes_item.inactivate( + author_id=self.author_id, force_new_value_node=force_new_value_node + ) + groupings_item.inactivate( + author_id=self.author_id, force_new_value_node=force_new_value_node + ) + self.repository.save(attributes_item, force_new_value_node=force_new_value_node) + groupings_repository.save( + groupings_item, force_new_value_node=force_new_value_node + ) + return self._transform_aggregate_root_to_pydantic_model(attributes_item) + + @ensure_transaction(db) + def reactivate_retired( + self, + uid: str, + cascade_reactivate: bool = False, + force_new_value_node: bool = False, + ) -> BaseModel: + """ + Reactivates both the attributes and the groupings tracks of an + activity instance. Both tracks must currently be in Retired status. + """ + attributes_item = self._find_by_uid_or_raise_not_found(uid, for_update=True) + + groupings_repository = self._repos.activity_instance_groupings_repository + groupings_item = groupings_repository.find_by_uid_2(uid=uid, for_update=True) + NotFoundException.raise_if( + groupings_item is None, + msg=(f"Activity Instance Groupings with UID '{uid}' doesn't exist."), + ) + + BusinessLogicException.raise_if( + attributes_item.item_metadata.status != LibraryItemStatus.RETIRED, + msg=( + "Cannot reactivate: activity instance attributes are not in" + " Retired status." + ), + ) + BusinessLogicException.raise_if( + groupings_item.item_metadata.status != LibraryItemStatus.RETIRED, + msg=( + "Cannot reactivate: activity instance groupings are not in" + " Retired status." + ), + ) + + attributes_item.reactivate( + author_id=self.author_id, force_new_value_node=force_new_value_node + ) + groupings_item.reactivate( + author_id=self.author_id, force_new_value_node=force_new_value_node + ) + self.repository.save(attributes_item, force_new_value_node=force_new_value_node) + groupings_repository.save( + groupings_item, force_new_value_node=force_new_value_node + ) + return self._transform_aggregate_root_to_pydantic_model(attributes_item) + class ActivityInstanceGroupingsService( ConceptGenericService[ActivityInstanceGroupingsAR] diff --git a/clinical-mdr-api/clinical_mdr_api/services/concepts/activities/activity_service.py b/clinical-mdr-api/clinical_mdr_api/services/concepts/activities/activity_service.py index f636a438..7c292197 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/concepts/activities/activity_service.py +++ b/clinical-mdr-api/clinical_mdr_api/services/concepts/activities/activity_service.py @@ -111,6 +111,7 @@ def _to_activity_grouping_vo( def _to_activity_grouping_vos( self, activity_groupings: Iterable[ActivityGrouping | ActivityGroupingVO], + existing_groupings: list[ActivityGroupingVO] | None = None, ) -> list[ActivityGroupingVO]: acg_and_acsg_by_uid: tuple[Any, ...] = ( self._get_activity_groups_and_subgroups_from_activity_groupings( @@ -120,8 +121,23 @@ def _to_activity_grouping_vos( activity_groups_by_uid: dict[Any, Any] = acg_and_acsg_by_uid[0] activity_subgroups_by_uid: dict[Any, Any] = acg_and_acsg_by_uid[1] + # Build set of existing (group, subgroup) pairs to skip validation for carried-over groupings + existing_pairs: set[tuple[str, str]] = set() + if existing_groupings: + existing_pairs = { + (g.activity_group_uid, g.activity_subgroup_uid) + for g in existing_groupings + } + # Validate that all activity groups and subgroups are in Final status for activity_grouping in activity_groupings: + # Skip validation for groupings carried over from the previous version + if ( + activity_grouping.activity_group_uid, + activity_grouping.activity_subgroup_uid, + ) in existing_pairs: + continue + # Check activity group status if activity_grouping.activity_group_uid in activity_groups_by_uid: activity_group = activity_groups_by_uid[ @@ -212,7 +228,10 @@ def _edit_aggregate( if "activity_groupings" in concept_edit_input.model_fields_set: # Use _to_activity_grouping_vos which includes validation activity_groupings = ( - self._to_activity_grouping_vos(concept_edit_input.activity_groupings) + self._to_activity_grouping_vos( + concept_edit_input.activity_groupings, + existing_groupings=item.concept_vo.activity_groupings, + ) if concept_edit_input.activity_groupings else [] ) diff --git a/clinical-mdr-api/clinical_mdr_api/services/controlled_terminologies/ct_codelist.py b/clinical-mdr-api/clinical_mdr_api/services/controlled_terminologies/ct_codelist.py index a253ea0c..814833a0 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/controlled_terminologies/ct_codelist.py +++ b/clinical-mdr-api/clinical_mdr_api/services/controlled_terminologies/ct_codelist.py @@ -9,6 +9,7 @@ from clinical_mdr_api.domains.controlled_terminologies.ct_codelist_attributes import ( CTCodelistAttributesAR, CTCodelistAttributesVO, + CTPairedCodelists, ) from clinical_mdr_api.domains.controlled_terminologies.ct_codelist_name import ( CTCodelistNameAR, @@ -23,6 +24,8 @@ CTCodelistPaired, CTCodelistPairedInput, CTCodelistTerm, + CTPairedCodelistCreateInput, + CTPairedCodelistInfo, CTPairedCodelistTerm, ) from clinical_mdr_api.models.utils import GenericFilteringReturn @@ -46,6 +49,20 @@ _AggregateRootType = TypeVar("_AggregateRootType") +def _paired_codelist_info_from( + paired: CTPairedCodelists, +) -> CTPairedCodelistInfo | None: + if paired.paired_codes_codelist_uid: + return CTPairedCodelistInfo( + uid=paired.paired_codes_codelist_uid, name=paired.paired_codes_codelist_name + ) + if paired.paired_names_codelist_uid: + return CTPairedCodelistInfo( + uid=paired.paired_names_codelist_uid, name=paired.paired_names_codelist_name + ) + return None + + class CTCodelistService: _repos: MetaRepository author_id: str @@ -221,6 +238,67 @@ def create( paired_names_codelist_uid=codelist_input.paired_names_codelist_uid, ) + def create_paired_codelists( + self, + paired_codelist_input: CTPairedCodelistCreateInput, + ) -> CTCodelistPaired: + names_codelist_input = CTCodelistCreateInput( + catalogue_names=paired_codelist_input.catalogue_names, + name=paired_codelist_input.name_information.name, + submission_value=paired_codelist_input.name_information.submission_value, + nci_preferred_name=paired_codelist_input.name_information.nci_preferred_name, + definition=paired_codelist_input.name_information.definition, + extensible=paired_codelist_input.extensible, + is_ordinal=paired_codelist_input.is_ordinal, + codelist_type=paired_codelist_input.codelist_type, + sponsor_preferred_name=paired_codelist_input.name_information.sponsor_preferred_name, + template_parameter=paired_codelist_input.template_parameter, + parent_codelist_uid=paired_codelist_input.parent_codelist_uid, + terms=[], + library_name=paired_codelist_input.library_name, + ) + codes_codelist_input = CTCodelistCreateInput( + catalogue_names=paired_codelist_input.catalogue_names, + name=paired_codelist_input.code_information.name, + submission_value=paired_codelist_input.code_information.submission_value, + nci_preferred_name=paired_codelist_input.code_information.nci_preferred_name, + definition=paired_codelist_input.code_information.definition, + extensible=paired_codelist_input.extensible, + is_ordinal=paired_codelist_input.is_ordinal, + codelist_type=paired_codelist_input.codelist_type, + sponsor_preferred_name=paired_codelist_input.code_information.sponsor_preferred_name, + template_parameter=paired_codelist_input.template_parameter, + parent_codelist_uid=paired_codelist_input.parent_codelist_uid, + terms=[], + library_name=paired_codelist_input.library_name, + ) + + names_codelist = self.create(names_codelist_input) + codes_codelist = self.create(codes_codelist_input) + + self._repos.ct_codelist_aggregated_repository.merge_link_to_codes_codelist( + names_codelist.codelist_uid, + codes_codelist.codelist_uid, + ) + + attributes_service = CTCodelistAttributesService() + names_service = CTCodelistNameService() + + return CTCodelistPaired.from_ct_codelists( + paired_names_codelist_name=names_service.get_by_uid( + names_codelist.codelist_uid + ), + paired_names_codelist_attrs=attributes_service.get_by_uid( + names_codelist.codelist_uid + ), + paired_codes_codelist_name=names_service.get_by_uid( + codes_codelist.codelist_uid + ), + paired_codes_codelist_attrs=attributes_service.get_by_uid( + codes_codelist.codelist_uid + ), + ) + def get_all_codelists( self, catalogue_name: str | None = None, @@ -257,8 +335,7 @@ def get_all_codelists( CTCodelistNameAndAttributes.from_ct_codelist_ar( ct_codelist_name_ar, ct_codelist_attributes_ar, - paired_codes_codelist_uid=paired.paired_codes_codelist_uid, - paired_names_codelist_uid=paired.paired_names_codelist_uid, + paired_codelist=_paired_codelist_info_from(paired), ) for ct_codelist_name_ar, ct_codelist_attributes_ar, paired in all_aggregated_codelists ] @@ -378,8 +455,7 @@ def get_sub_codelists_that_have_given_terms( CTCodelistNameAndAttributes.from_ct_codelist_ar( ct_codelist_name_ar, ct_codelist_attributes_ar, - paired_codes_codelist_uid=paired.paired_codes_codelist_uid, - paired_names_codelist_uid=paired.paired_names_codelist_uid, + paired_codelist=_paired_codelist_info_from(paired), ) for ct_codelist_name_ar, ct_codelist_attributes_ar, paired in all_aggregated_sub_codelists if ct_codelist_attributes_ar.uid in uid_of_sub_codelist_with_terms @@ -456,20 +532,9 @@ def add_term( ) BusinessLogicException.raise_if( parent_codelist_uid - and len( - self._repos.ct_term_aggregated_repository.find_all_aggregated_result( - filter_by={ - "codelists.codelist_uid": { - "v": [parent_codelist_uid], - "op": "eq", - }, - "term_uid": {"v": [term_uid], "op": "eq"}, - } - )[ - 0 - ] - ) - <= 0, + and not self._repos.ct_codelist_attribute_repository.is_term_in_codelist( + term_uid=term_uid, codelist_uid=parent_codelist_uid + ), msg=f"Term with UID '{term_uid}' isn't in use by Parent Codelist with UID '{parent_codelist_uid}'.", ) diff --git a/clinical-mdr-api/clinical_mdr_api/services/feature_flags.py b/clinical-mdr-api/clinical_mdr_api/services/feature_flags.py index 5c87634e..d79114a7 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/feature_flags.py +++ b/clinical-mdr-api/clinical_mdr_api/services/feature_flags.py @@ -37,6 +37,8 @@ def create_feature_flag( ) return self.repo.create_feature_flag( + section=feature_flag_input.section, + feature=feature_flag_input.feature, name=feature_flag_input.name, enabled=feature_flag_input.enabled, description=feature_flag_input.description, @@ -49,7 +51,7 @@ def update_feature_flag( feature_flag_patch_input: FeatureFlagPatchInput, ) -> FeatureFlag: return self.repo.update_feature_flag( - sn=sn, enabled=feature_flag_patch_input.enabled + sn=sn, **feature_flag_patch_input.model_dump(exclude_unset=True) ) @db.transaction diff --git a/clinical-mdr-api/clinical_mdr_api/services/studies/complexity_score.py b/clinical-mdr-api/clinical_mdr_api/services/studies/complexity_score.py index b90b1c89..31049f07 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/studies/complexity_score.py +++ b/clinical-mdr-api/clinical_mdr_api/services/studies/complexity_score.py @@ -7,6 +7,9 @@ Burden, BurdenIdInput, BurdenInput, + ComplexityScoreAssessment, + ComplexityScoreDetails, + ComplexityScoreVisit, ) from common import exceptions from common.utils import get_db_result_as_dict @@ -43,6 +46,15 @@ class VisitsSummary: non_physical_visits: int +@dataclass +class VisitTypeBurdens: + any: float + physical: float + virtual: float + initial: float + follow_up: float + + class ComplexityScoreService: @classmethod @@ -246,15 +258,95 @@ def calculate_site_complexity_score( total_score = ( initial_visit_burden + follow_up_visit_burden - + self.get_visits_site_burden(visits_summary, activity_burden_dict) - + self.get_activities_site_burden(soa, activity_burden_dict) + + self.get_total_visits_site_burden(visits_summary, activity_burden_dict) + + self.get_total_activities_site_burden(soa, activity_burden_dict) ) # Round total score to 3 decimal places return round(total_score, 3) + def get_complexity_score_details( + self, study_uid: str, study_version_number: str | None + ) -> ComplexityScoreDetails: + visits_summary = self.get_visits_summary(study_uid, study_version_number) + soa = self.get_soa(study_uid, study_version_number) + activity_burdens = self.get_activity_burdens() + activity_burden_dict = {ab.activity_subgroup_uid: ab for ab in activity_burdens} + + burdens = self.get_visit_types_site_burdens(activity_burden_dict) + + visits = [ + ComplexityScoreVisit( + type="initial", + count=1, + burden=burdens.initial, + ), + ComplexityScoreVisit( + type="follow_up", + count=1, + burden=burdens.follow_up, + ), + ComplexityScoreVisit( + type="physical", + count=visits_summary.physical_visits, + burden=round(burdens.any + burdens.physical, 3), + ), + ComplexityScoreVisit( + type="virtual", + count=visits_summary.non_physical_visits, + burden=round(burdens.any + burdens.virtual, 3), + ), + ] + + assessments = [] + for row in soa: + if not row.activity_subgroup_name: + continue + activity_burden = activity_burden_dict.get(row.activity_subgroup_uid) + visit_count = len(row.visits) + burden = round(activity_burden.site_burden, 3) if activity_burden else 0.0 + assessments.append( + ComplexityScoreAssessment( + type=row.activity_subgroup_name, + count=visit_count, + burden=burden, + ) + ) + + return ComplexityScoreDetails(visits=visits, assessments=assessments) + @classmethod - def get_visits_site_burden( + def get_visit_types_site_burdens(cls, activity_burden_dict) -> VisitTypeBurdens: + return VisitTypeBurdens( + any=( + activity_burden_dict.get(ANY_VISIT_BURDEN_ID).site_burden + if ANY_VISIT_BURDEN_ID in activity_burden_dict + else ANY_VISIT_BURDEN_DEFAULT_VAL + ), + virtual=( + activity_burden_dict.get(NON_PHYSICAL_VISIT_BURDEN_ID).site_burden + if NON_PHYSICAL_VISIT_BURDEN_ID in activity_burden_dict + else NON_PHYSICAL_VISIT_BURDEN_DEFAULT_VAL + ), + physical=( + activity_burden_dict.get(PHYSICAL_VISIT_BURDEN_ID).site_burden + if PHYSICAL_VISIT_BURDEN_ID in activity_burden_dict + else PHYSICAL_VISIT_BURDEN_DEFAULT_VAL + ), + initial=( + activity_burden_dict.get(ROUTINE_INITIAL_VISIT_BURDEN_ID).site_burden + if ROUTINE_INITIAL_VISIT_BURDEN_ID in activity_burden_dict + else ROUTINE_INITIAL_VISIT_BURDEN_DEFAULT_VAL + ), + follow_up=( + activity_burden_dict.get(ROUTINE_FOLLOW_UP_VISIT_BURDEN_ID).site_burden + if ROUTINE_FOLLOW_UP_VISIT_BURDEN_ID in activity_burden_dict + else ROUTINE_FOLLOW_UP_VISIT_BURDEN_DEFAULT_VAL + ), + ) + + @classmethod + def get_total_visits_site_burden( cls, visits_summary: VisitsSummary, activity_burden_dict ) -> float: # Total = @@ -262,33 +354,18 @@ def get_visits_site_burden( # + non_physical_visits * "simple or brief tel. visit [NC008]" burden = x * 0.60 # + physical_visits * "brief visit with vital signs [99211]" burden = x * 0.18 - burden_any_visit = ( - activity_burden_dict.get(ANY_VISIT_BURDEN_ID).site_burden - if ANY_VISIT_BURDEN_ID in activity_burden_dict - else ANY_VISIT_BURDEN_DEFAULT_VAL - ) - burden_non_physical = ( - activity_burden_dict.get(NON_PHYSICAL_VISIT_BURDEN_ID).site_burden - if NON_PHYSICAL_VISIT_BURDEN_ID in activity_burden_dict - else NON_PHYSICAL_VISIT_BURDEN_DEFAULT_VAL - ) - burden_physical = ( - activity_burden_dict.get(PHYSICAL_VISIT_BURDEN_ID).site_burden - if PHYSICAL_VISIT_BURDEN_ID in activity_burden_dict - else PHYSICAL_VISIT_BURDEN_DEFAULT_VAL - ) + burdens = cls.get_visit_types_site_burdens(activity_burden_dict) total_score = 0.0 - total_score += ( - visits_summary.non_physical_visits + visits_summary.physical_visits - ) * burden_any_visit - total_score += visits_summary.non_physical_visits * burden_non_physical - total_score += visits_summary.physical_visits * burden_physical + total_score += visits_summary.non_physical_visits * ( + burdens.any + burdens.virtual + ) + total_score += visits_summary.physical_visits * (burdens.any + burdens.physical) return total_score @classmethod - def get_activities_site_burden(cls, soa, activity_burden_dict) -> float: + def get_total_activities_site_burden(cls, soa, activity_burden_dict) -> float: # Activity burdens summed up over all visits # - all activities under the same activity subgroup will be added once per visit diff --git a/clinical-mdr-api/clinical_mdr_api/services/studies/study.py b/clinical-mdr-api/clinical_mdr_api/services/studies/study.py index 379ada73..b87967b8 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/studies/study.py +++ b/clinical-mdr-api/clinical_mdr_api/services/studies/study.py @@ -26,7 +26,7 @@ ) from clinical_mdr_api.domains.controlled_terminologies.ct_term_name import CTTermNameAR from clinical_mdr_api.domains.dictionaries.dictionary_term import DictionaryTermAR -from clinical_mdr_api.domains.enums import ValidationMode +from clinical_mdr_api.domains.enums import LibraryItemStatus, ValidationMode from clinical_mdr_api.domains.projects.project import ProjectAR from clinical_mdr_api.domains.study_definition_aggregates.registry_identifiers import ( RegistryIdentifiersVO, @@ -48,6 +48,10 @@ StudyPopulationVO, StudyStatus, ) +from clinical_mdr_api.domains.study_definition_aggregates.study_template import ( + StudyTemplateAR, + StudyTemplateValueVO, +) from clinical_mdr_api.domains.study_selections.study_selection_standard_version import ( StudyStandardVersionVO, ) @@ -69,7 +73,9 @@ StudyPatchRequestJsonModel, StudyPopulationJsonModel, StudyPreferredTimeUnit, + StudyProtocolHeaderVersion, StudyProtocolTitle, + StudySelectionContainmentResult, StudySimple, StudySoaPreferences, StudySoaPreferencesInput, @@ -77,9 +83,11 @@ StudySoaSplitInput, StudyStructureOverview, StudyStructureStatistics, - StudySubpartAuditTrail, StudySubpartCreateInput, StudySubpartReorderingInput, + StudyTemplate, + StudyTemplateInput, + StudyTemplatePatchInput, StudyVersionHistory, ) from clinical_mdr_api.models.utils import GenericFilteringReturn @@ -111,6 +119,8 @@ from common.telemetry import trace_calls from common.utils import booltostr +log = logging.getLogger(__name__) + def validate_if_study_is_not_locked( study_uid_arg_name: str, study_uid_arg_index: int = 0 @@ -417,6 +427,34 @@ def get_study_structure_statistics(self, uid: str) -> StudyStructureStatistics: return StudyStructureStatistics(**counters) + @ensure_transaction(db) + def get_study_selection_containment( + self, source_study_uid: str, target_study_uid: str + ) -> StudySelectionContainmentResult: + """ + Check whether **target** study selection statistics (labels taken from target) + are numerically contained in **source** (target counts <= source per label). + + ``StudySoAFootnote`` is excluded from the comparison (clone-dependent). + + Validates that both studies exist. + """ + if ( + self._repos.study_definition_repository.find_by_uid(source_study_uid) + is None + ): + raise NotFoundException("Study Definition", source_study_uid) + if ( + self._repos.study_definition_repository.find_by_uid(target_study_uid) + is None + ): + raise NotFoundException("Study Definition", target_study_uid) + raw = self._repos.study_definition_repository.get_study_selection_containment( + source_study_uid=source_study_uid, + target_study_uid=target_study_uid, + ) + return StudySelectionContainmentResult(**raw) + def _group_study_structure_overview_by_data(self, items): parsed_items: dict[tuple[Any, ...], StudyStructureOverview] = {} @@ -985,12 +1023,23 @@ def get_fields_audit_trail_by_uid( @db.transaction def get_subpart_audit_trail_by_uid( - self, uid: str, is_subpart: bool = False, study_value_version: str | None = None - ) -> list[StudySubpartAuditTrail]: + self, + uid: str, + is_subpart: bool = False, + study_value_version: str | None = None, + page_number: int = 1, + page_size: int = 0, + total_count: bool = False, + ) -> GenericFilteringReturn: try: return ( self._repos.study_definition_repository.get_subpart_audit_trail_by_uid( - uid, is_subpart, study_value_version=study_value_version + uid, + is_subpart, + study_value_version=study_value_version, + page_number=page_number, + page_size=page_size, + total_count=total_count, ) ) finally: @@ -1127,10 +1176,18 @@ def get_protocol_header_version( self, study_uid: str, study_value_version: str | None = None, - ) -> str | None: - return self._repos.study_definition_document_repository.get_latest_protocol_header_version( + ) -> StudyProtocolHeaderVersion: + protocol_header_version = self._repos.study_definition_document_repository.get_latest_protocol_header_version( study_uid=study_uid, study_value_version=study_value_version ) + has_final_protocol = self._repos.study_definition_document_repository.has_final_protocol_locked_version( + study_uid=study_uid, + study_value_version=study_value_version, + ) + return StudyProtocolHeaderVersion( + protocol_header_version=protocol_header_version, + has_final_protocol_locked_version=has_final_protocol, + ) def get_distinct_values_for_header( self, @@ -1309,6 +1366,42 @@ def clone_study( msg="Study Element should be also included", ) list_of_items_to_copy.append("StudyDesignCell") + if study_clone_input.copy_study_soa_group: + list_of_items_to_copy.append("StudySoAGroup") + if study_clone_input.copy_study_activity_group: + list_of_items_to_copy.append("StudyActivityGroup") + if study_clone_input.copy_study_activity_subgroup: + BusinessLogicException.raise_if( + study_clone_input.copy_study_activity_group is False, + msg="Study Activity Group should be also included", + ) + list_of_items_to_copy.append("StudyActivitySubGroup") + if study_clone_input.copy_study_activity: + BusinessLogicException.raise_if( + study_clone_input.copy_study_activity_group is False, + msg="Study Activity Group should be also included", + ) + BusinessLogicException.raise_if( + study_clone_input.copy_study_activity_subgroup is False, + msg="Study Activity Subgroup should be also included", + ) + list_of_items_to_copy.append("StudyActivity") + if study_clone_input.copy_study_activity_instance: + BusinessLogicException.raise_if( + study_clone_input.copy_study_activity is False, + msg="Study Activity should be also included", + ) + list_of_items_to_copy.append("StudyActivityInstance") + if study_clone_input.copy_study_activity_schedule: + BusinessLogicException.raise_if( + study_clone_input.copy_study_visit is False, + msg="Study Visit should be also included", + ) + BusinessLogicException.raise_if( + study_clone_input.copy_study_activity is False, + msg="Study Activity should be also included", + ) + list_of_items_to_copy.append("StudyActivitySchedule") BusinessLogicException.raise_if_not( study_clone_input.copy_study_arm or study_clone_input.copy_study_branch_arm @@ -1318,20 +1411,26 @@ def clone_study( or study_clone_input.copy_study_visit or study_clone_input.copy_study_visits_study_footnote or study_clone_input.copy_study_epochs_study_footnote - or study_clone_input.copy_study_design_matrix, + or study_clone_input.copy_study_design_matrix + or study_clone_input.copy_study_soa_group + or study_clone_input.copy_study_activity_group + or study_clone_input.copy_study_activity_subgroup + or study_clone_input.copy_study_activity + or study_clone_input.copy_study_activity_instance + or study_clone_input.copy_study_activity_schedule, msg="At least one item should be selected", ) # Validate source study integrity before cloning validation_mode = study_clone_input.validation_mode - logging.info( + log.info( "Running integrity checks for source study %s (mode: %s)", study_src_uid, validation_mode, ) execute_all_checks_for_study(study_src_uid, mode=validation_mode) - logging.info("Integrity checks passed for source study %s", study_src_uid) + log.info("Integrity checks passed for source study %s", study_src_uid) self._repos.study_definition_repository.copy_study_items( study_src_uid=study_src_uid, @@ -1340,14 +1439,60 @@ def clone_study( author_id=self.author_id, ) + containment = self.get_study_selection_containment( + source_study_uid=study_src_uid, + target_study_uid=study_created.uid, + ) + if not containment.target_contained_in_source: + failed_detail = "; ".join( + ( + f"{row.label} (target selections={row.target_selection_count}, " + f"source={row.source_selection_count}; " + f"target distinct CT term roots={row.target_distinct_ct_term_root_count}, " + f"source={row.source_distinct_ct_term_root_count})" + ) + for row in containment.per_label + if not row.label_contained + ) + containment_msg = ( + "Cloned study selection counts are not contained in the source study " + "for one or more labels (StudySoAFootnote excluded). " + f"{failed_detail}" + ) + if validation_mode == ValidationMode.STRICT: + log.error( + "Study selection containment failed after clone (strict): " + "source=%s target=%s. %s", + study_src_uid, + study_created.uid, + failed_detail, + ) + raise BusinessLogicException(msg=containment_msg) + log.warning( + "Study selection containment check did not pass after clone " + "(warning mode; continuing): source=%s target=%s. %s", + study_src_uid, + study_created.uid, + containment_msg, + ) + else: + log.info( + "Study selection containment passed for clone (mode=%s): source=%s " + "target=%s (%d label(s) compared)", + validation_mode.value, + study_src_uid, + study_created.uid, + len(containment.labels_from_target), + ) + # Validate cloned study integrity after cloning - logging.info( + log.info( "Running integrity checks for cloned study %s (mode: %s)", study_created.uid, validation_mode, ) execute_all_checks_for_study(study_created.uid, mode=validation_mode) - logging.info("Integrity checks passed for cloned study %s", study_created.uid) + log.info("Integrity checks passed for cloned study %s", study_created.uid) return study_created @@ -2782,7 +2927,7 @@ def run_integrity_checks(self, study_uids: list[str] | None = None) -> tuple[ ValueError, AttributeError, ) as normalize_error: - logging.warning( + log.warning( "Error normalizing noncompliant_node_ids for check %s: %s. Raw data: %s", check_result.check_id, normalize_error, @@ -2804,7 +2949,7 @@ def run_integrity_checks(self, study_uids: list[str] | None = None) -> tuple[ ) except (ValueError, TypeError) as validation_error: # If validation fails, create a simplified result with error - logging.warning( + log.warning( "Error creating IntegrityCheckResult for check %s: %s", check_result.check_id, validation_error, @@ -2903,3 +3048,166 @@ def run_integrity_check_for_study( raise NotFoundException(f"Study with uid {study_uid} was not found.") return study_results[0] + + def ensure_study_integrity_after_mutation( + self, study_uid: str, *, context: str + ) -> None: + """ + Run study integrity checks after a mutation and log warning on failures. + + Args: + study_uid: UID of the mutated study. + context: Human-readable mutation context for log attribution. + """ + integrity = self.run_integrity_check_for_study(study_uid) + if not integrity.all_passed or integrity.error: + failed_check_ids = [c.check_id for c in integrity.checks if not c.passed] + log.warning( + "Study integrity check failed after %s: study_uid=%s " + "integrity_error=%s failed_check_ids=%s", + context, + study_uid, + integrity.error, + failed_check_ids, + ) + + @staticmethod + def _to_study_template_model(study_template_ar: StudyTemplateAR) -> StudyTemplate: + return StudyTemplate( + uid=study_template_ar.uid or "", + study_uid=study_template_ar.value.study_uid, + study_value_version=study_template_ar.value.study_value_version, + status=study_template_ar.item_metadata.status, + version=study_template_ar.item_metadata.version, + change_description=study_template_ar.item_metadata.change_description, + ) + + def _validate_study_template_target( + self, study_uid: str, study_value_version: str + ) -> None: + self.check_if_study_uid_and_version_exists( + study_uid=study_uid, study_value_version=study_value_version + ) + + def _get_single_study_template( + self, status: LibraryItemStatus | None = None + ) -> StudyTemplateAR | None: + items = list(self._repos.study_template_repository.find_all(status=status)) + BusinessLogicException.raise_if( + len(items) > 1, + msg="Only one Study Template configuration is allowed.", + ) + return items[0] if items else None + + @db.transaction + def get_study_template(self) -> StudyTemplate | None: + item = self._get_single_study_template() + if item is None or ( + item.item_metadata.status + not in [LibraryItemStatus.FINAL, LibraryItemStatus.RETIRED] + ): + return None + return self._to_study_template_model(item) + + @db.transaction + def create_study_template( + self, study_template_input: StudyTemplateInput + ) -> StudyTemplate: + self._validate_study_template_target( + study_uid=study_template_input.study_uid, + study_value_version=study_template_input.study_value_version, + ) + existing = self._get_single_study_template() + BusinessLogicException.raise_if( + existing is not None, + msg="Study Template configuration already exists. Use PATCH to update it.", + ) + + study_template_ar = StudyTemplateAR.from_input_values( + author_id=self.author_id, + generate_uid_callback=self._repos.study_template_repository.generate_uid_callback, + study_template_value=StudyTemplateValueVO.from_input_values( + study_uid=study_template_input.study_uid, + study_value_version=study_template_input.study_value_version, + ), + ) + study_template_ar.approve(self.author_id) + self._repos.study_template_repository.save(study_template_ar) + return self._to_study_template_model(study_template_ar) + + @db.transaction + def patch_study_template( + self, patch_input: StudyTemplatePatchInput + ) -> StudyTemplate: + study_uid_value = patch_input.study_uid.strip() + if not study_uid_value: + study_uid_value = "" + study_value_version_value = "" + else: + study_value_version_value = patch_input.study_value_version.strip() + + if study_uid_value: + self._validate_study_template_target( + study_uid=study_uid_value, + study_value_version=study_value_version_value, + ) + study_template_ar = self._get_single_study_template() + NotFoundException.raise_if( + study_template_ar is None, "Study Template configuration", "latest" + ) + study_template_ar = self._repos.study_template_repository.find_by_uid_2( + study_template_ar.uid, for_update=True + ) + NotFoundException.raise_if( + study_template_ar is None, "Study Template configuration", "latest" + ) + + if study_template_ar.item_metadata.status in [ + LibraryItemStatus.FINAL, + LibraryItemStatus.RETIRED, + ]: + study_template_ar.create_new_version(self.author_id) + + study_template_ar.edit_draft( + author_id=self.author_id, + change_description=patch_input.change_description, + new_study_template_value=StudyTemplateValueVO.from_input_values( + study_uid=study_uid_value, + study_value_version=study_value_version_value, + ), + ) + study_template_ar.approve(self.author_id) + self._repos.study_template_repository.save(study_template_ar) + return self._to_study_template_model(study_template_ar) + + @db.transaction + def retire_study_template(self) -> StudyTemplate: + study_template_ar = self._get_single_study_template() + NotFoundException.raise_if( + study_template_ar is None, "Study Template configuration", "latest" + ) + study_template_ar = self._repos.study_template_repository.find_by_uid_2( + study_template_ar.uid, for_update=True + ) + NotFoundException.raise_if( + study_template_ar is None, "Study Template configuration", "latest" + ) + study_template_ar.inactivate(self.author_id) + self._repos.study_template_repository.save(study_template_ar) + return self._to_study_template_model(study_template_ar) + + @db.transaction + def reactivate_study_template(self) -> StudyTemplate: + study_template_ar = self._get_single_study_template() + NotFoundException.raise_if( + study_template_ar is None, "Study Template configuration", "latest" + ) + study_template_ar = self._repos.study_template_repository.find_by_uid_2( + study_template_ar.uid, for_update=True + ) + NotFoundException.raise_if( + study_template_ar is None, "Study Template configuration", "latest" + ) + study_template_ar.reactivate(self.author_id) + self._repos.study_template_repository.save(study_template_ar) + return self._to_study_template_model(study_template_ar) diff --git a/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_instance_selection.py b/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_instance_selection.py index 422d21f1..80028227 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_instance_selection.py +++ b/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_instance_selection.py @@ -444,6 +444,43 @@ def _patch_prepare_new_value_object( origin_source_uid=request_object.origin_source_uid, ) + @ensure_transaction(db) + def patch_selection( + self, + study_uid: str, + study_selection_uid: str, + selection_update_input: StudySelectionActivityInstanceEditInput, + ): + super().patch_selection( + study_uid=study_uid, + study_selection_uid=study_selection_uid, + selection_update_input=selection_update_input, + ) + # Fetch the single updated VO directly (avoids loading the full aggregate). + return self._get_specific_selection_vo_as_response( + study_uid=study_uid, + study_selection_uid=study_selection_uid, + ) + + def _get_specific_selection_vo_as_response( + self, + study_uid: str, + study_selection_uid: str, + ) -> StudySelectionActivityInstance: + """Fetch a single fully-resolved VO and convert to response model.""" + vo = self._repos.study_activity_instance_repository.find_selection_vo_by_uid( + study_uid=study_uid, + study_selection_uid=study_selection_uid, + ) + if vo is None: + raise exceptions.NotFoundException( + msg=f"Study activity instance selection with UID '{study_selection_uid}' not found." + ) + return self._transform_from_vo_to_response_model( + study_uid=study_uid, + specific_selection=vo, + ) + def get_specific_selection( self, study_uid: str, diff --git a/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_selection.py b/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_selection.py index 084bb13c..12f8e407 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_selection.py +++ b/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_selection.py @@ -268,13 +268,22 @@ def _validate_no_study_wide_duplicate( def _update_aggregate( self, selection_aggregate: StudySelectionActivityAR, - previous_selection: StudySelectionActivityVO, updated_selection: StudySelectionActivityVO, - ): - self._validate_no_study_wide_duplicate( - study_uid=selection_aggregate.study_uid, - updated_selection=updated_selection, - ) + previous_selection: StudySelectionActivityVO | None = None, + ) -> StudySelectionActivityVO: + updated_vo: StudySelectionActivityVO | None = None + # Only re-validate uniqueness when fields that affect duplicate detection have actually changed + if ( + previous_selection.activity_name != updated_selection.activity_name + or previous_selection.activity_subgroup_uid + != updated_selection.activity_subgroup_uid + or previous_selection.activity_group_uid + != updated_selection.activity_group_uid + ): + self._validate_no_study_wide_duplicate( + study_uid=selection_aggregate.study_uid, + updated_selection=updated_selection, + ) if ( previous_selection.study_activity_subgroup_uid != updated_selection.study_activity_subgroup_uid @@ -284,14 +293,28 @@ def _update_aggregate( and previous_selection.study_soa_group_uid != updated_selection.study_soa_group_uid ): - new_selection_aggregate = self.repository.find_by_study( - study_uid=selection_aggregate.study_uid, - for_update=True, - study_activity_subgroup_uid=updated_selection.study_activity_subgroup_uid, - study_soa_group_uid=updated_selection.study_soa_group_uid, - find_requested_study_activities=updated_selection.activity_library_name - == settings.requested_library_name, - ) + find_requested = ( + updated_selection.activity_library_name + == settings.requested_library_name + ) + activity_ar_cache_key = ( + updated_selection.study_activity_subgroup_uid, + updated_selection.study_soa_group_uid, + find_requested, + ) + ar_cache_key = self._get_batch_ar_cache_key(*activity_ar_cache_key) + if self._batch_cache is not None and ar_cache_key in self._batch_cache: + new_selection_aggregate = self._batch_cache[ar_cache_key] + else: + new_selection_aggregate = self.repository.find_by_study( + study_uid=selection_aggregate.study_uid, + for_update=True, + study_activity_subgroup_uid=updated_selection.study_activity_subgroup_uid, + study_soa_group_uid=updated_selection.study_soa_group_uid, + find_requested_study_activities=find_requested, + ) + if self._batch_cache is not None: + self._batch_cache[ar_cache_key] = new_selection_aggregate new_selection_aggregate.add_object_selection( updated_selection, object_exist_callback=self._get_selected_object_exist_check(), @@ -302,6 +325,22 @@ def _update_aggregate( ) new_selection_aggregate.validate() self.repository.save(new_selection_aggregate, author_id=self.author) + # Invalidate Activity cache only when activity_uid changed (used_by_studies affected) + if previous_selection.activity_uid != updated_selection.activity_uid: + with self._repos.activity_repository.lock_store_item_by_uid: + self._repos.activity_repository.cache_store_item_by_uid.clear() + # Grab the VO with the correct order written back by save() + updated_vo = new_selection_aggregate.get_specific_object_selection( + updated_selection.study_selection_uid + )[ + 0 + ] # type: ignore[assignment] + # Keep closure current so the next batch-mode PATCH with the same destination scope + # sees the already-saved state and only diffs its own new selection. + if self._batch_cache is not None: + new_selection_aggregate.repository_closure_data = ( + new_selection_aggregate.study_objects_selection + ) selection_aggregate.remove_object_selection( updated_selection.study_selection_uid @@ -309,39 +348,60 @@ def _update_aggregate( selection_aggregate.add_selection_to_closure_from_other_ar( updated_selection ) - # The SoAGroup of previous StudyActivity should be updated if currently there is none StudyActivities in that SoAGroup - study_soa_group_aggregate = ( - self._repos.study_soa_group_repository.find_by_study( - study_uid=previous_selection.study_uid, - for_update=True, - ) - ) - - is_soa_group_update_needed = False - for new_order, study_soa_group_selection in enumerate( - study_soa_group_aggregate.study_objects_selection, start=1 - ): - reordered_study_soa_group_selection = dataclasses.replace( - study_soa_group_selection, order=new_order + # Evict the old-scope AR from the activity cache. The moved activity is no longer in + # that scope on DB, but the cached AR's repository_closure_data still contains it. + # A subsequent batch POST to the old scope would reuse the stale AR and the save diff + # would try to issue a redundant Delete audit node for the already-moved activity. + if self._batch_cache is not None: + old_find_requested = ( + previous_selection.activity_library_name + == settings.requested_library_name ) - if study_soa_group_selection.order != new_order: - is_soa_group_update_needed = True - study_soa_group_aggregate.update_selection( - updated_study_object_selection=reordered_study_soa_group_selection, - object_exist_callback=self._repos.activity_subgroup_repository.final_concept_exists, - ct_term_level_exist_callback=self._repos.ct_term_name_repository.term_specific_exists_by_uid, + old_scope_key = ( + previous_selection.study_activity_subgroup_uid, + previous_selection.study_soa_group_uid, + old_find_requested, ) - # if some StudySoAGroup order was changed - if is_soa_group_update_needed: - study_soa_group_aggregate.validate() - # sync with DB and save the update - self._repos.study_soa_group_repository.save( - study_soa_group_aggregate, self.author + old_cache_key = self._get_batch_ar_cache_key(*old_scope_key) + self._batch_cache.pop(old_cache_key, None) + # Recompact SoAGroup / ActivitySubGroup / ActivityGroup orders under the *old* parent + # in case this SA was the last one there and the hierarchy node becomes invisible. + # In batch mode, each unique old-parent scope is reordered at most once. + soa_reorder_key = self._get_batch_reordered_key("soa") + if self._batch_cache is None or soa_reorder_key not in self._batch_cache: + study_soa_group_aggregate = ( + self._repos.study_soa_group_repository.find_by_study( + study_uid=previous_selection.study_uid, + for_update=True, + ) ) + is_soa_group_update_needed = False + for new_order, study_soa_group_selection in enumerate( + study_soa_group_aggregate.study_objects_selection, start=1 + ): + if study_soa_group_selection.order != new_order: + is_soa_group_update_needed = True + reordered_study_soa_group_selection = dataclasses.replace( + study_soa_group_selection, order=new_order + ) + study_soa_group_aggregate.update_selection( + updated_study_object_selection=reordered_study_soa_group_selection, + ) + if is_soa_group_update_needed: + study_soa_group_aggregate.validate() + self._repos.study_soa_group_repository.save( + study_soa_group_aggregate, self.author + ) + if self._batch_cache is not None: + self._batch_cache[soa_reorder_key] = True - # If previous StudyActivity was assigned with some StudyActivitySubGroup - # The past StudyActivitySubGroup orders should be updated - if previous_selection.study_activity_subgroup_uid: + subgroup_reorder_key = self._get_batch_reordered_key( + "subgroup", previous_selection.study_activity_group_uid + ) + if previous_selection.study_activity_subgroup_uid and ( + self._batch_cache is None + or subgroup_reorder_key not in self._batch_cache + ): study_activity_subgroup_aggregate = self._repos.study_activity_subgroup_repository.find_by_study( study_uid=previous_selection.study_uid, for_update=True, @@ -351,27 +411,30 @@ def _update_aggregate( for new_order, study_activity_subgroup_selection in enumerate( study_activity_subgroup_aggregate.study_objects_selection, start=1 ): - reordered_study_activity_subgroup_selection = dataclasses.replace( - study_activity_subgroup_selection, order=new_order - ) - study_activity_subgroup_aggregate.update_selection( - updated_study_object_selection=reordered_study_activity_subgroup_selection, - object_exist_callback=self._repos.activity_subgroup_repository.final_concept_exists, - ct_term_level_exist_callback=self._repos.ct_term_name_repository.term_specific_exists_by_uid, - ) if study_activity_subgroup_selection.order != new_order: is_study_activity_subgroup_update_needed = True - # if some StudyActivitySubGroup order was changed + reordered_study_activity_subgroup_selection = ( + dataclasses.replace( + study_activity_subgroup_selection, order=new_order + ) + ) + study_activity_subgroup_aggregate.update_selection( + updated_study_object_selection=reordered_study_activity_subgroup_selection, + ) if is_study_activity_subgroup_update_needed: study_activity_subgroup_aggregate.validate() - # sync with DB and save the update self._repos.study_activity_subgroup_repository.save( study_activity_subgroup_aggregate, self.author ) + if self._batch_cache is not None: + self._batch_cache[subgroup_reorder_key] = True - # If previous StudyActivitySelection was assigned with some StudyActivityGroup - # The past StudyActivityGroup orders should be updated - if previous_selection.study_activity_group_uid: + group_reorder_key = self._get_batch_reordered_key( + "group", previous_selection.study_soa_group_uid + ) + if previous_selection.study_activity_group_uid and ( + self._batch_cache is None or group_reorder_key not in self._batch_cache + ): study_activity_group_aggregate = ( self._repos.study_activity_group_repository.find_by_study( study_uid=previous_selection.study_uid, @@ -383,23 +446,21 @@ def _update_aggregate( for new_order, study_activity_group_selection in enumerate( study_activity_group_aggregate.study_objects_selection, start=1 ): - reordered_study_activity_group_selection = dataclasses.replace( - study_activity_group_selection, order=new_order - ) - study_activity_group_aggregate.update_selection( - updated_study_object_selection=reordered_study_activity_group_selection, - object_exist_callback=self._repos.activity_group_repository.final_concept_exists, - ct_term_level_exist_callback=self._repos.ct_term_name_repository.term_specific_exists_by_uid, - ) if study_activity_group_selection.order != new_order: is_study_activity_group_update_needed = True - # if some StudyActivityGroup order was changed + reordered_study_activity_group_selection = dataclasses.replace( + study_activity_group_selection, order=new_order + ) + study_activity_group_aggregate.update_selection( + updated_study_object_selection=reordered_study_activity_group_selection, + ) if is_study_activity_group_update_needed: study_activity_group_aggregate.validate() - # sync with DB and save the update self._repos.study_activity_group_repository.save( study_activity_group_aggregate, self.author ) + if self._batch_cache is not None: + self._batch_cache[group_reorder_key] = True else: # let the aggregate update the value object selection_aggregate.update_selection( @@ -409,6 +470,22 @@ def _update_aggregate( ) selection_aggregate.validate() self.repository.save(selection_aggregate, self.author) + # Invalidate Activity cache only when activity_uid changed (used_by_studies affected). + if ( + previous_selection is not None + and previous_selection.activity_uid != updated_selection.activity_uid + ): + with self._repos.activity_repository.lock_store_item_by_uid: + self._repos.activity_repository.cache_store_item_by_uid.clear() + # In the no-parent-change case the activity stayed in selection_aggregate; + # grab the VO with the correct order written back by save(). + if updated_vo is None: + updated_vo = selection_aggregate.get_specific_object_selection( + updated_selection.study_selection_uid + )[ + 0 + ] # type: ignore[assignment] + return updated_vo def update_dependent_objects( self, @@ -472,8 +549,7 @@ def replace_study_activity_with_multiple_activities( - First item in the list replaces the original StudyActivity - Remaining items create new StudyActivities - Schedules are preserved for the replaced StudyActivity and replicated to all new ones - - StudyActivityInstances are recreated for the replaced StudyActivity only (existing behavior) - - StudyActivityInstances are NOT created for newly created StudyActivities + - StudyActivityInstances are recreated for the replaced StudyActivity and newly created StudyActivities """ if not replacements: raise ValidationException( @@ -523,8 +599,8 @@ def replace_study_activity_with_multiple_activities( # Process remaining items: Create new StudyActivities for replacement in replacements[1:]: # Validate activity groupings using the same validation as patch_selection - activity_ar = self._repos.activity_repository.find_by_uid_2( - replacement.activity_uid, for_update=True + activity_ar = self._repos.activity_repository.find_by_uid( + replacement.activity_uid ) NotFoundException.raise_if_not( activity_ar, "Activity", replacement.activity_uid @@ -566,29 +642,6 @@ def replace_study_activity_with_multiple_activities( study_uid=study_uid, selection_create_input=create_input ) - # Remove StudyActivityInstances that were automatically created by make_selection - # We don't want instances for newly created StudyActivities, only for the replaced one - study_activity_instances = self._repos.study_activity_instance_repository.get_all_study_activity_instances_for_study_activity( - study_uid=study_uid, - study_activity_uid=new_study_activity.study_activity_uid, - ) - for study_activity_instance in study_activity_instances: - ( - study_activity_instance_ar, - _, - _, - ) = self._get_specific_activity_instance_selection_by_uids( - study_uid=study_uid, - study_selection_uid=study_activity_instance.uid, - for_update=True, - ) - study_activity_instance_ar.remove_object_selection( - study_activity_instance.uid - ) - self._repos.study_activity_instance_repository.save( - study_activity_instance_ar, self.author - ) - # Replicate schedules to the new StudyActivity # Use the schedules we fetched before replacement (they're still valid) assert ( @@ -605,8 +658,8 @@ def replace_study_activity_with_multiple_activities( return results def _validate_activity(self, activity_uid: str) -> ActivityAR: - activity_ar: ActivityAR = self._repos.activity_repository.find_by_uid_2( - activity_uid, for_update=True + activity_ar: ActivityAR | None = self._repos.activity_repository.find_by_uid( + activity_uid ) NotFoundException.raise_if_not(activity_ar, "Activity", activity_uid) @@ -630,12 +683,26 @@ def _create_value_object( ): # activity_ar: ActivityAR = kwargs.get("activity_ar") study_soa_group_selection_uid = kwargs["study_soa_group_selection_uid"] + study_soa_group_order = kwargs.get("study_soa_group_order") study_activity_subgroup_selection_uid = kwargs.get( "study_activity_subgroup_selection_uid" ) + study_activity_subgroup_order = kwargs.get("study_activity_subgroup_order") study_activity_group_selection_uid = kwargs.get( "study_activity_group_selection_uid" ) + study_activity_group_order = kwargs.get("study_activity_group_order") + activity_group_name = kwargs.get("activity_group_name") + activity_subgroup_name = kwargs.get("activity_subgroup_name") + show_soa_group_in_protocol_flowchart = kwargs.get( + "show_soa_group_in_protocol_flowchart", False + ) + show_activity_group_in_protocol_flowchart = kwargs.get( + "show_activity_group_in_protocol_flowchart", True + ) + show_activity_subgroup_in_protocol_flowchart = kwargs.get( + "show_activity_subgroup_in_protocol_flowchart", True + ) BusinessLogicException.raise_if( activity_ar.library.name != settings.requested_library_name @@ -673,8 +740,16 @@ def _create_value_object( activity_library_name=activity_ar.library.name, soa_group_term_uid=selection_create_input.soa_group_term_uid, study_soa_group_uid=study_soa_group_selection_uid, + study_soa_group_order=study_soa_group_order, study_activity_subgroup_uid=study_activity_subgroup_selection_uid, + study_activity_subgroup_order=study_activity_subgroup_order, study_activity_group_uid=study_activity_group_selection_uid, + study_activity_group_order=study_activity_group_order, + activity_group_name=activity_group_name, + activity_subgroup_name=activity_subgroup_name, + show_soa_group_in_protocol_flowchart=show_soa_group_in_protocol_flowchart, + show_activity_group_in_protocol_flowchart=show_activity_group_in_protocol_flowchart, + show_activity_subgroup_in_protocol_flowchart=show_activity_subgroup_in_protocol_flowchart, order=None, generate_uid_callback=self.repository.generate_uid, activity_subgroup_uid=selection_create_input.activity_subgroup_uid, @@ -829,12 +904,12 @@ def _create_activity_subgroup_selection_value_object( @classmethod def _validate_activity_subgroup( cls, - activity_subgroup_uid: str | None, + activity_subgroup_uid: str, perform_subgroup_validation: bool = True, activity_subgroup_version: str | None = None, ) -> ActivitySubGroupAR: activity_subgroup_service = ActivitySubGroupService() - activity_subgroup_ar = activity_subgroup_service.repository.find_by_uid_2( + activity_subgroup_ar = activity_subgroup_service.repository.find_by_uid( activity_subgroup_uid, version=activity_subgroup_version ) NotFoundException.raise_if_not( @@ -857,12 +932,12 @@ def _validate_activity_subgroup( @classmethod def _validate_activity_group( cls, - activity_group_uid: str | None, + activity_group_uid: str, perform_group_validation: bool = True, activity_group_version: str | None = None, ) -> ActivityGroupAR: activity_group_service = ActivityGroupService() - activity_group_ar = activity_group_service.repository.find_by_uid_2( + activity_group_ar = activity_group_service.repository.find_by_uid( activity_group_uid, version=activity_group_version ) @@ -1015,36 +1090,35 @@ def _patch_soa_group_selection_value_object( is_soa_group_changed: bool, ): soa_group_term_uid = str(selection_create_input.soa_group_term_uid) - selection_aggregate = self._repos.study_soa_group_repository.find_by_study( - study_uid=study_uid - ) - assert selection_aggregate is not None - new_selection, _ = selection_aggregate.get_specific_object_selection( - study_selection_uid=current_study_activity.study_soa_group_uid - ) - if is_soa_group_changed: - ct_term_ar = self._repos.ct_term_name_repository.find_by_uid( - soa_group_term_uid + if not is_soa_group_changed: + # SoA group unchanged — build a lightweight VO from known values; avoids find_by_study round-trip + return StudySoAGroupVO.from_input_values( + study_uid=study_uid, + author_id=self.author, + soa_group_term_uid=current_study_activity.soa_group_term_uid, + study_selection_uid=current_study_activity.study_soa_group_uid, + order=current_study_activity.study_soa_group_order, + show_soa_group_in_protocol_flowchart=current_study_activity.show_soa_group_in_protocol_flowchart, ) - NotFoundException.raise_if_not( - ct_term_ar, "SoA Group CT Term", soa_group_term_uid - ) + ct_term_ar = self._repos.ct_term_name_repository.find_by_uid(soa_group_term_uid) - NotFoundException.raise_if( - ct_term_ar.item_metadata.status - in [ - LibraryItemStatus.DRAFT, - LibraryItemStatus.RETIRED, - ], - msg=f"There is no approved SoAGroup CTTerm with UID '{soa_group_term_uid}'.", - ) - # get VO if possible or create it - new_selection = self._get_or_create_study_soa_group( - study_uid=study_uid, soa_group_term_uid=soa_group_term_uid - ) + NotFoundException.raise_if_not( + ct_term_ar, "SoA Group CT Term", soa_group_term_uid + ) - return new_selection + NotFoundException.raise_if( + ct_term_ar.item_metadata.status + in [ + LibraryItemStatus.DRAFT, + LibraryItemStatus.RETIRED, + ], + msg=f"There is no approved SoAGroup CTTerm with UID '{soa_group_term_uid}'.", + ) + # get VO if possible or create it + return self._get_or_create_study_soa_group( + study_uid=study_uid, soa_group_term_uid=soa_group_term_uid + ) def _create_soa_group_selection_value_object( self, study_uid: str, soa_group_term_uid: str @@ -1076,6 +1150,10 @@ def _create_soa_group_selection_value_object( def _get_or_create_study_soa_group( self, study_uid: str, soa_group_term_uid: str ) -> StudySoAGroupVO: + cache_key = self._get_batch_vo_cache_key("soa", study_uid, soa_group_term_uid) + if self._batch_cache is not None and cache_key in self._batch_cache: + return self._batch_cache[cache_key] + study_soa_group_node = ( self._repos.study_soa_group_repository.find_study_soa_group_in_a_study( study_uid=study_uid, @@ -1083,12 +1161,15 @@ def _get_or_create_study_soa_group( ) ) if study_soa_group_node: - ( - _, - study_soa_group_selection, - _, - ) = self._get_specific_soa_group_selection_by_uids( - study_uid=study_uid, study_selection_uid=study_soa_group_node.uid + # Node already found — construct a lightweight VO directly; avoids a second find_by_study. + # Pass the node's order and show_* flag so the VO carries them without a reload. + study_soa_group_selection = StudySoAGroupVO.from_input_values( + study_uid=study_uid, + author_id=self.author, + soa_group_term_uid=soa_group_term_uid, + study_selection_uid=study_soa_group_node.uid, + order=study_soa_group_node.order, + show_soa_group_in_protocol_flowchart=study_soa_group_node.show_soa_group_in_protocol_flowchart, ) else: study_soa_group_selection = self._create_soa_group_selection_value_object( @@ -1109,6 +1190,14 @@ def _get_or_create_study_soa_group( self._repos.study_soa_group_repository.save( study_soa_group_aggregate, self.author ) + # save() writes the DB-assigned order back into the aggregate; read the updated VO. + study_soa_group_selection, _ = ( + study_soa_group_aggregate.get_specific_object_selection( + study_soa_group_selection.study_selection_uid + ) + ) + if self._batch_cache is not None: + self._batch_cache[cache_key] = study_soa_group_selection return study_soa_group_selection def _get_or_create_study_activity_subgroup( @@ -1128,23 +1217,25 @@ def _get_or_create_study_activity_subgroup( ) if activity_subgroup_uid and activity_group_uid and soa_group_term_uid: - study_activity_subgroup_node = self._repos.study_activity_subgroup_repository.find_study_activity_subgroup_with_same_groupings( + cache_key = self._get_batch_vo_cache_key( + "subgroup", + study_uid, + activity_subgroup_uid, + activity_group_uid, + soa_group_term_uid, + sync_latest_version, + ) + if self._batch_cache is not None and cache_key in self._batch_cache: + return self._batch_cache[cache_key] + + study_activity_subgroup_selection = self._repos.study_activity_subgroup_repository.find_study_activity_subgroup_vo_with_same_groupings( study_uid=study_uid, activity_subgroup_uid=activity_subgroup_uid, activity_group_uid=activity_group_uid, soa_group_term_uid=soa_group_term_uid, sync_latest_version=sync_latest_version, ) - if study_activity_subgroup_node: - ( - _, - study_activity_subgroup_selection, - _, - ) = self._get_specific_activity_subgroup_selection_by_uids( - study_uid=study_uid, - study_selection_uid=study_activity_subgroup_node.uid, - ) - else: + if study_activity_subgroup_selection is None: # create new VO to add study_activity_subgroup_selection = ( self._create_activity_subgroup_selection_value_object( @@ -1172,6 +1263,14 @@ def _get_or_create_study_activity_subgroup( self._repos.study_activity_subgroup_repository.save( study_activity_subgroup_aggregate, self.author ) + # save() writes the DB-assigned order back into the aggregate; read the updated VO. + study_activity_subgroup_selection, _ = ( + study_activity_subgroup_aggregate.get_specific_object_selection( + study_activity_subgroup_selection.study_selection_uid + ) + ) + if self._batch_cache is not None: + self._batch_cache[cache_key] = study_activity_subgroup_selection return study_activity_subgroup_selection def _get_or_create_study_activity_group( @@ -1193,22 +1292,23 @@ def _get_or_create_study_activity_group( and soa_group_term_uid and study_soa_group_uid ): - study_activity_group_node = self._repos.study_activity_group_repository.find_study_activity_group_with_same_groupings( + cache_key = self._get_batch_vo_cache_key( + "group", + study_uid, + activity_group_uid, + soa_group_term_uid, + sync_latest_version, + ) + if self._batch_cache is not None and cache_key in self._batch_cache: + return self._batch_cache[cache_key] + + study_activity_group_selection = self._repos.study_activity_group_repository.find_study_activity_group_vo_with_same_groupings( study_uid=study_uid, activity_group_uid=activity_group_uid, soa_group_term_uid=soa_group_term_uid, sync_latest_version=sync_latest_version, ) - if study_activity_group_node: - ( - _, - study_activity_group_selection, - _, - ) = self._get_specific_activity_group_selection_by_uids( - study_uid=study_uid, - study_selection_uid=study_activity_group_node.uid, - ) - else: + if study_activity_group_selection is None: # create new VO to add study_activity_group_selection = ( self._create_activity_group_selection_value_object( @@ -1236,6 +1336,14 @@ def _get_or_create_study_activity_group( self._repos.study_activity_group_repository.save( study_activity_group_aggregate, self.author ) + # save() writes the DB-assigned order back into the aggregate; read the updated VO. + study_activity_group_selection, _ = ( + study_activity_group_aggregate.get_specific_object_selection( + study_activity_group_selection.study_selection_uid + ) + ) + if self._batch_cache is not None: + self._batch_cache[cache_key] = study_activity_group_selection return study_activity_group_selection def _create_study_activity_instances( @@ -1266,6 +1374,20 @@ def _create_study_activity_instances( if len(linked_activity_instances) == 0: linked_activity_instances[None] = False + # In batch mode reuse the cached AR so all POSTs share one find_by_study + one save; + # in non-batch mode load fresh and save immediately as before. + if self._batch_instance_ar is not None: + study_activity_instance_aggregate = self._batch_instance_ar + else: + study_activity_instance_aggregate = ( + self._repos.study_activity_instance_repository.find_by_study( + study_uid=study_uid, for_update=True + ) + ) + assert study_activity_instance_aggregate is not None + if self._batch_cache is not None: + # Batch mode — cache for reuse by subsequent POST operations + self._batch_instance_ar = study_activity_instance_aggregate for ( activity_instance_uid, is_required_for_activity, @@ -1281,45 +1403,43 @@ def _create_study_activity_instances( generate_uid_callback=self._repos.study_activity_instance_repository.generate_uid, is_reviewed=is_required_for_activity, ) # add VO to aggregate - - study_activity_instance_aggregate = ( - self._repos.study_activity_instance_repository.find_by_study( - study_uid=study_uid, for_update=True - ) - ) - assert study_activity_instance_aggregate is not None study_activity_instance_aggregate.add_object_selection( activity_instance_selection, self._repos.activity_instance_repository.check_exists_final_version, ) - study_activity_instance_aggregate.validate() - # sync with DB and save the update + study_activity_instance_aggregate.validate() + if self._batch_instance_ar is None: + # Non-batch mode — save immediately self._repos.study_activity_instance_repository.save( study_activity_instance_aggregate, self.author ) + # Batch mode: AR is already cached; the batch finally block flushes once at the end def _recreate_study_activity_instances_after_activity_replacement( self, study_uid: str, study_activity_selection: StudySelectionActivityVO ): - # Remove related Study activity instances + # Flush any deferred batch-mode instance saves so the DB query below sees current state + if self._batch_instance_ar is not None: + self._repos.study_activity_instance_repository.save( + self._batch_instance_ar, self.author + ) + self._batch_instance_ar = None + # Remove related Study activity instances — load aggregate once, remove all, save once study_activity_instances = self._repos.study_activity_instance_repository.get_all_study_activity_instances_for_study_activity( study_uid=study_uid, study_activity_uid=study_activity_selection.study_selection_uid, ) - for study_activity_instance in study_activity_instances: - # delete study activity instance - ( - study_activity_instance_ar, - _, - _, - ) = self._get_specific_activity_instance_selection_by_uids( - study_uid=study_uid, - study_selection_uid=study_activity_instance.uid, - for_update=True, - ) - study_activity_instance_ar.remove_object_selection( - study_activity_instance.uid + if study_activity_instances: + study_activity_instance_ar = ( + self._repos.study_activity_instance_repository.find_by_study( + study_uid, for_update=True + ) ) + assert study_activity_instance_ar is not None + for study_activity_instance in study_activity_instances: + study_activity_instance_ar.remove_object_selection( + study_activity_instance.uid + ) self._repos.study_activity_instance_repository.save( study_activity_instance_ar, self.author ) @@ -1350,10 +1470,13 @@ def make_selection( ) -> StudySelectionActivity: repos = self._repos try: - study_soa_group_selection_uid = self._get_or_create_study_soa_group( + study_soa_group_selection = self._get_or_create_study_soa_group( study_uid=study_uid, soa_group_term_uid=selection_create_input.soa_group_term_uid, - ).study_selection_uid + ) + study_soa_group_selection_uid = ( + study_soa_group_selection.study_selection_uid + ) activity_ar = self._validate_activity(selection_create_input.activity_uid) @@ -1402,18 +1525,67 @@ def make_selection( selection_create_input=selection_create_input, activity_ar=activity_ar, study_soa_group_selection_uid=study_soa_group_selection_uid, + study_soa_group_order=study_soa_group_selection.order, study_activity_subgroup_selection_uid=study_activity_subgroup_selection_uid, + study_activity_subgroup_order=( + study_activity_subgroup_selection.order + if study_activity_subgroup_selection + else None + ), study_activity_group_selection_uid=study_activity_group_selection_uid, + study_activity_group_order=( + study_activity_group_selection.order + if study_activity_group_selection + else None + ), + activity_group_name=( + study_activity_group_selection.activity_group_name + if study_activity_group_selection + else None + ), + activity_subgroup_name=( + study_activity_subgroup_selection.activity_subgroup_name + if study_activity_subgroup_selection + else None + ), + show_soa_group_in_protocol_flowchart=study_soa_group_selection.show_soa_group_in_protocol_flowchart, + show_activity_group_in_protocol_flowchart=( + study_activity_group_selection.show_activity_group_in_protocol_flowchart + if study_activity_group_selection + else True + ), + show_activity_subgroup_in_protocol_flowchart=( + study_activity_subgroup_selection.show_activity_subgroup_in_protocol_flowchart + if study_activity_subgroup_selection + else True + ), ) # add VO to aggregate - study_activity_aggregate = self.repository.find_by_study( - study_uid=study_uid, - for_update=True, - study_activity_subgroup_uid=study_activity_subgroup_selection_uid, - study_soa_group_uid=study_soa_group_selection_uid, - find_requested_study_activities=study_activity_selection.activity_library_name - == settings.requested_library_name, + # In batch mode reuse the cached AR so all POSTs with the same (subgroup, soa) grouping + # share one find_by_study(for_update) + acquire._write_lock; the AR is saved per-POST + # but closure_data is updated after each save so the diff stays correct. + find_requested = ( + study_activity_selection.activity_library_name + == settings.requested_library_name ) + activity_ar_cache_key = ( + study_activity_subgroup_selection_uid, + study_soa_group_selection_uid, + find_requested, + ) + ar_cache_key = self._get_batch_ar_cache_key(*activity_ar_cache_key) + if self._batch_cache is not None and ar_cache_key in self._batch_cache: + study_activity_aggregate = self._batch_cache[ar_cache_key] + else: + study_activity_aggregate = self.repository.find_by_study( + study_uid=study_uid, + for_update=True, + study_activity_subgroup_uid=study_activity_subgroup_selection_uid, + study_soa_group_uid=study_soa_group_selection_uid, + find_requested_study_activities=find_requested, + ) + if self._batch_cache is not None: + self._batch_cache[ar_cache_key] = study_activity_aggregate assert study_activity_aggregate is not None study_activity_aggregate.add_object_selection( study_activity_selection, @@ -1425,8 +1597,16 @@ def make_selection( updated_selection=study_activity_selection, ) study_activity_aggregate.validate() - # sync with DB and save the update self.repository.save(study_activity_aggregate, self.author) + # Invalidate Activity cache so GET /concepts/activities/... will recalculate used_by_studies + with repos.activity_repository.lock_store_item_by_uid: + repos.activity_repository.cache_store_item_by_uid.clear() + # Update closure so the cached AR reflects the just-saved state; the next POST + # in the same batch will then only diff its own new selection. + if self._batch_cache is not None: + study_activity_aggregate.repository_closure_data = ( + study_activity_aggregate.study_objects_selection + ) # create StudyActivityInstance selection if ( @@ -1439,23 +1619,24 @@ def make_selection( study_activity_selection=study_activity_selection, ) - study_activity_aggregate = self.repository.find_by_study( - study_uid=study_uid, - ) - # Fetch the new selection which was just added - ( - new_selection, - _, - ) = study_activity_aggregate.get_specific_object_selection( + # save() writes the computed order back into the in-memory aggregate, + # so we can read the fully-populated VO directly without a DB round-trip. + new_selection, _ = study_activity_aggregate.get_specific_object_selection( study_activity_selection.study_selection_uid ) + + # In batch mode, update the patch AR selections cache with the new VO + # so subsequent PATCHes see the newly created entity without a DB reload. + if self._batch_cache is not None: + self._append_to_patch_ar_selections_cache(new_selection) + terms_at_specific_datetime = self._extract_study_standards_effective_date( study_uid=study_uid ) # add the activity and return return self._transform_from_vo_to_response_model( study_uid=study_activity_aggregate.study_uid, - specific_selection=new_selection, + specific_selection=new_selection, # type: ignore[arg-type] terms_at_specific_datetime=terms_at_specific_datetime, ) finally: @@ -1472,8 +1653,8 @@ def delete_selection(self, study_uid: str, study_selection_uid: str): repos = self._repos try: - # Load aggregate - selection_aggregate, _ = self._find_ar_to_patch( + # Load aggregate and capture the VO being deleted (needed for cache updates) + selection_aggregate, deleted_vo = self._find_ar_to_patch( study_uid=study_uid, study_selection_uid=study_selection_uid ) @@ -1562,26 +1743,29 @@ def delete_selection(self, study_uid: str, study_selection_uid: str): # Remove related Study activity instances with trace_block("Removing related study activity instances"): + # Flush any deferred batch-mode instance saves so the DB query below sees current state + if self._batch_instance_ar is not None: + repos.study_activity_instance_repository.save( + self._batch_instance_ar, self.author + ) + self._batch_instance_ar = None study_activity_instances = repos.study_activity_instance_repository.get_all_study_activity_instances_for_study_activity( study_uid=study_uid, study_activity_uid=study_selection_uid ) - for study_activity_instance in study_activity_instances: - # Skip placeholders (they don't have a database node to delete) - if study_activity_instance.uid is None: - continue - # delete study activity instance - ( - study_activity_instance_ar, - _, - _, - ) = self._get_specific_activity_instance_selection_by_uids( - study_uid=study_uid, - study_selection_uid=study_activity_instance.uid, - for_update=True, - ) - study_activity_instance_ar.remove_object_selection( - study_activity_instance.uid + non_placeholder_instances = [ + inst for inst in study_activity_instances if inst.uid is not None + ] + if non_placeholder_instances: + study_activity_instance_ar = ( + repos.study_activity_instance_repository.find_by_study( + study_uid, for_update=True + ) ) + assert study_activity_instance_ar is not None + for study_activity_instance in non_placeholder_instances: + study_activity_instance_ar.remove_object_selection( + study_activity_instance.uid + ) repos.study_activity_instance_repository.save( study_activity_instance_ar, self.author ) @@ -1593,7 +1777,17 @@ def delete_selection(self, study_uid: str, study_selection_uid: str): # sync with DB and save the update repos.study_activity_repository.save(selection_aggregate, self.author) # Invalidate Activity cache so GET /concepts/activities/... will recalculate used_by_studies - repos.activity_repository.cache_store_item_by_uid.clear() + with repos.activity_repository.lock_store_item_by_uid: + repos.activity_repository.cache_store_item_by_uid.clear() + + # In batch mode, update caches with targeted eviction instead of blanket invalidation. + if self._batch_cache is not None: + self._remove_from_patch_ar_selections_cache(study_selection_uid) + self._evict_ar_cache_for_scope( + study_activity_subgroup_uid=deleted_vo.study_activity_subgroup_uid, + study_soa_group_uid=deleted_vo.study_soa_group_uid, + activity_library_name=deleted_vo.activity_library_name, + ) finally: repos.close() @@ -1607,6 +1801,7 @@ def _update_underlying_activity_if_needed( | UpdateActivityPlaceholderToSponsorActivity ), ): + activity_ar: ActivityAR | None # update underlying Activity if isinstance(request_object, StudySelectionActivityRequestEditInput): activity_ar = self._patch_selected_activity( @@ -1623,7 +1818,7 @@ def _update_underlying_activity_if_needed( ) and request_object.activity_uid ): - activity_ar = self._repos.activity_repository.find_by_uid_2( + activity_ar = self._repos.activity_repository.find_by_uid( request_object.activity_uid ) ValidationException.raise_if_not( @@ -1631,7 +1826,14 @@ def _update_underlying_activity_if_needed( msg=f"The Activity with UID '{current_object.activity_uid}' doesn't exist.", ) else: - activity_ar = self._repos.activity_repository.find_by_uid_2( + # Skip fetching activity AR when no grouping-related fields are changing; + # _patch_prepare_new_value_object falls back to current_object values in that case + if ( + request_object.activity_group_uid is None + and request_object.activity_subgroup_uid is None + ): + return None + activity_ar = self._repos.activity_repository.find_by_uid( current_object.activity_uid, version=current_object.activity_version, ) @@ -1645,9 +1847,11 @@ def _validate_new_activity_groupings( | StudySelectionActivityRequestEditInput | UpdateActivityPlaceholderToSponsorActivity ), - activity_ar: ActivityAR, + activity_ar: ActivityAR | None, current_object: StudySelectionActivityVO, ): + if activity_ar is None: + return ValidationException.raise_if( request_object.activity_group_uid is None and request_object.activity_subgroup_uid is not None @@ -1706,10 +1910,7 @@ def _patch_or_get_study_activity_group( is_soa_group_changed: bool, study_soa_group_uid: str, sync_latest_version: bool = False, - ): - activity_group_uid = current_object.activity_group_uid - activity_group_name = current_object.activity_group_name - study_activity_group_uid = current_object.study_activity_group_uid + ) -> StudySelectionActivityGroupVO: soa_group_term_uid = ( request_object.soa_group_term_uid if not isinstance(request_object, StudyActivitySyncLatestVersionInput) @@ -1719,8 +1920,7 @@ def _patch_or_get_study_activity_group( request_object.activity_group_uid and current_object.activity_group_uid != request_object.activity_group_uid ) or sync_latest_version: - activity_group_uid = request_object.activity_group_uid - study_activity_group = self._get_or_create_study_activity_group( + return self._get_or_create_study_activity_group( study_uid=current_object.study_uid, activity_subgroup_uid=request_object.activity_subgroup_uid, activity_group_uid=request_object.activity_group_uid, @@ -1729,30 +1929,29 @@ def _patch_or_get_study_activity_group( sync_latest_version=sync_latest_version, current_object=current_object, ) - study_activity_group_uid = study_activity_group.study_selection_uid - activity_group_name = study_activity_group.activity_group_name # When SoAGroup is changed we need to update StudyActivityGroup for other shared nodes if given StudyActivity contains StudyActivityGroup - elif is_soa_group_changed and activity_group_uid: - activity_group_selection = self._get_or_create_study_activity_group( + if is_soa_group_changed and current_object.activity_group_uid: + return self._get_or_create_study_activity_group( study_uid=current_object.study_uid, activity_subgroup_uid=current_object.activity_subgroup_uid, - activity_group_uid=activity_group_uid, + activity_group_uid=current_object.activity_group_uid, soa_group_term_uid=soa_group_term_uid, study_soa_group_uid=study_soa_group_uid, perform_group_validation=False, sync_latest_version=sync_latest_version, current_object=current_object, ) - ( - activity_group_uid, - activity_group_name, - study_activity_group_uid, - ) = ( - activity_group_selection.activity_group_uid, - None, - activity_group_selection.study_selection_uid, - ) - return activity_group_uid, activity_group_name, study_activity_group_uid + # Unchanged — build lightweight VO from current_object + return StudySelectionActivityGroupVO.from_input_values( + study_uid=current_object.study_uid, + author_id=self.author, + activity_group_uid=current_object.activity_group_uid, # type: ignore[arg-type] + activity_group_name=current_object.activity_group_name, + activity_group_version=None, + study_selection_uid=current_object.study_activity_group_uid, + order=current_object.study_activity_group_order, + show_activity_group_in_protocol_flowchart=current_object.show_activity_group_in_protocol_flowchart, + ) def _patch_or_get_study_activity_subgroup( self, @@ -1768,10 +1967,7 @@ def _patch_or_get_study_activity_subgroup( is_study_activity_group_changed: bool, study_activity_group_uid: str | None, sync_latest_version: bool = False, - ): - activity_subgroup_uid = current_object.activity_subgroup_uid - activity_subgroup_name = current_object.activity_subgroup_name - study_activity_subgroup_uid = current_object.study_activity_subgroup_uid + ) -> StudySelectionActivitySubGroupVO: soa_group_term_uid = ( request_object.soa_group_term_uid if not isinstance(request_object, StudyActivitySyncLatestVersionInput) @@ -1782,9 +1978,7 @@ def _patch_or_get_study_activity_subgroup( and current_object.activity_subgroup_uid != request_object.activity_subgroup_uid ) or sync_latest_version: - activity_subgroup_uid = request_object.activity_subgroup_uid - - study_activity_subgroup = self._get_or_create_study_activity_subgroup( + return self._get_or_create_study_activity_subgroup( study_uid=current_object.study_uid, activity_subgroup_uid=request_object.activity_subgroup_uid, activity_group_uid=request_object.activity_group_uid @@ -1794,15 +1988,13 @@ def _patch_or_get_study_activity_subgroup( sync_latest_version=sync_latest_version, current_object=current_object, ) - study_activity_subgroup_uid = study_activity_subgroup.study_selection_uid - activity_subgroup_name = study_activity_subgroup.activity_subgroup_name # When SoAGroup or StudyActivityGroup is changed we need to update StudyActivitySubGroup for other shared nodes if given StudyActivity contains StudyActivitySubGroup - elif ( + if ( is_soa_group_changed or is_study_activity_group_changed - ) and activity_subgroup_uid: - activity_subgroup_selection = self._get_or_create_study_activity_subgroup( + ) and current_object.activity_subgroup_uid: + return self._get_or_create_study_activity_subgroup( study_uid=current_object.study_uid, - activity_subgroup_uid=activity_subgroup_uid, + activity_subgroup_uid=current_object.activity_subgroup_uid, activity_group_uid=current_object.activity_group_uid, soa_group_term_uid=soa_group_term_uid, perform_subgroup_validation=False, @@ -1810,20 +2002,16 @@ def _patch_or_get_study_activity_subgroup( sync_latest_version=sync_latest_version, current_object=current_object, ) - ( - activity_subgroup_uid, - activity_subgroup_name, - study_activity_subgroup_uid, - ) = ( - activity_subgroup_selection.activity_subgroup_uid, - None, - activity_subgroup_selection.study_selection_uid, - ) - - return ( - activity_subgroup_uid, - activity_subgroup_name, - study_activity_subgroup_uid, + # Unchanged — build lightweight VO from current_object + return StudySelectionActivitySubGroupVO.from_input_values( + study_uid=current_object.study_uid, + author_id=self.author, + activity_subgroup_uid=current_object.activity_subgroup_uid, # type: ignore[arg-type] + activity_subgroup_name=current_object.activity_subgroup_name, + activity_subgroup_version=None, + study_selection_uid=current_object.study_activity_subgroup_uid, + order=current_object.study_activity_subgroup_order, + show_activity_subgroup_in_protocol_flowchart=current_object.show_activity_subgroup_in_protocol_flowchart, ) def _patch_prepare_new_value_object( @@ -1877,11 +2065,7 @@ def _patch_prepare_new_value_object( ) # update StudyActivityGroup - ( - activity_group_uid, - activity_group_name, - study_activity_group_uid, - ) = self._patch_or_get_study_activity_group( + updated_group = self._patch_or_get_study_activity_group( request_object=request_object, current_object=current_object, is_soa_group_changed=is_soa_group_changed, @@ -1889,34 +2073,44 @@ def _patch_prepare_new_value_object( ) is_study_activity_group_changed = ( - study_activity_group_uid != current_object.study_activity_group_uid + updated_group.study_selection_uid != current_object.study_activity_group_uid ) # update StudyActivitySubGroup - ( - activity_subgroup_uid, - activity_subgroup_name, - study_activity_subgroup_uid, - ) = self._patch_or_get_study_activity_subgroup( + updated_subgroup = self._patch_or_get_study_activity_subgroup( request_object=request_object, current_object=current_object, is_soa_group_changed=is_soa_group_changed, is_study_activity_group_changed=is_study_activity_group_changed, - study_activity_group_uid=study_activity_group_uid, + study_activity_group_uid=updated_group.study_selection_uid, ) updated_study_activity_vo = StudySelectionActivityVO.from_input_values( study_uid=current_object.study_uid, - activity_uid=activity_ar.uid, - activity_version=activity_ar.item_metadata.version, - activity_name=activity_ar.name, + activity_uid=( + activity_ar.uid if activity_ar else current_object.activity_uid + ), + activity_version=( + activity_ar.item_metadata.version + if activity_ar + else current_object.activity_version # type: ignore[arg-type] + ), + activity_name=( + activity_ar.name if activity_ar else current_object.activity_name + ), soa_group_term_uid=updated_soa_selection.soa_group_term_uid, study_soa_group_uid=updated_soa_selection.study_selection_uid, + study_soa_group_order=updated_soa_selection.order, study_selection_uid=current_object.study_selection_uid, - study_activity_subgroup_uid=study_activity_subgroup_uid, - activity_subgroup_uid=activity_subgroup_uid, - activity_subgroup_name=activity_subgroup_name, - study_activity_group_uid=study_activity_group_uid, - activity_group_uid=activity_group_uid, - activity_group_name=activity_group_name, + study_activity_subgroup_uid=updated_subgroup.study_selection_uid, + study_activity_subgroup_order=updated_subgroup.order, + activity_subgroup_uid=updated_subgroup.activity_subgroup_uid, + activity_subgroup_name=updated_subgroup.activity_subgroup_name, + study_activity_group_uid=updated_group.study_selection_uid, + study_activity_group_order=updated_group.order, + activity_group_uid=updated_group.activity_group_uid, + activity_group_name=updated_group.activity_group_name, + show_soa_group_in_protocol_flowchart=updated_soa_selection.show_soa_group_in_protocol_flowchart, + show_activity_group_in_protocol_flowchart=updated_group.show_activity_group_in_protocol_flowchart, + show_activity_subgroup_in_protocol_flowchart=updated_subgroup.show_activity_subgroup_in_protocol_flowchart, show_activity_in_protocol_flowchart=request_object.show_activity_in_protocol_flowchart, keep_old_version=request_object.keep_old_version, keep_old_version_date=keep_old_version_date, @@ -1933,46 +2127,87 @@ def handle_batch_operations( study_uid: str, operations: list[StudySelectionActivityBatchInput], ) -> list[StudySelectionActivityBatchOutput]: + """Handle a batch of study activity selection operations with proper cache management. + + This method processes POST, PATCH, and DELETE operations in sequence while + maintaining cache consistency. Each operation may invalidate caches that + affect subsequent operations in the batch. + + Cache Lifecycle: + 1. Initialize all caches at batch start + 2. Each operation may invalidate related cache entries + 3. Cache state is preserved between operations for performance + 4. All caches are cleared in finally block + + Args: + study_uid: Study identifier for all operations + operations: List of batch operations to execute + + Returns: + List of batch operation results with status codes and content + """ results = [] - for operation in operations: - item = None - try: - if operation.method == "PATCH": - item = self.patch_selection( - study_uid, - operation.content.study_activity_uid, - operation.content.content, - ) - response_code = status.HTTP_200_OK - elif operation.method == "DELETE": - self.delete_selection( - study_uid, operation.content.study_activity_uid - ) - response_code = status.HTTP_204_NO_CONTENT - elif operation.method == "POST": - if isinstance(operation.content, StudySelectionActivityCreateInput): - item = self.make_selection(study_uid, operation.content) - response_code = status.HTTP_201_CREATED + + # Initialize all batch caches and tracking + self._initialize_batch_caches() + + try: + for operation in operations: + item = None + try: + if operation.method == "PATCH": + item = self.patch_selection( + study_uid, + operation.content.study_activity_uid, + operation.content.content, + ) + response_code = status.HTTP_200_OK + + elif operation.method == "DELETE": + self.delete_selection( + study_uid, operation.content.study_activity_uid + ) + response_code = status.HTTP_204_NO_CONTENT + + elif operation.method == "POST": + if isinstance( + operation.content, StudySelectionActivityCreateInput + ): + item = self.make_selection(study_uid, operation.content) + response_code = status.HTTP_201_CREATED + else: + raise ValidationException( + msg="POST operation requires StudySelectionActivityCreateInput as request payload." + ) else: - raise ValidationException( - msg="POST operation requires StudySelectionActivityCreateInput as request payload." + raise MethodNotAllowedException(method=operation.method) + + results.append( + StudySelectionActivityBatchOutput.model_construct( + response_code=response_code, + content=item, ) - else: - raise MethodNotAllowedException(method=operation.method) - results.append( - StudySelectionActivityBatchOutput( - response_code=response_code, - content=item, ) - ) - except MDRApiBaseException as error: - results.append( - StudySelectionActivityBatchOutput.model_construct( - response_code=error.status_code, - content=BatchErrorResponse(message=str(error)), + + except MDRApiBaseException as error: + results.append( + StudySelectionActivityBatchOutput.model_construct( + response_code=error.status_code, + content=BatchErrorResponse(message=str(error)), + ) ) + raise error + + finally: + # Save any pending batch instance AR before clearing caches + if self._batch_instance_ar is not None: + self._repos.study_activity_instance_repository.save( + self._batch_instance_ar, self.author ) - raise error + + # Clear all caches and tracking state + self._clear_all_batch_caches() + return results @ensure_transaction(db) @@ -1981,42 +2216,63 @@ def handle_review_changes( study_uid: str, operations: list[StudySelectionActivityReviewBatchInput], ) -> list[StudySelectionActivityBatchOutput]: + """Handle a batch of review changes with proper cache management. + + Review operations are simpler than full batch operations but still + require cache management for consistency between operations. + """ results = [] - for operation in operations: - item = None - try: - if operation.action == StudySelectionReviewAction.ACCEPT: - item = self.update_selection_to_latest_version( - study_uid=study_uid, - study_selection_uid=operation.uid, - sync_latest_version_input=StudyActivitySyncLatestVersionInput( - activity_group_uid=operation.content.activity_group_uid, - activity_subgroup_uid=operation.content.activity_subgroup_uid, - ), - ) - response_code = status.HTTP_200_OK - elif operation.action == StudySelectionReviewAction.DECLINE: - self.patch_selection( - study_uid=study_uid, - study_selection_uid=operation.uid, - selection_update_input=operation.content, - ) - response_code = status.HTTP_204_NO_CONTENT - else: - raise MethodNotAllowedException(method=operation.action) - results.append( - StudySelectionActivityBatchOutput( - response_code=response_code, - content=item, + + # Initialize all batch caches for review operations + self._initialize_batch_caches() + + try: + for operation in operations: + item = None + try: + if operation.action == StudySelectionReviewAction.ACCEPT: + item = self.update_selection_to_latest_version( + study_uid=study_uid, + study_selection_uid=operation.uid, + sync_latest_version_input=StudyActivitySyncLatestVersionInput( + activity_group_uid=operation.content.activity_group_uid, + activity_subgroup_uid=operation.content.activity_subgroup_uid, + ), + ) + response_code = status.HTTP_200_OK + elif operation.action == StudySelectionReviewAction.DECLINE: + self.patch_selection( + study_uid=study_uid, + study_selection_uid=operation.uid, + selection_update_input=operation.content, + ) + response_code = status.HTTP_204_NO_CONTENT + else: + raise MethodNotAllowedException(method=operation.action) + results.append( + StudySelectionActivityBatchOutput( + response_code=response_code, + content=item, + ) ) - ) - except MDRApiBaseException as error: - results.append( - StudySelectionActivityBatchOutput.model_construct( - response_code=error.status_code, - content=BatchErrorResponse(message=str(error)), + except MDRApiBaseException as error: + results.append( + StudySelectionActivityBatchOutput.model_construct( + response_code=error.status_code, + content=BatchErrorResponse(message=str(error)), + ) ) + raise error + finally: + # Save any pending batch instance AR before clearing caches + if self._batch_instance_ar is not None: + self._repos.study_activity_instance_repository.save( + self._batch_instance_ar, self.author ) + + # Clear all caches and tracking state + self._clear_all_batch_caches() + return results @ensure_transaction(db) @@ -2025,66 +2281,93 @@ def handle_soa_edit_batch_operations( study_uid: str, operations: list[StudySoAEditBatchInput], ) -> list[StudySoAEditBatchOutput]: + """Handle a batch of SoA edit operations with proper cache management. + + SoA edit operations can modify study activities and schedules, requiring + comprehensive cache invalidation to maintain consistency. + """ study_activity_schedules_service = StudyActivityScheduleService() results = [] - for operation in operations: - item = None - try: - if ( - operation.method == "PATCH" - and operation.object == SoAItemType.STUDY_ACTIVITY.value - ): - item = self.patch_selection( - study_uid, - operation.content.study_activity_uid, - operation.content.content, - ) - response_code = status.HTTP_200_OK - elif operation.method == "POST": - if operation.object == SoAItemType.STUDY_ACTIVITY.value: - if isinstance( - operation.content, StudySelectionActivityCreateInput + + # Initialize all batch caches and tracking + self._initialize_batch_caches() + try: + for operation in operations: + item = None + try: + if ( + operation.method == "PATCH" + and operation.object == SoAItemType.STUDY_ACTIVITY.value + ): + item = self.patch_selection( + study_uid, + operation.content.study_activity_uid, + operation.content.content, + ) + response_code = status.HTTP_200_OK + elif operation.method == "POST": + if operation.object == SoAItemType.STUDY_ACTIVITY.value: + if isinstance( + operation.content, StudySelectionActivityCreateInput + ): + item = self.make_selection(study_uid, operation.content) + else: + raise ValidationException( + msg="POST operation requires StudySelectionActivityCreateInput as request payload." + ) + elif ( + operation.object + == SoAItemType.STUDY_ACTIVITY_SCHEDULE.value ): - item = self.make_selection(study_uid, operation.content) - else: - raise ValidationException( - msg="POST operation requires StudySelectionActivityCreateInput as request payload." + if isinstance( + operation.content, StudyActivityScheduleCreateInput + ): + item = study_activity_schedules_service.create( + study_uid, operation.content + ) + else: + raise ValidationException( + msg="POST operation requires StudyActivityScheduleCreateInput as request payload." + ) + + response_code = status.HTTP_201_CREATED + elif operation.method == "DELETE": + if operation.object == SoAItemType.STUDY_ACTIVITY.value: + self.delete_selection( + study_uid, operation.content.study_activity_uid ) - elif operation.object == SoAItemType.STUDY_ACTIVITY_SCHEDULE.value: - if isinstance( - operation.content, StudyActivityScheduleCreateInput + elif ( + operation.object + == SoAItemType.STUDY_ACTIVITY_SCHEDULE.value ): - item = study_activity_schedules_service.create( - study_uid, operation.content + item = study_activity_schedules_service.delete( + study_uid, operation.content.uid ) - else: - raise ValidationException( - msg="POST operation requires StudyActivityScheduleCreateInput as request payload." - ) - - response_code = status.HTTP_201_CREATED - elif operation.method == "DELETE": - if operation.object == SoAItemType.STUDY_ACTIVITY.value: - self.delete_selection( - study_uid, operation.content.study_activity_uid + response_code = status.HTTP_204_NO_CONTENT + else: + raise MethodNotAllowedException(method=operation.method) + results.append( + StudySoAEditBatchOutput( + response_code=response_code, content=item ) - elif operation.object == SoAItemType.STUDY_ACTIVITY_SCHEDULE.value: - item = study_activity_schedules_service.delete( - study_uid, operation.content.uid + ) + except MDRApiBaseException as error: + results.append( + StudySoAEditBatchOutput.model_construct( + response_code=error.status_code, + content=BatchErrorResponse(message=str(error)), ) - response_code = status.HTTP_204_NO_CONTENT - else: - raise MethodNotAllowedException(method=operation.method) - results.append( - StudySoAEditBatchOutput(response_code=response_code, content=item) - ) - except MDRApiBaseException as error: - results.append( - StudySoAEditBatchOutput.model_construct( - response_code=error.status_code, - content=BatchErrorResponse(message=str(error)), ) + finally: + # Save any pending batch instance AR before clearing caches + if self._batch_instance_ar is not None: + self._repos.study_activity_instance_repository.save( + self._batch_instance_ar, self.author ) + + # Clear all caches and tracking state + self._clear_all_batch_caches() + return results @ensure_transaction(db) @@ -2100,11 +2383,17 @@ def update_activity_request_with_sponsor_activity( assert selection_aggregate is not None # Load the current VO for updates - activity_ar = self._repos.activity_repository.find_by_uid_2( + activity_ar = self._repos.activity_repository.find_by_uid( current_vo.activity_uid ) - replaced_activity_ar = self._repos.activity_repository.find_by_uid_2( - activity_ar.concept_vo.replaced_by_activity + NotFoundException.raise_if_not(activity_ar, "Activity", current_vo.activity_uid) + replaced_activity_ar = self._repos.activity_repository.find_by_uid( + activity_ar.concept_vo.replaced_by_activity # type: ignore[arg-type] + ) + NotFoundException.raise_if_not( + replaced_activity_ar, + "Activity", + activity_ar.concept_vo.replaced_by_activity, ) updated_study_activity = self.patch_selection( study_uid=study_uid, @@ -2208,7 +2497,6 @@ def update_selection_to_latest_version( ) # update StudyActivityGroup - study_activity_group_uid = selection.study_activity_group_uid is_study_activity_group_changed: bool = False sync_activity_group_version: bool = False if sync_latest_version_input and sync_latest_version_input.activity_group_uid: @@ -2220,11 +2508,7 @@ def update_selection_to_latest_version( if latest_activity_group_name != current_selection.activity_group_name: sync_activity_group_version = True if sync_latest_version_input and sync_latest_version_input.activity_group_uid: - ( - activity_group_uid, - _, - study_activity_group_uid, - ) = self._patch_or_get_study_activity_group( + updated_group = self._patch_or_get_study_activity_group( request_object=sync_latest_version_input, current_object=selection, is_soa_group_changed=False, @@ -2232,17 +2516,17 @@ def update_selection_to_latest_version( sync_latest_version=sync_activity_group_version, ) is_study_activity_group_changed = ( - selection.study_activity_group_uid != study_activity_group_uid + selection.study_activity_group_uid != updated_group.study_selection_uid ) - if study_activity_group_uid is None: + if not updated_group.study_selection_uid: raise BusinessLogicException( msg="Study Activity Group UID cannot be None when syncing to latest version." ) selection = selection.update_activity_group( - activity_group_uid=activity_group_uid, - study_activity_group_uid=study_activity_group_uid, + activity_group_uid=updated_group.activity_group_uid, + study_activity_group_uid=updated_group.study_selection_uid, ) sync_activity_subgroup_version: bool = False @@ -2269,22 +2553,18 @@ def update_selection_to_latest_version( raise ValidationException( msg="Sync latest version input can't be None at this point" ) - ( - activity_subgroup_uid, - _, - study_activity_subgroup_uid, - ) = self._patch_or_get_study_activity_subgroup( + updated_subgroup = self._patch_or_get_study_activity_subgroup( request_object=sync_latest_version_input, current_object=selection, is_soa_group_changed=False, is_study_activity_group_changed=is_study_activity_group_changed, - study_activity_group_uid=study_activity_group_uid, + study_activity_group_uid=updated_group.study_selection_uid, sync_latest_version=sync_activity_subgroup_version or is_study_activity_group_changed, ) selection = selection.update_activity_subgroup( - activity_subgroup_uid=activity_subgroup_uid, - study_activity_subgroup_uid=study_activity_subgroup_uid, + activity_subgroup_uid=updated_subgroup.activity_subgroup_uid, + study_activity_subgroup_uid=updated_subgroup.study_selection_uid, ) self._update_aggregate( selection_aggregate=selection_ar, @@ -2297,12 +2577,22 @@ def update_selection_to_latest_version( selection_aggregate = self.repository.find_by_study( study_uid=study_uid, ) - # Fetch the new selection which was just added + # Fetch the new selection which was just updated ( new_selection, _, ) = selection_aggregate.get_specific_object_selection(study_selection_uid) + # Keep _batch_patch_ar_selections current for subsequent operations in the same batch. + # patch_selection does this in the base class; update_selection_to_latest_version + # calls _update_aggregate directly so we mirror the same update here using the + # fully-populated VO from the reload above. Uses immutable update to prevent cache coherence issues. + if self._batch_patch_ar_selections is not None: + self._batch_patch_ar_selections = [ + new_selection if vo.study_selection_uid == study_selection_uid else vo + for vo in self._batch_patch_ar_selections + ] + return self._transform_from_vo_to_response_model( study_uid=study_uid, specific_selection=new_selection, diff --git a/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_selection_base.py b/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_selection_base.py index 6c5c8ddd..e5e26f7a 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_selection_base.py +++ b/clinical-mdr-api/clinical_mdr_api/services/studies/study_activity_selection_base.py @@ -1,8 +1,10 @@ import abc +import threading from collections import defaultdict from datetime import datetime from typing import Any, Callable, Generic, Iterable, TypeVar +from cachetools import TTLCache from neomodel import db from clinical_mdr_api.domain_repositories.study_selections.study_activity_base_repository import ( @@ -29,6 +31,7 @@ from clinical_mdr_api.services.studies.study_selection_base import StudySelectionMixin from common import exceptions from common.auth.user import user +from common.config import settings from common.telemetry import trace_calls _AggregateRootType = TypeVar("_AggregateRootType", bound=StudySelectionBaseAR) @@ -45,9 +48,34 @@ class StudyActivitySelectionBaseService( _vo_to_ar_filter_map: dict[Any, Any] = {} + # Shared class-level cache for study standards effective dates across all service instances + # Uses config values for consistent cache behavior across the application + _shared_terms_date_cache: TTLCache = TTLCache( + maxsize=settings.cache_max_size, ttl=settings.cache_ttl + ) + # Thread lock for safe concurrent access to the shared cache + _shared_terms_date_cache_lock: threading.RLock = threading.RLock() + def __init__(self): self._repos = MetaRepository() + # Unified batch cache - single TTLCache with namespaced keys for all batch operations. + # Uses dedicated batch_cache_* settings (NOT the cross-request cache_ttl which may be 0). + # Batch caches are instance-scoped and explicitly cleared in _clear_all_batch_caches. + self._batch_cache: TTLCache | None = None + + # Single cached instance AR to avoid repeated find_by_study/save per POST + # Flushed to DB in the batch finally block (or on first PATCH/DELETE that needs DB state) + self._batch_instance_ar: Any | None = ( + None # Keep as single value - no TTL needed + ) + + # Cached selection list for _find_ar_to_patch operations + # Avoids repeated find_by_study(for_update=True) calls during batch PATCH + self._batch_patch_ar_selections: list | None = ( + None # Keep as list - single value per batch + ) + @property def author(self): return user().id() @@ -69,6 +97,136 @@ def _get_selected_object_exist_check( ) -> Callable[[str], bool]: return self.selected_object_repository.final_concept_exists + def _initialize_batch_caches(self) -> None: + """Initialize all batch-related caches and tracking sets for a new batch operation. + + Cache Lifecycle Management: + - Called at the start of handle_batch_operations + - Caches persist throughout the entire batch for performance + - Individual operations may invalidate specific cache entries + - All caches are cleared in the finally block + """ + + self._batch_cache = TTLCache( + maxsize=settings.cache_max_size, ttl=settings.cache_ttl + ) + self._batch_patch_ar_selections = [] # Keep as list - single value per batch + + def _clear_all_batch_caches(self) -> None: + """Clear all batch-related caches and reset tracking state. + + This method ensures a clean state after batch processing and prevents + memory leaks from retained cache references. + """ + if self._batch_cache is not None: + try: + # Explicitly clear all entries to trigger internal cleanup + self._batch_cache.clear() + # Force garbage collection of expired entries + # TTLCache may have internal timers that need explicit cleanup + if hasattr(self._batch_cache, "expire"): + self._batch_cache.expire() + except Exception as e: # pylint: disable=broad-exception-caught + # Log but don't fail - cleanup is best-effort + import logging + + logging.warning("Error during batch cache cleanup: %s", e) + finally: + self._batch_cache = None + + self._batch_instance_ar = None + self._batch_patch_ar_selections = None + + # Unified Cache Access Helpers + def _get_batch_vo_cache_key(self, *args) -> tuple[str, ...]: + """Generate namespaced key for VO cache entries.""" + return ("vo", *args) + + def _get_batch_ar_cache_key(self, *args) -> tuple[str, ...]: + """Generate namespaced key for AR cache entries.""" + return ("ar", *args) + + def _get_batch_reordered_key(self, *args) -> tuple[str, ...]: + """Generate namespaced key for reordered parent tracking.""" + return ("reordered", *args) + + def _append_to_patch_ar_selections_cache(self, new_vo: _VOType) -> None: + """Append a newly created VO to the patch AR selections cache. + + Called after POST so subsequent PATCHes see the new entity without + a DB reload. Uses immutable update to prevent cache coherence issues. + """ + if self._batch_patch_ar_selections is None: + return + self._batch_patch_ar_selections = [*self._batch_patch_ar_selections, new_vo] + + def _remove_from_patch_ar_selections_cache(self, study_selection_uid: str) -> None: + """Remove a deleted VO from the patch AR selections cache. + + Called after DELETE so subsequent PATCHes no longer see the removed entity + without a DB reload. + """ + if self._batch_patch_ar_selections is None: + return + self._batch_patch_ar_selections = [ + vo + for vo in self._batch_patch_ar_selections + if vo.study_selection_uid != study_selection_uid + ] + + def _clear_batch_caches(self) -> None: + """Clear unified batch cache for data consistency.""" + if self._batch_cache is not None: + self._batch_cache.clear() + + @classmethod + def clear_study_standards_cache_for_study(cls, study_uid: str) -> None: + """Clear cached study standards effective dates for a specific study. + + Call this when study standards are modified (create/edit/delete operations) + to ensure subsequent queries see updated effective dates. + + Args: + study_uid: The study whose standards cache entries should be cleared + """ + with cls._shared_terms_date_cache_lock: + if not cls._shared_terms_date_cache: + return + + # Remove all cache entries for this study (across all versions) + keys_to_remove = [ + key + for key in cls._shared_terms_date_cache.keys() + if key[0] + == study_uid # Cache key format: (study_uid, study_value_version) + ] + for key in keys_to_remove: + del cls._shared_terms_date_cache[key] + + def _evict_ar_cache_for_scope( + self, + study_activity_subgroup_uid: str | None, + study_soa_group_uid: str, + activity_library_name: str, + ) -> None: + """Evict the specific scope's AR cache entry after DELETE. + + Only the deleted entity's scope becomes stale. Other scopes remain valid. + Also clears reordered-parent tracking since the hierarchy may need re-compaction. + """ + if self._batch_cache is None: + return + find_requested = activity_library_name == settings.requested_library_name + scope_key = self._get_batch_ar_cache_key( + study_activity_subgroup_uid, study_soa_group_uid, find_requested + ) + self._batch_cache.pop(scope_key, None) + + # Parent hierarchy may need re-compaction after deletion + reordered_keys = [k for k in self._batch_cache.keys() if k[0] == "reordered"] + for key in reordered_keys: + del self._batch_cache[key] + @abc.abstractmethod def _transform_all_to_response_model( self, @@ -327,12 +485,49 @@ def get_specific_selection( @trace_calls(args=[1, 2], kwargs=["study_uid", "study_selection_uid"]) def _find_ar_to_patch( - self, study_uid: str, study_selection_uid: str + self, study_uid: str, study_selection_uid: str, for_update: bool = True ) -> tuple[_AggregateRootType, _VOType]: - # Load aggregate - selection_aggregate = self.repository.find_by_study( - study_uid=study_uid, for_update=True - ) + """Find aggregate root and value object for patching operations. + + In batch mode, this method uses cached selection lists to avoid repeated + database queries. The cache is automatically invalidated when POST/DELETE + operations modify the entity set that PATCH operations need to see. + + Args: + study_uid: Study identifier + study_selection_uid: Selection identifier to patch + for_update: Whether to acquire update locks + + Returns: + Tuple of (selection_aggregate, current_vo) for the patch operation + """ + # In batch mode, use cached selections if available and not invalidated. + # Three states for _batch_patch_ar_selections: + # non-empty list → cache populated, use it + # [] → batch initialised but not yet seeded → load from DB and store + # None → non-batch mode or cache was invalidated → load from DB without storing + if for_update and self._batch_patch_ar_selections: + # Build AR from cached selections — avoids DB round-trip + selections = self._batch_patch_ar_selections + selection_aggregate = ( + self.repository._aggregate_root_type.from_repository_values( + study_uid=study_uid, study_objects_selection=selections + ) + ) + selection_aggregate.repository_closure_data = selections + elif for_update and self._batch_patch_ar_selections is not None: + # Empty list [] — batch mode started, seed the cache from DB + selection_aggregate = self.repository.find_by_study( + study_uid=study_uid, for_update=for_update + ) + self._batch_patch_ar_selections = list( + selection_aggregate.study_objects_selection + ) + else: + # None — non-batch mode or cache was invalidated — load from database + selection_aggregate = self.repository.find_by_study( + study_uid=study_uid, for_update=for_update + ) assert selection_aggregate is not None @@ -341,17 +536,17 @@ def _find_ar_to_patch( study_selection_uid=study_selection_uid ) selection_aggregate = self._filter_ars_from_same_parent( - selection_aggregate=selection_aggregate, selection_vo=current_vo + selection_aggregate=selection_aggregate, selection_vo=current_vo # type: ignore[arg-type] ) return selection_aggregate, current_vo def _update_aggregate( self, selection_aggregate: _AggregateRootType, - # pylint: disable=unused-argument - previous_selection: _VOType, updated_selection: _VOType, - ): + # pylint: disable=unused-argument + previous_selection: _VOType | None = None, + ) -> _VOType: # let the aggregate update the value object selection_aggregate.update_selection( updated_study_object_selection=updated_selection, @@ -362,6 +557,11 @@ def _update_aggregate( # sync with DB and save the update self.repository.save(selection_aggregate, self.author) + # After save(), the repository writeback has updated the in-memory VO with the correct order. + updated_vo, _ = selection_aggregate.get_specific_object_selection( + updated_selection.study_selection_uid + ) + return updated_vo @ensure_transaction(db) def patch_selection( @@ -383,10 +583,10 @@ def patch_selection( current_object=current_vo, ) - self._update_aggregate( + updated_selection = self._update_aggregate( selection_aggregate=selection_aggregate, - previous_selection=current_vo, updated_selection=updated_selection, + previous_selection=current_vo, ) # # sync related nodes @@ -394,16 +594,26 @@ def patch_selection( study_selection=updated_selection, previous_study_selection=current_vo ) - selection_aggregate, updated_selection = self._find_ar_to_patch( - study_uid=study_uid, study_selection_uid=study_selection_uid - ) + # Keep the batch AR-selection cache current after each PATCH save. + # This ensures subsequent PATCH operations in the same batch see the updated state. + # Uses immutable update to prevent cache coherence issues. + if self._batch_patch_ar_selections is not None: + self._batch_patch_ar_selections = [ + ( + updated_selection + if vo.study_selection_uid == study_selection_uid + else vo + ) + for vo in self._batch_patch_ar_selections + ] + terms_at_specific_datetime = self._extract_study_standards_effective_date( study_uid=study_uid ) # add the activity and return return self._transform_from_vo_to_response_model( - study_uid=selection_aggregate.study_uid, + study_uid=study_uid, specific_selection=updated_selection, terms_at_specific_datetime=terms_at_specific_datetime, ) @@ -494,19 +704,41 @@ def _get_linked_activities( selection_vos: Iterable[StudySelectionBaseVO], filter_out_retired_groupings: bool = False, ) -> list[ActivityAR]: - version_specific_uids = defaultdict(set) + version_specific_uids: dict[str, set[str]] = defaultdict(set) + latest_uids: dict[str, set[str]] = defaultdict(set) for selection_vo in selection_vos: version_specific_uids[selection_vo.activity_uid].add( selection_vo.activity_version ) - version_specific_uids[selection_vo.activity_uid].add("LATEST") + latest_uids[selection_vo.activity_uid].add("LATEST") if not version_specific_uids: return [] - return self._repos.activity_repository.get_all_optimized( - version_specific_uids=version_specific_uids, - include_retired_versions=True, - filter_out_retired_groupings=filter_out_retired_groupings, - )[0] + if not filter_out_retired_groupings: + for uid, versions in latest_uids.items(): + version_specific_uids[uid].update(versions) + return self._repos.activity_repository.get_all_optimized( + version_specific_uids=version_specific_uids, + include_retired_versions=True, + )[0] + + # When filtering retired groupings, fetch pinned versions unfiltered (so the study's + # pinned `activity` always shows all its original groupings) and fetch the LATEST + # versions with the filter applied (so `latest_activity` only shows active groupings). + # Combining both allows _find_versions to resolve each field independently. + pinned_results: list[ActivityAR] = ( + self._repos.activity_repository.get_all_optimized( + version_specific_uids=version_specific_uids, + include_retired_versions=True, + )[0] + ) + latest_results: list[ActivityAR] = ( + self._repos.activity_repository.get_all_optimized( + version_specific_uids=latest_uids, + include_retired_versions=True, + filter_out_retired_groupings=True, + )[0] + ) + return pinned_results + latest_results diff --git a/clinical-mdr-api/clinical_mdr_api/services/studies/study_disease_milestone.py b/clinical-mdr-api/clinical_mdr_api/services/studies/study_disease_milestone.py index 8a30764f..2a3644db 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/studies/study_disease_milestone.py +++ b/clinical-mdr-api/clinical_mdr_api/services/studies/study_disease_milestone.py @@ -3,6 +3,9 @@ from neomodel import db +from clinical_mdr_api.domain_repositories._utils.helpers import ( + acquire_write_lock_study_value, +) from clinical_mdr_api.domains.study_definition_aggregates.study_metadata import ( StudyStatus, ) @@ -262,6 +265,7 @@ def create( study_uid: str, study_disease_milestone_input: StudyDiseaseMilestoneCreateInput, ): + acquire_write_lock_study_value(uid=study_uid) all_disease_milestones = self.repo.find_all_disease_milestones_by_study( study_uid ) @@ -295,9 +299,11 @@ def create( @db.transaction def edit( self, + study_uid: str, study_disease_milestone_uid: str, study_disease_milestone_input: StudyDiseaseMilestoneEditInput, ): + acquire_write_lock_study_value(uid=study_uid) study_disease_milestone = self.repo.find_by_uid(study_disease_milestone_uid) self._validate_update(study_disease_milestone_input, study_disease_milestone) fill_missing_values_in_base_model_from_reference_base_model( @@ -316,7 +322,8 @@ def edit( return self._transform_all_to_response_model(updated_item) @db.transaction - def reorder(self, study_disease_milestone_uid: str, new_order: int): + def reorder(self, study_uid: str, study_disease_milestone_uid: str, new_order: int): + acquire_write_lock_study_value(uid=study_uid) new_order -= 1 disease_milestone = self.repo.find_by_uid(study_disease_milestone_uid) study_disease_milestones = self.repo.find_all_disease_milestones_by_study( @@ -353,7 +360,8 @@ def reorder(self, study_disease_milestone_uid: str, new_order: int): return self._transform_all_to_response_model(disease_milestone) @db.transaction - def delete(self, study_disease_milestone_uid: str): + def delete(self, study_uid: str, study_disease_milestone_uid: str): + acquire_write_lock_study_value(uid=study_uid) study_disease_milestone = self.repo.find_by_uid(study_disease_milestone_uid) self.repo.save(study_disease_milestone, delete_flag=True) diff --git a/clinical-mdr-api/clinical_mdr_api/services/studies/study_selection_base.py b/clinical-mdr-api/clinical_mdr_api/services/studies/study_selection_base.py index 21c5aca5..676cc5b3 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/studies/study_selection_base.py +++ b/clinical-mdr-api/clinical_mdr_api/services/studies/study_selection_base.py @@ -783,6 +783,17 @@ def _get_specific_activity_instance_selection_by_uids( def _extract_study_standards_effective_date( self, study_uid, study_value_version: str | None = None ) -> datetime | None: + cache_key = (study_uid, study_value_version) + # Use shared class-level cache for study standards effective dates + from clinical_mdr_api.services.studies.study_activity_selection_base import ( + StudyActivitySelectionBaseService, + ) + + cache = StudyActivitySelectionBaseService._shared_terms_date_cache + with StudyActivitySelectionBaseService._shared_terms_date_cache_lock: + if cache_key in cache: + return cache[cache_key] + repos = self._repos study_standard_versions = ( repos.study_standard_version_repository.find_standard_versions_in_study( @@ -811,6 +822,9 @@ def _extract_study_standards_effective_date( 59, 999999, ) + # Save to shared class-level cache + with StudyActivitySelectionBaseService._shared_terms_date_cache_lock: + cache[cache_key] = terms_at_specific_datetime return terms_at_specific_datetime @trace_calls diff --git a/clinical-mdr-api/clinical_mdr_api/services/studies/study_standard_version_selection.py b/clinical-mdr-api/clinical_mdr_api/services/studies/study_standard_version_selection.py index ddeafab9..f4037d6b 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/studies/study_standard_version_selection.py +++ b/clinical-mdr-api/clinical_mdr_api/services/studies/study_standard_version_selection.py @@ -24,6 +24,9 @@ calculate_diffs_history, fill_missing_values_in_base_model_from_reference_base_model, ) +from clinical_mdr_api.services.studies.study_activity_selection_base import ( + StudyActivitySelectionBaseService, +) from common import exceptions from common.auth.user import user @@ -188,6 +191,12 @@ def create( ) updated_item = self.repo.save(created_study_standard_version) + + # Clear study standards caches across all study activity services + StudyActivitySelectionBaseService.clear_study_standards_cache_for_study( + study_uid + ) + return self._transform_all_to_response_model(updated_item) @db.transaction @@ -248,6 +257,11 @@ def edit( updated_item = self.repo.save(study_standard_version) + # Clear study standards caches across all study activity services + StudyActivitySelectionBaseService.clear_study_standards_cache_for_study( + study_uid + ) + return self._transform_all_to_response_model(updated_item) raise exceptions.BusinessLogicException(msg="There's nothing to change") @@ -258,6 +272,11 @@ def delete(self, study_uid: str, study_standard_version_uid: str): ) self.repo.save(study_standard_version, delete_flag=True) + # Clear study standards caches across all study activity services + StudyActivitySelectionBaseService.clear_study_standards_cache_for_study( + study_uid + ) + @db.transaction def audit_trail( self, diff --git a/clinical-mdr-api/clinical_mdr_api/services/studies/study_visit.py b/clinical-mdr-api/clinical_mdr_api/services/studies/study_visit.py index eefe11a9..87be863a 100644 --- a/clinical-mdr-api/clinical_mdr_api/services/studies/study_visit.py +++ b/clinical-mdr-api/clinical_mdr_api/services/studies/study_visit.py @@ -1044,7 +1044,7 @@ def _from_input_values( time_unit_object=time_unit_object, description=create_input.description, start_rule=( - settings.unscheduled_visit_start_rule + create_input.start_rule or settings.unscheduled_visit_start_rule if create_input.visit_class == VisitClass.UNSCHEDULED_VISIT else create_input.start_rule ), @@ -1425,6 +1425,7 @@ def edit( @ensure_transaction(db) def delete(self, study_uid: str, study_visit_uid: str): + acquire_write_lock_study_value(uid=study_uid) study_visits = self.repo.find_all_visits_by_study_uid(study_uid) timeline = TimelineAR(study_uid=study_uid, _visits=study_visits) ordered_visits = timeline.ordered_study_visits diff --git a/clinical-mdr-api/clinical_mdr_api/tests/auth/integration/routes.py b/clinical-mdr-api/clinical_mdr_api/tests/auth/integration/routes.py index 43dff0da..d1ed2476 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/auth/integration/routes.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/auth/integration/routes.py @@ -992,6 +992,7 @@ ("/ct/codelists/{codelist_uid}/names/approvals", "POST", {"Library.Write"}), ("/ct/codelists/{codelist_uid}/paired", "GET", {"Library.Read"}), ("/ct/codelists/{codelist_uid}/paired", "PATCH", {"Library.Write"}), + ("/ct/paired-codelists", "POST", {"Library.Write"}), ("/ct/paired-codelists/{codelist_uid}/terms", "GET", {"Library.Read"}), ("/ct/terms", "POST", {"Library.Write"}), ("/ct/terms", "GET", {"Library.Read"}), @@ -1143,12 +1144,12 @@ {"Library.Write"}, ), ( - "/concepts/activities/activity-instances/{activity_instance_uid}/groupings/activations", + "/concepts/activities/activity-instances/{activity_instance_uid}/activations", "POST", {"Library.Write"}, ), ( - "/concepts/activities/activity-instances/{activity_instance_uid}/groupings/activations", + "/concepts/activities/activity-instances/{activity_instance_uid}/activations", "DELETE", {"Library.Write"}, ), @@ -1182,16 +1183,6 @@ "POST", {"Library.Write"}, ), - ( - "/concepts/activities/activity-instances/{activity_instance_uid}/attributes/activations", - "POST", - {"Library.Write"}, - ), - ( - "/concepts/activities/activity-instances/{activity_instance_uid}/attributes/activations", - "DELETE", - {"Library.Write"}, - ), ("/activity-instance-classes", "GET", {"Library.Read"}), ("/activity-instance-classes/versions", "GET", {"Library.Read"}), ("/activity-instance-classes/headers", "GET", {"Library.Read"}), @@ -1743,11 +1734,21 @@ ("/studies/list", "GET", {"Study.Read"}), ("/studies/structure-overview", "GET", {"Study.Read"}), ("/studies/structure-overview/headers", "GET", {"Study.Read"}), + ("/studies/template", "GET", {"Study.Read"}), + ("/studies/template", "POST", {"Study.Write"}), + ("/studies/template", "PATCH", {"Study.Write"}), + ("/studies/template/activations", "DELETE", {"Study.Write"}), + ("/studies/template/activations", "POST", {"Study.Write"}), ("/studies/{study_uid}/locks", "POST", {"Study.Write"}), ("/studies/{study_uid}/unlocks", "POST", {"Study.Write"}), ("/studies/{study_uid}/release", "POST", {"Study.Write"}), ("/studies/{study_uid}/order", "PATCH", {"Study.Write"}), ("/studies/{study_uid}/structure-statistics", "GET", {"Study.Read"}), + ( + "/studies/{study_uid}/study-selection-containment/{target_study_uid}", + "GET", + {"Study.Read"}, + ), ("/studies/{study_uid}", "DELETE", {"Study.Write"}), ("/studies/{study_uid}", "PATCH", {"Study.Write"}), ("/studies/{study_uid}", "GET", {"Study.Read"}), @@ -2436,6 +2437,7 @@ ("/studies/{study_uid}/soa-splits", "DELETE", {"Study.Write"}), ("/studies/{study_uid}/soa-splits/{study_visit_uid}", "DELETE", {"Study.Write"}), ("/studies/{study_uid}/complexity-score", "GET", {"Study.Read"}), + ("/studies/{study_uid}/complexity-score-details", "GET", {"Study.Read"}), ("/concepts/unit-definitions", "GET", {"Library.Read"}), ("/concepts/unit-definitions/headers", "GET", {"Library.Read"}), ("/concepts/unit-definitions/{unit_definition_uid}", "GET", {"Library.Read"}), diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activities.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activities.py index 8751c2d3..b4bccfee 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activities.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activities.py @@ -459,9 +459,7 @@ def test_get_activity(api_client): response = api_client.get( f"/concepts/activities/activities/{activities_all[0].uid}" ) - res = response.json() - - assert_response_status_code(response, 200) + res = parse_json_response(response, assert_status=200) # Check fields included in the response assert set(res.keys()) == set(ACTIVITY_FIELDS_ALL) @@ -508,7 +506,7 @@ def test_get_activity_pagination(api_client): sort_by = '{"name": true}' for page_number in range(1, 4): url = f"/concepts/activities/activities?page_number={page_number}&page_size=3&sort_by={sort_by}" - res = parse_json_response(api_client.get(url)) + res = parse_json_response(api_client.get(url), assert_status=200) res_names = [item["name"] for item in res["items"]] results_paginated[page_number] = res_names log.info("Page %s: %s", page_number, res_names) @@ -524,7 +522,8 @@ def test_get_activity_pagination(api_client): res_all = parse_json_response( api_client.get( f"/concepts/activities/activities?page_number=1&page_size=100&sort_by={sort_by}" - ) + ), + assert_status=200, ) results_all_in_one_page = list(map(lambda x: x["name"], res_all["items"])) log.info("All rows in one page: %s", results_all_in_one_page) @@ -537,7 +536,8 @@ def test_get_activity_pagination(api_client): res_all = parse_json_response( api_client.get( f"/concepts/activities/activities?page_number=1&page_size=100&sort_by={sort_by}" - ) + ), + assert_status=200, ) all_results = list( map( @@ -557,7 +557,8 @@ def test_get_activity_pagination(api_client): res_all = parse_json_response( api_client.get( f"/concepts/activities/activities?page_number=1&page_size=100&sort_by={sort_by}" - ) + ), + assert_status=200, ) all_results = list( map( @@ -577,7 +578,8 @@ def test_get_activity_pagination(api_client): res_all = parse_json_response( api_client.get( f"/concepts/activities/activities?page_number=1&page_size=100&sort_by={sort_by}" - ) + ), + assert_status=200, ) all_results = list( map( @@ -597,7 +599,8 @@ def test_get_activity_pagination(api_client): res_all = parse_json_response( api_client.get( f"/concepts/activities/activities?page_number=1&page_size=100&sort_by={sort_by}" - ) + ), + assert_status=200, ) all_results = list( map( @@ -622,9 +625,7 @@ def test_get_activity_versions(api_client): # Get all versions of all activities response = api_client.get("/concepts/activities/activities/versions?page_size=100") - res = response.json() - - assert_response_status_code(response, 200) + res = parse_json_response(response, assert_status=200) # Check fields included in the response assert set(res.keys()) == set(["items", "total", "page", "size"]) @@ -655,9 +656,8 @@ def test_filtering_versions_wildcard( ): url = f"/concepts/activities/activities/versions?filters={filter_by}" response = api_client.get(url) - res = response.json() + res = parse_json_response(response, assert_status=200) - assert_response_status_code(response, 200) if expected_result_prefix: assert len(res["items"]) > 0 nested_path = None @@ -708,9 +708,8 @@ def test_filtering_versions_exact( ): url = f"/concepts/activities/activities/versions?filters={filter_by}" response = api_client.get(url) - res = response.json() + res = parse_json_response(response, assert_status=200) - assert_response_status_code(response, 200) if expected_result: assert len(res["items"]) > 0 @@ -748,8 +747,7 @@ def test_explicit_filtering_by_activity_subgroup_and_group_uid(api_client): "activity_group_uid": activity_group.uid, }, ) - assert_response_status_code(response, 200) - res = response.json()["items"] + res = parse_json_response(response, assert_status=200)["items"] assert len(res) == 0 response = api_client.get( @@ -759,8 +757,7 @@ def test_explicit_filtering_by_activity_subgroup_and_group_uid(api_client): "activity_group_uid": different_activity_group.uid, }, ) - assert_response_status_code(response, 200) - res = response.json()["items"] + res = parse_json_response(response, assert_status=200)["items"] assert len(res) == 6 assert res[0]["uid"] == activity_with_multiple_groupings.uid @@ -785,8 +782,7 @@ def test_explicit_filtering_by_activity_subgroup_and_group_uid(api_client): "activity_group_uid": different_activity_group.uid, }, ) - assert_response_status_code(response, 200) - res = response.json()["items"] + res = parse_json_response(response, assert_status=200)["items"] assert len(res) == 0 @@ -803,24 +799,24 @@ def test_grouped_groupings_payload_flag(api_client): "page_size": 0, }, ) - assert_response_status_code(response, 200) - res = response.json()["items"] + items = parse_json_response(response, assert_status=200)["items"] + assert any( - len(activity["activity_groupings"]) > 1 for activity in res + len(activity["activity_groupings"]) > 1 for activity in items ), "Test precondition failed: At least one activity should have multiple groupings" assert any( - len(activity["activity_instances"]) > 1 for activity in res + len(activity["activity_instances"]) > 1 for activity in items ), "Test precondition failed: At least one activity should have multiple instances" # Check that the activity with multiple groupings is present and has two instances linked to it assert any( - activity["uid"] == activity_with_multiple_groupings.uid for activity in res + activity["uid"] == activity_with_multiple_groupings.uid for activity in items ), "Test precondition failed: The activity with multiple groupings is not present in the response" # Collect groupings and number of instances for later comparison grouped_groupings = set() nbr_instances_grouped = 0 - for activity in res: + for activity in items: if activity["uid"] == activity_with_multiple_groupings.uid: assert ( len(activity["activity_instances"]) == 2 @@ -847,12 +843,11 @@ def test_grouped_groupings_payload_flag(api_client): "page_size": 0, }, ) - assert_response_status_code(response, 200) - res = response.json()["items"] + items = parse_json_response(response, assert_status=200)["items"] ungrouped_groupings = set() nbr_instances_ungrouped = 0 - for activity in res: + for activity in items: nbr_instances_ungrouped += len(activity["activity_instances"]) if activity["uid"] == activity_with_multiple_groupings.uid: assert ( @@ -927,11 +922,8 @@ def test_create_activity_unique_name_validation(api_client): "library_name": archived_library["name"], }, ) - assert_response_status_code(response, 409) - assert ( - response.json()["message"] - == f"Activity with Name '{activity_name}' already exists." - ) + res = parse_json_response(response, assert_status=409) + assert res["message"] == f"Activity with Name '{activity_name}' already exists." # Create activity with the same name as the first one but in different Library response = api_client.post( @@ -948,8 +940,7 @@ def test_create_activity_unique_name_validation(api_client): "library_name": "Sponsor", }, ) - assert_response_status_code(response, 201) - res = response.json() + res = parse_json_response(response, assert_status=201) assert res["name"] == activity_name assert res["library_name"] == "Sponsor" @@ -968,11 +959,8 @@ def test_create_activity_unique_name_validation(api_client): "library_name": archived_library["name"], }, ) - assert_response_status_code(response, 409) - assert ( - response.json()["message"] - == f"Activity with Name '{activity_name2}' already exists." - ) + res = parse_json_response(response, assert_status=409) + assert res["message"] == f"Activity with Name '{activity_name2}' already exists." def test_update_activity_to_new_grouping(api_client): @@ -1022,8 +1010,7 @@ def test_update_activity_to_new_grouping(api_client): f"/concepts/activities/activity-sub-groups/{subgroup.uid}" ) - assert_response_status_code(response, 200) - res = response.json() + res = parse_json_response(response, assert_status=200) assert res["name"] == edited_subgroup_name @@ -1062,8 +1049,7 @@ def test_update_activity_to_new_grouping(api_client): # Get the activity by uid and assert that it was updated to the new subgroup version response = api_client.get(f"/concepts/activities/activities/{activity.uid}") - assert_response_status_code(response, 200) - res = response.json() + res = parse_json_response(response, assert_status=200) assert res["version"] == "2.0" assert res["status"] == "Final" @@ -1103,8 +1089,7 @@ def test_update_activity(api_client): "change_description": "Updated synonyms and groupings", }, ) - assert_response_status_code(response, 200) - res = response.json() + res = parse_json_response(response, assert_status=200) assert res["uid"] == activity.uid assert res["name"] == "name-CCC" @@ -1157,7 +1142,7 @@ def test_cannot_create_activity_with_non_unique_synonyms(api_client): }, ) assert_response_status_code(response, 409) - res = response.json() + res = parse_json_response(response, assert_status=409) assert res["type"] == "AlreadyExistsException" assert ( @@ -1191,7 +1176,7 @@ def test_cannot_update_activity_with_non_unique_synonyms(api_client): }, ) assert_response_status_code(response, 409) - res = response.json() + res = parse_json_response(response, assert_status=409) assert res["type"] == "AlreadyExistsException" assert ( @@ -1259,8 +1244,7 @@ def test_cascade_edit_activities(api_client): f"/concepts/activities/activity-instances/{activity_instance.uid}" ) - res = response.json() - assert_response_status_code(response, 200) + res = parse_json_response(response, assert_status=200) assert res["name"] == "Cascade Activity Instance" assert len(res["activity_groupings"]) == 1 assert res["activity_groupings"][0]["activity"]["uid"] == activity.uid @@ -1326,8 +1310,7 @@ def test_cascade_edit_activities(api_client): response = api_client.get( f"/concepts/activities/activity-instances/{activity_instance.uid}" ) - assert_response_status_code(response, 200) - res = response.json() + res = parse_json_response(response, assert_status=200) assert len(res["activity_groupings"]) == 1 # Update the activity by adding new activity groupings @@ -1373,8 +1356,7 @@ def test_cascade_edit_activities(api_client): response = api_client.get( f"/concepts/activities/activity-instances/{activity_instance.uid}" ) - assert_response_status_code(response, 200) - res = response.json() + res = parse_json_response(response, assert_status=200) assert len(res["activity_groupings"]) == 1 assert res["groupings_version"] == "3.0" assert res["groupings_status"] == "Final" @@ -1414,8 +1396,7 @@ def test_cascade_edit_activities(api_client): response = api_client.get( f"/concepts/activities/activity-instances/{activity_instance.uid}" ) - assert_response_status_code(response, 200) - res = response.json() + res = parse_json_response(response, assert_status=200) assert len(res["activity_groupings"]) == 1 assert res["groupings_version"] == "3.0" assert res["groupings_status"] == "Final" @@ -1425,8 +1406,7 @@ def test_cascade_edit_activities(api_client): response = api_client.get( f"/concepts/activities/activity-instances/{activity_instance.uid}/groupings/versions" ) - assert_response_status_code(response, 200) - res = response.json() + res = parse_json_response(response, assert_status=200) unchanged_draft = TestUtils._get_version_from_list(res, "1.1") updated_draft = TestUtils._get_version_from_list(res, "1.2") new_final = TestUtils._get_version_from_list(res, "2.0") @@ -1475,8 +1455,7 @@ def test_cascade_edit_activities(api_client): response = api_client.get( f"/concepts/activities/activity-instances/{activity_instance.uid}" ) - assert_response_status_code(response, 200) - res = response.json() + res = parse_json_response(response, assert_status=200) assert res["groupings_version"] == "3.0" assert res["groupings_status"] == "Final" @@ -1798,8 +1777,7 @@ def test_create_activity_without_groupings_not_allowed(api_client): "activity_groupings": [], }, ) - assert_response_status_code(response, 400) - res = response.json() + res = parse_json_response(response, assert_status=400) assert res["type"] == "BusinessLogicException" assert res["message"] == "Sponsor activities must have at least one grouping." diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activity_group_status_validation.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activity_group_status_validation.py index ac0d7f60..c02f4815 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activity_group_status_validation.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activity_group_status_validation.py @@ -4,6 +4,7 @@ from fastapi.testclient import TestClient from clinical_mdr_api.main import app +from clinical_mdr_api.models.concepts.activities.activity import ActivityEditInput from clinical_mdr_api.services.concepts.activities.activity_group_service import ( ActivityGroupService, ) @@ -23,6 +24,7 @@ # pylint: disable=unused-argument # pylint: disable=redefined-outer-name # pylint: disable=too-many-arguments +# pylint: disable=unsubscriptable-object # pytest fixture functions have other fixture functions as arguments, # which pylint interprets as unused arguments @@ -185,10 +187,6 @@ def test_edit_activity_with_retired_activity_group_fails(): activity_service = ActivityService() with pytest.raises(BusinessLogicException) as exc_info: - from clinical_mdr_api.models.concepts.activities.activity import ( - ActivityEditInput, - ) - edit_input = ActivityEditInput( name=activity.name, name_sentence_case=activity.name_sentence_case, @@ -213,6 +211,87 @@ def test_edit_activity_with_retired_activity_group_fails(): ) +def test_edit_activity_to_remove_retired_subgroup_grouping_succeeds(): + """Test that editing an activity to remove a grouping with a retired sub-group + succeeds when the remaining groupings are carried over from the previous version. + + This reproduces the real-world circular dependency reported in the bug: + 1. Activity group and two sub-groups are Final, activity is Final + 2. One sub-group is retired + 3. A new version of the group is created and approved with cascade, + which cascade-edits the activity to a new Final version + 4. Another new version of the group is created (now Draft again) + 5. User creates a draft of the activity and tries to remove the retired + sub-group grouping, but the carried-over grouping referencing the + Draft group blocks the edit + """ + activity_group_service = ActivityGroupService() + activity_service = ActivityService() + + # Create and approve an activity group and two sub-groups + activity_group = TestUtils.create_activity_group( + name=TestUtils.random_str(20, "ActivityGroup-"), + approve=True, + ) + subgroup_to_keep = TestUtils.create_activity_subgroup( + name=TestUtils.random_str(20, "SubgroupKeep-"), + approve=True, + ) + subgroup_to_retire = TestUtils.create_activity_subgroup( + name=TestUtils.random_str(20, "SubgroupRetire-"), + approve=True, + ) + + # Create activity with two groupings, then approve it (Final v1.0) + activity = TestUtils.create_activity( + name=TestUtils.random_str(20, "Activity-"), + activity_subgroups=[subgroup_to_keep.uid, subgroup_to_retire.uid], + activity_groups=[activity_group.uid, activity_group.uid], + approve=True, + ) + + # Retire one sub-group + ActivitySubGroupService().inactivate_final(uid=subgroup_to_retire.uid) + + # Create new version of the group (Draft v2.0) and approve it with cascade. + # The cascade automatically creates a new version of the activity and approves it. + activity_group_service.create_new_version(uid=activity_group.uid) + activity_group_service.approve( + uid=activity_group.uid, cascade_edit_and_approve=True + ) + + # Create yet another new version of the group (now Draft v3.0). + # The activity's carried-over grouping now references a Draft group. + activity_group_service.create_new_version(uid=activity_group.uid) + + # Create new draft version of the activity (so we can edit it) + activity_service.create_new_version(uid=activity.uid) + + # Edit the activity to remove the retired sub-group grouping, + # keeping only the (draft-group, final-subgroup) pair. + # This should succeed because we're only carrying over an existing grouping, + # not adding a new one. + edit_input = ActivityEditInput( + name=activity.name, + name_sentence_case=activity.name_sentence_case, + change_description="Remove retired sub-group grouping", + activity_groupings=[ + { + "activity_group_uid": activity_group.uid, + "activity_subgroup_uid": subgroup_to_keep.uid, + } + ], + ) + result = activity_service.edit_draft( + uid=activity.uid, concept_edit_input=edit_input, patch_mode=False + ) + + assert result is not None + assert len(result.activity_groupings) == 1 + assert result.activity_groupings[0].activity_group_uid == activity_group.uid + assert result.activity_groupings[0].activity_subgroup_uid == subgroup_to_keep.uid + + def test_create_activity_with_final_groups_succeeds(): """Test that creating an activity with final (approved) groups succeeds.""" # Create and approve activity group and subgroup @@ -238,6 +317,5 @@ def test_create_activity_with_final_groups_succeeds(): assert activity.uid is not None assert activity.activity_groupings is not None assert len(activity.activity_groupings) == 1 - # pylint: disable=unsubscriptable-object assert activity.activity_groupings[0].activity_group_uid == activity_group.uid assert activity.activity_groupings[0].activity_subgroup_uid == activity_subgroup.uid diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activity_instances.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activity_instances.py index 2504fd99..97c0c899 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activity_instances.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/biomedical_concepts/test_activity_instances.py @@ -1184,6 +1184,11 @@ def test_get_activity_instance_groupings_versions(api_client): assert item["start_date"] is not None assert item["status"] is not None assert item["version"] is not None + # check that activity_groupings contain version info for linked entities + for grouping in item["activity_groupings"]: + assert grouping["activity"]["version"] is not None + assert grouping["activity_subgroup"]["version"] is not None + assert grouping["activity_group"]["version"] is not None # Check that the items are sorted by start_date descending sorted_items = sorted(res, key=itemgetter("start_date"), reverse=True) @@ -1233,6 +1238,7 @@ def test_edit_activity_instance_groupings(api_client): assert len(res["activity_groupings"]) == 1 assert res["activity_groupings"][0]["activity"]["uid"] == activity.uid assert res["activity_groupings"][0]["activity"]["name"] == activity.name + assert res["activity_groupings"][0]["activity"]["version"] is not None assert ( res["activity_groupings"][0]["activity_subgroup"]["uid"] == activity_subgroup.uid @@ -1241,8 +1247,10 @@ def test_edit_activity_instance_groupings(api_client): res["activity_groupings"][0]["activity_subgroup"]["name"] == activity_subgroup.name ) + assert res["activity_groupings"][0]["activity_subgroup"]["version"] is not None assert res["activity_groupings"][0]["activity_group"]["uid"] == activity_group.uid assert res["activity_groupings"][0]["activity_group"]["name"] == activity_group.name + assert res["activity_groupings"][0]["activity_group"]["version"] is not None assert res["version"] == "0.1" assert res["status"] == "Draft" @@ -1267,6 +1275,7 @@ def test_edit_activity_instance_groupings(api_client): assert len(res["activity_groupings"]) == 1 assert res["activity_groupings"][0]["activity"]["uid"] == activity2.uid assert res["activity_groupings"][0]["activity"]["name"] == activity2.name + assert res["activity_groupings"][0]["activity"]["version"] is not None assert ( res["activity_groupings"][0]["activity_subgroup"]["uid"] == activity_subgroup.uid @@ -1275,8 +1284,10 @@ def test_edit_activity_instance_groupings(api_client): res["activity_groupings"][0]["activity_subgroup"]["name"] == activity_subgroup.name ) + assert res["activity_groupings"][0]["activity_subgroup"]["version"] is not None assert res["activity_groupings"][0]["activity_group"]["uid"] == activity_group.uid assert res["activity_groupings"][0]["activity_group"]["name"] == activity_group.name + assert res["activity_groupings"][0]["activity_group"]["version"] is not None assert res["version"] == "0.2" assert res["status"] == "Draft" @@ -1292,6 +1303,7 @@ def test_edit_activity_instance_groupings(api_client): assert res["version"] == "0.1" assert len(res["activity_groupings"]) == 1 assert res["activity_groupings"][0]["activity"]["uid"] == activity.uid + assert res["activity_groupings"][0]["activity"]["version"] is not None # Get version 0.2 response = api_client.get( @@ -1783,18 +1795,27 @@ def test_activity_instance_attributes_versioning(api_client): assert res["message"] == "The object isn't in draft status." response = api_client.post( - f"/concepts/activities/activity-instances/{activity_instance_uid}/attributes/activations" + f"/concepts/activities/activity-instances/{activity_instance_uid}/activations" ) assert_response_status_code(response, 400) res = response.json() - assert res["message"] == "Only RETIRED version can be reactivated." + assert res["message"] == ( + "Cannot reactivate: activity instance attributes are not in Retired status." + ) + + # Groupings must also be Final before the merged inactivate endpoint is allowed + response = api_client.post( + f"/concepts/activities/activity-instances/{activity_instance_uid}/groupings/approvals" + ) + assert_response_status_code(response, 201) + response = api_client.delete( - f"/concepts/activities/activity-instances/{activity_instance_uid}/attributes/activations" + f"/concepts/activities/activity-instances/{activity_instance_uid}/activations" ) assert_response_status_code(response, 200) response = api_client.post( - f"/concepts/activities/activity-instances/{activity_instance_uid}/attributes/activations" + f"/concepts/activities/activity-instances/{activity_instance_uid}/activations" ) assert_response_status_code(response, 200) diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/controlled_terminologies/test_paired_codelists.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/controlled_terminologies/test_paired_codelists.py index 212636aa..fc83959f 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/controlled_terminologies/test_paired_codelists.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/controlled_terminologies/test_paired_codelists.py @@ -29,6 +29,7 @@ catalogue: str URL = "/ct/codelists" +PAIRED_URL = "/ct/paired-codelists" @pytest.fixture(scope="module") @@ -84,10 +85,8 @@ def test_get_paired_codelist(api_client): response_json = response.json() print(response_json) assert len(response_json["items"]) == 1 - assert response_json["items"][0]["paired_names_codelist_uid"] is None - assert ( - response_json["items"][0]["paired_codes_codelist_uid"] == codes_cl.codelist_uid - ) + assert response_json["items"][0]["paired_codelist"]["uid"] == codes_cl.codelist_uid + assert response_json["items"][0]["paired_codelist"]["name"] is not None # Check the codelist with codes response = api_client.get(f"{URL}/{codes_cl.codelist_uid}/paired") @@ -108,10 +107,8 @@ def test_get_paired_codelist(api_client): response_json = response.json() print(response_json) assert len(response_json["items"]) == 1 - assert ( - response_json["items"][0]["paired_names_codelist_uid"] == names_cl.codelist_uid - ) - assert response_json["items"][0]["paired_codes_codelist_uid"] is None + assert response_json["items"][0]["paired_codelist"]["uid"] == names_cl.codelist_uid + assert response_json["items"][0]["paired_codelist"]["name"] is not None def test_post_paired_codelist(api_client): @@ -211,3 +208,104 @@ def test_unlink_paired_codelists(api_client): response_json = response.json() assert response_json["codes"] is None assert response_json["names"] is None + + +def test_create_paired_codelists(api_client): + response = api_client.post( + PAIRED_URL, + json={ + "catalogue_names": [catalogue], + "name_information": { + "name": "paired names codelist", + "submission_value": "PAIRED_NAMES_SV", + "nci_preferred_name": "Names NCI Preferred", + "definition": "Names codelist definition", + "sponsor_preferred_name": "Names Sponsor Preferred", + }, + "code_information": { + "name": "paired codes codelist", + "submission_value": "PAIRED_CODES_SV", + "nci_preferred_name": "Codes NCI Preferred", + "definition": "Codes codelist definition", + "sponsor_preferred_name": "Codes Sponsor Preferred", + }, + "extensible": True, + "is_ordinal": False, + "template_parameter": False, + "library_name": "Sponsor", + }, + ) + assert_response_status_code(response, 201) + res = response.json() + + # Response should have both names and codes codelists + assert res["names"] is not None + assert res["codes"] is not None + + names_uid = res["names"]["codelist_uid"] + codes_uid = res["codes"]["codelist_uid"] + assert names_uid != codes_uid + + # Verify names codelist attributes + names_attrs = res["names"]["attributes"] + assert names_attrs["name"] == "paired names codelist" + assert names_attrs["submission_value"] == "PAIRED_NAMES_SV" + assert names_attrs["nci_preferred_name"] == "Names NCI Preferred" + assert names_attrs["definition"] == "Names codelist definition" + names_name = res["names"]["name"] + assert names_name["name"] == "Names Sponsor Preferred" + + # Verify codes codelist attributes + codes_attrs = res["codes"]["attributes"] + assert codes_attrs["name"] == "paired codes codelist" + assert codes_attrs["submission_value"] == "PAIRED_CODES_SV" + assert codes_attrs["nci_preferred_name"] == "Codes NCI Preferred" + assert codes_attrs["definition"] == "Codes codelist definition" + codes_name = res["codes"]["name"] + assert codes_name["name"] == "Codes Sponsor Preferred" + + # Verify pairing from the names codelist side + response = api_client.get(f"{URL}/{names_uid}/paired") + assert_response_status_code(response, 200) + paired = response.json() + assert paired["codes"]["codelist_uid"] == codes_uid + assert paired["names"] is None + + # Verify pairing from the codes codelist side + response = api_client.get(f"{URL}/{codes_uid}/paired") + assert_response_status_code(response, 200) + paired = response.json() + assert paired["names"]["codelist_uid"] == names_uid + assert paired["codes"] is None + + +def test_create_paired_codelists_minimal(api_client): + """Test creating paired codelists with minimal optional fields.""" + response = api_client.post( + PAIRED_URL, + json={ + "catalogue_names": [catalogue], + "name_information": { + "name": "minimal names codelist", + "submission_value": "MIN_NAMES_SV", + "definition": "Minimal names definition", + "sponsor_preferred_name": "Min Names Sponsor", + }, + "code_information": { + "name": "minimal codes codelist", + "submission_value": "MIN_CODES_SV", + "definition": "Minimal codes definition", + "sponsor_preferred_name": "Min Codes Sponsor", + }, + "extensible": False, + "is_ordinal": False, + "template_parameter": False, + "library_name": "Sponsor", + }, + ) + assert_response_status_code(response, 201) + res = response.json() + assert res["names"] is not None + assert res["codes"] is not None + assert res["names"]["attributes"]["nci_preferred_name"] is None + assert res["codes"]["attributes"]["nci_preferred_name"] is None diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/old/test_ct_stats.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/old/test_ct_stats.py index 047442c5..05f14aac 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/old/test_ct_stats.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/old/test_ct_stats.py @@ -66,8 +66,7 @@ def test_ct_stats(api_client): "library_name": "Sponsor", "parent_codelist_uid": None, "child_codelist_uids": [], - "paired_codes_codelist_uid": None, - "paired_names_codelist_uid": None, + "paired_codelist": None, "name": { "catalogue_names": [], "codelist_uid": None, @@ -110,8 +109,7 @@ def test_ct_stats(api_client): "library_name": "CDISC", "parent_codelist_uid": None, "child_codelist_uids": [], - "paired_codes_codelist_uid": None, - "paired_names_codelist_uid": None, + "paired_codelist": None, "name": { "catalogue_names": [], "codelist_uid": None, @@ -154,8 +152,7 @@ def test_ct_stats(api_client): "library_name": "Sponsor", "parent_codelist_uid": None, "child_codelist_uids": [], - "paired_codes_codelist_uid": None, - "paired_names_codelist_uid": None, + "paired_codelist": None, "name": { "catalogue_names": [], "codelist_uid": None, diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study/test_study_soa_split.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study/test_study_soa_split.py index 37705653..76d9483a 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study/test_study_soa_split.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study/test_study_soa_split.py @@ -79,7 +79,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): # Initially no SOA splits, returns an empty list response = api_client.get(f"/studies/{test_data.study.uid}/soa-splits") - returned = parse_json_response(response, status=200) + returned = parse_json_response(response, assert_status=200) assert returned == [] # Add a uid to SoA splits @@ -87,7 +87,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", json={"uid": test_data.visits[5].uid}, ) - returned = parse_json_response(response) + returned = parse_json_response(response, assert_status=200) expected = [{"uid": test_data.visits[5].uid, "study_uid": test_data.study.uid}] assert returned == expected @@ -95,7 +95,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): response = api_client.get( f"/studies/{test_data.study.uid}/soa-splits", ) - returned = parse_json_response(response) + returned = parse_json_response(response, assert_status=200) assert returned == expected # Add another uid to SoA splits @@ -103,7 +103,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", json={"uid": test_data.visits[3].uid}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) expected = [ {"uid": test_data.visits[3].uid, "study_uid": test_data.study.uid}, {"uid": test_data.visits[5].uid, "study_uid": test_data.study.uid}, @@ -122,7 +122,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", json={"uid": test_data.visits[1].uid}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) expected = [ {"uid": test_data.visits[1].uid, "study_uid": test_data.study.uid}, {"uid": test_data.visits[3].uid, "study_uid": test_data.study.uid}, @@ -141,14 +141,14 @@ def test_study_soa_splits(api_client: TestClient, test_database): response = api_client.get( f"/studies/{test_data.study.uid}/soa-splits", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == expected # Delete an uid from SoA splits response = api_client.delete( f"/studies/{test_data.study.uid}/soa-splits/{test_data.visits[3].uid}", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) expected = [ {"uid": test_data.visits[1].uid, "study_uid": test_data.study.uid}, {"uid": test_data.visits[5].uid, "study_uid": test_data.study.uid}, @@ -159,7 +159,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): response = api_client.get( f"/studies/{test_data.study.uid}/soa-splits", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == expected # Lock the study @@ -173,14 +173,14 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", params={"study_value_version": v1_version}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v1_expected # Get SoA splits for latest version response = api_client.get( f"/studies/{test_data.study.uid}/soa-splits", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v1_expected # Try to add another uid to SoA splits to locked study - should fail @@ -206,21 +206,21 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", params={"study_value_version": v1_version}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v1_expected # Get SoA splits for latest version response = api_client.get( f"/studies/{test_data.study.uid}/soa-splits", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v1_expected # Delete an uid from SoA splits response = api_client.delete( f"/studies/{test_data.study.uid}/soa-splits/{test_data.visits[1].uid}", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) expected = [{"uid": test_data.visits[5].uid, "study_uid": test_data.study.uid}] assert returned == expected @@ -229,7 +229,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", params={"study_value_version": v1_version}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v1_expected # Lock and unlock the study again @@ -245,14 +245,14 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", params={"study_value_version": v1_version}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v1_expected # Get SoA splits for latest version response = api_client.get( f"/studies/{test_data.study.uid}/soa-splits", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == expected # Get SoA splits for v2 version @@ -260,7 +260,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", params={"study_value_version": v2_version}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v2_expected # Add another uid to SoA splits @@ -268,7 +268,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", json={"uid": test_data.visits[4].uid}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) expected = [ {"uid": test_data.visits[4].uid, "study_uid": test_data.study.uid}, {"uid": test_data.visits[5].uid, "study_uid": test_data.study.uid}, @@ -280,14 +280,14 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", params={"study_value_version": v1_version}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v1_expected # Get SoA splits for latest version response = api_client.get( f"/studies/{test_data.study.uid}/soa-splits", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == expected # Get SoA splits for v2 version @@ -295,14 +295,14 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", params={"study_value_version": v2_version}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v2_expected # Delete all SoA splits response = api_client.delete( f"/studies/{test_data.study.uid}/soa-splits/{test_data.visits[5].uid}", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) expected = [{"uid": test_data.visits[4].uid, "study_uid": test_data.study.uid}] assert returned == expected response = api_client.delete( @@ -314,7 +314,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): response = api_client.get( f"/studies/{test_data.study.uid}/soa-splits", ) - returned = parse_json_response(response) + returned = parse_json_response(response, assert_status=200) assert returned == [] # Get SoA splits for v2 version @@ -322,7 +322,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", params={"study_value_version": v2_version}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v2_expected # Get SoA splits for v1 version @@ -330,7 +330,7 @@ def test_study_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", params={"study_value_version": v1_version}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == v1_expected @@ -341,7 +341,7 @@ def test_study_locked_with_no_soa_splits(api_client: TestClient, test_database): # Initially no SOA splits response = api_client.get(f"/studies/{test_data.study.uid}/soa-splits") - returned = parse_json_response(response, status=200) + returned = parse_json_response(response, assert_status=200) assert returned == [] # Try to delete an uid from non-existing SoA splits @@ -362,7 +362,7 @@ def test_study_locked_with_no_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", json={"uid": test_data.visits[4].uid}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) expected = [{"uid": test_data.visits[4].uid, "study_uid": test_data.study.uid}] assert returned == expected @@ -371,7 +371,7 @@ def test_study_locked_with_no_soa_splits(api_client: TestClient, test_database): f"/studies/{test_data.study.uid}/soa-splits", json={"uid": test_data.visits[6].uid}, ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) expected = [ {"uid": test_data.visits[4].uid, "study_uid": test_data.study.uid}, {"uid": test_data.visits[6].uid, "study_uid": test_data.study.uid}, @@ -390,7 +390,7 @@ def test_study_locked_with_no_soa_splits(api_client: TestClient, test_database): response = api_client.get( f"/studies/{test_data.study.uid}/soa-splits", ) - returned = _sort_by_uid(parse_json_response(response)) + returned = _sort_by_uid(parse_json_response(response, assert_status=200)) assert returned == expected diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_design_listings/test_adam_listings_mdvisit.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_design_listings/test_adam_listings_mdvisit.py index 8a36ffda..1cf3ac92 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_design_listings/test_adam_listings_mdvisit.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_design_listings/test_adam_listings_mdvisit.py @@ -238,7 +238,7 @@ def test_adam_with_protocol_soa_html_with_time_units(api_client): AVISIT2="Week 11", AVISIT2N="11", ) - expected_output.STUDYID = mock.ANY + expected_output.STUDYID = mock.ANY # pylint: disable=invalid-name assert res[0] == expected_output.model_dump() day_uid = get_unit_uid_by_name("day") response = api_client.patch( @@ -266,5 +266,5 @@ def test_adam_with_protocol_soa_html_with_time_units(api_client): AVISIT2="Week 11", AVISIT2N="11", ) - expected_output.STUDYID = mock.ANY + expected_output.STUDYID = mock.ANY # pylint: disable=invalid-name assert res[0] == expected_output.model_dump() diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activities.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activities.py index e932e310..cfc19673 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activities.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activities.py @@ -558,7 +558,6 @@ def test_update_library_items_of_relationship_to_value_nodes(api_client): response = api_client.post( f"/concepts/activities/activities/{library_activity_uid}/versions", ) - res = response.json() assert_response_status_code(response, 201) response = api_client.put( @@ -571,13 +570,11 @@ def test_update_library_items_of_relationship_to_value_nodes(api_client): "activity_groupings": initial_activity_groupings, }, ) - res = response.json() assert_response_status_code(response, 200) response = api_client.post( f"/concepts/activities/activity-sub-groups/{library_activity_grouping_subgroup_uid}/versions", ) - res = response.json() assert_response_status_code(response, 201) response = api_client.put( @@ -591,18 +588,15 @@ def test_update_library_items_of_relationship_to_value_nodes(api_client): "activity_groups": [library_activity_grouping_group_uid], }, ) - res = response.json() assert_response_status_code(response, 200) response = api_client.post( f"/concepts/activities/activities/{library_activity_uid}/approvals" ) - res = response.json() assert_response_status_code(response, 201) response = api_client.post( f"/concepts/activities/activity-sub-groups/{library_activity_grouping_subgroup_uid}/approvals" ) - res = response.json() assert_response_status_code(response, 201) # check that the Library item has been changed @@ -636,7 +630,6 @@ def test_update_library_items_of_relationship_to_value_nodes(api_client): response = api_client.post( f"/concepts/activities/activities/{library_activity_uid}/versions", ) - res = response.json() assert_response_status_code(response, 201) response = api_client.put( f"/concepts/activities/activities/{library_activity_uid}", @@ -648,19 +641,16 @@ def test_update_library_items_of_relationship_to_value_nodes(api_client): "activity_groupings": initial_activity_groupings, }, ) - res = response.json() assert_response_status_code(response, 200) response = api_client.post( f"/concepts/activities/activities/{library_activity_uid}/approvals" ) - res = response.json() assert_response_status_code(response, 201) # change activity name and approve the version response = api_client.post( f"/concepts/activities/activity-sub-groups/{library_activity_grouping_subgroup_uid}/versions", ) - res = response.json() assert_response_status_code(response, 201) response = api_client.put( f"/concepts/activities/activity-sub-groups/{library_activity_grouping_subgroup_uid}", @@ -673,12 +663,10 @@ def test_update_library_items_of_relationship_to_value_nodes(api_client): "activity_groups": [library_activity_grouping_group_uid], }, ) - res = response.json() assert_response_status_code(response, 200) response = api_client.post( f"/concepts/activities/activity-sub-groups/{library_activity_grouping_subgroup_uid}/approvals" ) - res = response.json() assert_response_status_code(response, 201) @@ -963,6 +951,19 @@ def test_maintain_outbound_rels(api_client): assert len(res) == 0 +def _normalize_versioned_study_data( + outer_list: list[dict[str, Any]], + visits_items: list[dict[str, Any]], + set_study_version: bool = True, +) -> None: + for i, item in enumerate(outer_list): + if set_study_version: + item["study_version"] = mock.ANY + for j in visits_items[i]: + if isinstance(visits_items[i][j], dict): + visits_items[i][j]["queried_effective_date"] = mock.ANY + + def test_versioning_on_activity_activity_instruction_activity_schedule_as_group( api_client, ): @@ -1160,44 +1161,32 @@ def test_versioning_on_activity_activity_instruction_activity_schedule_as_group( assert len(res) == 0 # CHECK if data of locked study version was not changed + visits_items = expected_visits["items"] # compare study visits of locked study version - for i, _ in enumerate(expected_visits["items"]): - expected_visits["items"][i]["study_version"] = mock.ANY - for j in expected_visits["items"][i]: - if isinstance(expected_visits["items"][i][j], dict): - expected_visits["items"][i][j]["queried_effective_date"] = mock.ANY + _normalize_versioned_study_data(expected_visits["items"], visits_items) current_visits = api_client.get( f"/studies/{study_for_versioning.uid}/study-visits?study_value_version=1" ).json() assert current_visits == expected_visits # compare study activities of locked study version - for i, _ in enumerate(expected_activities["items"]): - expected_activities["items"][i]["study_version"] = mock.ANY - for j in expected_visits["items"][i]: - if isinstance(expected_visits["items"][i][j], dict): - expected_visits["items"][i][j]["queried_effective_date"] = mock.ANY + _normalize_versioned_study_data(expected_activities["items"], visits_items) current_activities = api_client.get( f"/studies/{study_for_versioning.uid}/study-activities?study_value_version=1" ).json() assert current_activities == expected_activities # compare study activity schedules of locked study version - for i, _ in enumerate(expected_activity_schedules): - for j in expected_visits["items"][i]: - if isinstance(expected_visits["items"][i][j], dict): - expected_visits["items"][i][j]["queried_effective_date"] = mock.ANY + _normalize_versioned_study_data( + expected_activity_schedules, visits_items, set_study_version=False + ) current_activity_schedules = api_client.get( f"/studies/{study_for_versioning.uid}/study-activity-schedules?study_value_version=1" ).json() assert current_activity_schedules == expected_activity_schedules # compare study activity instructions of locked study version - for i, _ in enumerate(expected_activity_instructions): - expected_activity_instructions[i]["study_version"] = mock.ANY - for j in expected_visits["items"][i]: - if isinstance(expected_visits["items"][i][j], dict): - expected_visits["items"][i][j]["queried_effective_date"] = mock.ANY + _normalize_versioned_study_data(expected_activity_instructions, visits_items) current_activity_instructions = api_client.get( f"/studies/{study_for_versioning.uid}/study-activity-instructions?study_value_version=1" ).json() @@ -2379,6 +2368,7 @@ def test_study_activity_version_selecting_ct_package(api_client): "change_description": "string", }, ) + assert_response_status_code(response, 200) response = api_client.post(f"/ct/terms/{ctterm_uid}/names/approvals") assert_response_status_code(response, 201) @@ -4191,6 +4181,37 @@ def test_cross_soa_group_duplicate_study_activity_blocked_on_create(api_client): ) +def test_clone_big_study_with_study_activity_flags_enabled(api_client): + response = api_client.post( + "/studies/Study_000001/clone", + json={ + "study_number": "9001", + "study_acronym": "9001", + "project_number": project.project_number, + "description": "Clone with study activity flags enabled", + "copy_study_arm": True, + "copy_study_branch_arm": True, + "copy_study_cohort": True, + "copy_study_element": True, + "copy_study_visit": True, + "copy_study_epoch": True, + "copy_study_visits_study_footnote": True, + "copy_study_epochs_study_footnote": True, + "copy_study_design_matrix": True, + "copy_study_soa_group": True, + "copy_study_activity_group": True, + "copy_study_activity_subgroup": True, + "copy_study_activity": True, + "copy_study_activity_instance": True, + "copy_study_activity_schedule": True, + "validation_mode": ValidationMode.WARNING.value, + }, + ) + assert_response_status_code(response, 201) + study_cloned = response.json() + assert study_cloned["uid"] != "Study_000001" + + def test_cross_soa_group_duplicate_study_activity_blocked_on_patch(api_client): test_study = TestUtils.create_study(project_number=project.project_number) @@ -4821,9 +4842,9 @@ def test_study_activity_invalidate_keep_old_version(api_client): ) assert_response_status_code(response, 200) - # TODO fix study-activities get by uid as it returns same activity for latest_activity and activity if activity is retired - # it should return Retired one as latest_activity and Final one as activity - # as temporary fix calling get all that works fine as for now. + # Known limitation: get by uid returns same activity for latest_activity and activity when activity is retired. + # It should return Retired one as latest_activity and Final one as activity. + # As temporary fix calling get all that works fine as for now. response = api_client.get(f"/studies/{test_study.uid}/study-activities") assert_response_status_code(response, 200) res = response.json()["items"] diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_groups.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_groups.py index 2b1c6e26..099b59f8 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_groups.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_groups.py @@ -366,6 +366,7 @@ def test_modify_visibility_flag_in_protocol_flowchart( assert_response_status_code(response, 201) locked_results = res locked_results["study_version"] = ANY + locked_results["start_date"] = ANY response = api_client.patch( f"/studies/{study.uid}/study-activities/{study_activity_uid}", diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_instances.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_instances.py index 851b39b4..0c398dc3 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_instances.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_instances.py @@ -15,6 +15,7 @@ from fastapi.testclient import TestClient from neomodel import db +from clinical_mdr_api.domains.enums import ValidationMode from clinical_mdr_api.domains.study_selections.study_selection_activity_instance import ( StudyActivityInstanceState, ) @@ -690,6 +691,7 @@ def test_create_study_activity_instance(api_client): "study_visit_uid": study_visit_1.uid, }, ) + assert_response_status_code(response, 201) response = api_client.post( f"/studies/{test_study.uid}/study-activity-instances", @@ -816,6 +818,7 @@ def test_edit_study_activity_instance(api_client): "study_visit_uid": study_visit_2.uid, }, ) + assert_response_status_code(response, 201) # Test is_important & baseline visits # Test is_important field - initially should be False @@ -1102,12 +1105,12 @@ def test_study_activity_instance_header_endpoint(api_client): "flowchart_group": {"term_uid": term_efficacy_uid}, }, ) - res = parse_json_response(response, status=201) + res = parse_json_response(response, assert_status=201) response = api_client.post( f"/concepts/activities/activities/{res['uid']}/approvals" ) - res = parse_json_response(response, status=201) + res = parse_json_response(response, assert_status=201) hello_activity = Activity(**res) response = api_client.post( @@ -1123,7 +1126,7 @@ def test_study_activity_instance_header_endpoint(api_client): response = api_client.get( f"/studies/{test_study.uid}/study-activity-instances/headers?field_name=activity.name", ) - res = parse_json_response(response, status=200) + res = parse_json_response(response, assert_status=200) assert res == [ randomized_activity.name, body_mes_activity.name, @@ -1135,7 +1138,7 @@ def test_study_activity_instance_header_endpoint(api_client): response = api_client.get( f"/studies/{test_study.uid}/study-activity-instances/headers?field_name=activity.library_name", ) - res = parse_json_response(response, status=200) + res = parse_json_response(response, assert_status=200) assert set(res) == {randomized_activity.library_name, hello_activity.library_name} # delete Test Study @@ -1975,7 +1978,7 @@ def test_study_activity_instances_review_changes_batch(api_client): ) response = api_client.delete( - f"/concepts/activities/activity-instances/{randomized_activity_instance.uid}/attributes/activations" + f"/concepts/activities/activity-instances/{randomized_activity_instance.uid}/activations" ) assert_response_status_code(response, 200) @@ -2021,7 +2024,7 @@ def test_study_activity_instances_review_changes_batch(api_client): assert res["state"] == StudyActivityInstanceState.REVIEW_NOT_NEEDED.value response = api_client.post( - f"/concepts/activities/activity-instances/{randomized_activity_instance.uid}/attributes/activations" + f"/concepts/activities/activity-instances/{randomized_activity_instance.uid}/activations" ) assert_response_status_code(response, 200) @@ -2144,7 +2147,7 @@ def test_study_activity_instances_invalidate_keep_old_version(api_client): assert study_activity_instances[0]["activity_instance"]["status"] == "Final" response = api_client.delete( - f"/concepts/activities/activity-instances/{randomized_activity_instance.uid}/attributes/activations" + f"/concepts/activities/activity-instances/{randomized_activity_instance.uid}/activations" ) assert_response_status_code(response, 200) @@ -2177,7 +2180,7 @@ def test_study_activity_instances_invalidate_keep_old_version(api_client): assert res["latest_activity_instance"]["status"] == "Retired" response = api_client.post( - f"/concepts/activities/activity-instances/{randomized_activity_instance.uid}/attributes/activations" + f"/concepts/activities/activity-instances/{randomized_activity_instance.uid}/activations" ) assert_response_status_code(response, 200) @@ -3087,3 +3090,34 @@ def test_state_remove_instance_when_too_many_instances(api_client): assert item["state"] == StudyActivityInstanceState.REMOVE_INSTANCE.value TestUtils.delete_study(test_study.uid) + + +def test_clone_big_study_with_study_activity_flags_enabled(api_client): + response = api_client.post( + "/studies/Study_000001/clone", + json={ + "study_number": "9002", + "study_acronym": "9002", + "project_number": project.project_number, + "description": "Clone with study activity flags enabled", + "copy_study_arm": True, + "copy_study_branch_arm": True, + "copy_study_cohort": True, + "copy_study_element": True, + "copy_study_visit": True, + "copy_study_epoch": True, + "copy_study_visits_study_footnote": True, + "copy_study_epochs_study_footnote": True, + "copy_study_design_matrix": True, + "copy_study_soa_group": True, + "copy_study_activity_group": True, + "copy_study_activity_subgroup": True, + "copy_study_activity": True, + "copy_study_activity_instance": True, + "copy_study_activity_schedule": True, + "validation_mode": ValidationMode.WARNING.value, + }, + ) + assert_response_status_code(response, 201) + study_cloned = response.json() + assert study_cloned["uid"] != "Study_000001" diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_subgroups.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_subgroups.py index 28c86062..2a8fb7ef 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_subgroups.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_activity_subgroups.py @@ -345,6 +345,7 @@ def test_modify_visibility_flag_in_protocol_flowchart( assert_response_status_code(response, 201) locked_data = res locked_data["study_version"] = ANY + locked_data["start_date"] = ANY response = api_client.patch( f"/studies/{study.uid}/study-activities/{study_activity_uid}", diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_design_cells.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_design_cells.py index 71c75696..93908bab 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_design_cells.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_design_cells.py @@ -550,6 +550,12 @@ def test_study_design_cell_with_study_epoch_relationship(api_client): "copy_study_visits_study_footnote": True, "copy_study_epochs_study_footnote": True, "copy_study_design_matrix": True, + "copy_study_soa_group": True, + "copy_study_activity_group": True, + "copy_study_activity_subgroup": True, + "copy_study_activity": True, + "copy_study_activity_instance": True, + "copy_study_activity_schedule": True, "validation_mode": ValidationMode.STRICT.value, }, ) @@ -634,6 +640,20 @@ def test_study_design_cell_with_study_epoch_relationship(api_client): final_design_cell_by_branch_arm[i]["start_date"] = mock.ANY assert cloned_design_cell_by_branch_arm_any == final_design_cell_by_branch_arm + containment_response = api_client.get( + f"/studies/{study.uid}/study-selection-containment/{study_cloned['uid']}" + ) + assert_response_status_code(containment_response, 200) + containment = containment_response.json() + assert containment["target_contained_in_source"] + for row in containment["per_label"]: + assert row["label_contained"] + assert row["target_selection_count"] == row["source_selection_count"] + assert ( + row["target_distinct_ct_term_root_count"] + == row["source_distinct_ct_term_root_count"] + ) + response = api_client.post( f"/studies/{study.uid}/clone", json={ @@ -650,6 +670,12 @@ def test_study_design_cell_with_study_epoch_relationship(api_client): "copy_study_visits_study_footnote": False, "copy_study_epochs_study_footnote": False, "copy_study_design_matrix": False, + "copy_study_soa_group": False, + "copy_study_activity_group": False, + "copy_study_activity_subgroup": False, + "copy_study_activity": False, + "copy_study_activity_instance": False, + "copy_study_activity_schedule": False, "validation_mode": ValidationMode.STRICT.value, }, ) @@ -673,9 +699,52 @@ def test_study_design_cell_with_study_epoch_relationship(api_client): "copy_study_visits_study_footnote": False, "copy_study_epochs_study_footnote": False, "copy_study_design_matrix": False, + "copy_study_soa_group": False, + "copy_study_activity_group": False, + "copy_study_activity_subgroup": False, + "copy_study_activity": False, + "copy_study_activity_instance": False, + "copy_study_activity_schedule": False, "validation_mode": ValidationMode.STRICT.value, }, ) assert_response_status_code(response, 400) res = response.json() assert res["message"] == "Study Branch should be also included" + + db.cypher_query( + """ + MATCH (sr:StudyRoot {uid: $study_uid})-[:LATEST]->(sv:StudyValue)-[rel]->(ss:StudyBranchArm) + WHERE type(rel) <> 'HAS_PROTOCOL_SOA_CELL' AND type(rel) <> 'HAS_PROTOCOL_SOA_FOOTNOTE' + WITH ss LIMIT 1 + REMOVE ss:StudyBranchArm + SET ss:StudyBranchArm_THIS_IS_A_TEST + """, + {"study_uid": study_cloned["uid"]}, + ) + containment_after_partial = api_client.get( + f"/studies/{study.uid}/study-selection-containment/{study_cloned['uid']}" + ) + assert_response_status_code(containment_after_partial, 200) + partial = containment_after_partial.json() + assert partial["target_contained_in_source"] + branch_rows = [r for r in partial["per_label"] if r["label"] == "StudyBranchArm"] + if branch_rows: + assert ( + branch_rows[0]["target_selection_count"] + < branch_rows[0]["source_selection_count"] + ) + assert branch_rows[0]["label_contained"] + else: + assert "StudyBranchArm" not in partial["labels_from_target"] + + db.cypher_query( + """ + MATCH (sr:StudyRoot {uid: $study_uid})-[:LATEST]->(sv:StudyValue)-[rel]->(ss:StudyBranchArm_THIS_IS_A_TEST) + WHERE type(rel) <> 'HAS_PROTOCOL_SOA_CELL' AND type(rel) <> 'HAS_PROTOCOL_SOA_FOOTNOTE' + WITH ss LIMIT 1 + REMOVE ss:StudyBranchArm_THIS_IS_A_TEST + SET ss:StudyBranchArm + """, + {"study_uid": study_cloned["uid"]}, + ) diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_epochs.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_epochs.py index 51274042..401d727f 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_epochs.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_epochs.py @@ -663,3 +663,101 @@ def test_get_all_epochs_invalid_study_uid_or_version( response = api_client.get(f"/studies/{study_uid}/study-epochs", params=params) assert_response_status_code(response, 404) + + +def test_preview_epoch_does_not_create_duplicate_terms_in_epoch_codelist(api_client): + """Preview a study epoch with Screening subtype (C101526) and verify + that the number of terms in the Epoch codelist (C99079) does not change.""" + catalogue_name, library_name = get_catalogue_name_library_name(use_test_utils=True) + + # Create a Screening epoch subtype and link it to an existing epoch type + screening_subtype = TestUtils.create_ct_term( + codelist_uid="CTCodelist_00003", + term_uid="C101526", + submission_value="SCREENING", + sponsor_preferred_name="Screening", + order=10, + catalogue_name=catalogue_name, + library_name=library_name, + approve=True, + ) + TestUtils.add_ct_term_parent( + term=screening_subtype, + parent_uid="EpochType_0001", + relationship_type="type", + ) + + # Create a dedicated study for this test + preview_study = TestUtils.create_study() + + # Count terms in the Epoch codelist before preview + response = api_client.get( + "/ct/terms", + params={"codelist_uid": "C99079", "total_count": True, "page_size": 0}, + ) + assert_response_status_code(response, 200) + terms_before = response.json()["total"] + + # Call the preview endpoint + response = api_client.post( + f"/studies/{preview_study.uid}/study-epochs/preview", + json={ + "study_uid": preview_study.uid, + "epoch_subtype": screening_subtype.term_uid, + }, + ) + assert_response_status_code(response, 200) + preview_result = response.json() + + assert ( + preview_result["epoch_subtype_ctterm"]["term_uid"] == screening_subtype.term_uid + ) + assert ( + preview_result["epoch_subtype_ctterm"]["sponsor_preferred_name"] == "Screening" + ) + + # Count terms in the Epoch codelist after preview — should be unchanged + response = api_client.get( + "/ct/terms", + params={"codelist_uid": "C99079", "total_count": True, "page_size": 0}, + ) + assert_response_status_code(response, 200) + terms_after = response.json()["total"] + + assert terms_after == terms_before + 1, ( + f"Preview should create new terms in the Epoch codelist (C99079). " + f"Terms before: {terms_before}, terms after: {terms_after}" + ) + + terms_before = terms_after + + # Call the preview endpoint + response = api_client.post( + f"/studies/{preview_study.uid}/study-epochs/preview", + json={ + "study_uid": preview_study.uid, + "epoch_subtype": screening_subtype.term_uid, + }, + ) + assert_response_status_code(response, 200) + preview_result = response.json() + + assert ( + preview_result["epoch_subtype_ctterm"]["term_uid"] == screening_subtype.term_uid + ) + assert ( + preview_result["epoch_subtype_ctterm"]["sponsor_preferred_name"] == "Screening" + ) + + # Count terms in the Epoch codelist after preview — should be unchanged + response = api_client.get( + "/ct/terms", + params={"codelist_uid": "C99079", "total_count": True, "page_size": 0}, + ) + assert_response_status_code(response, 200) + terms_after = response.json()["total"] + + assert terms_after == terms_before, ( + f"Preview should create new terms in the Epoch codelist (C99079). " + f"Terms before: {terms_before}, terms after: {terms_after}" + ) diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_flowchart.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_flowchart.py index 320500fb..c9d3280f 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_flowchart.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_flowchart.py @@ -1204,7 +1204,7 @@ def test_get_study_flowchart_versioned(api_client, temp_database_populated): "study_value_version": released_study_version, }, ) - released_soa = parse_json_response(response, status=200) + released_soa = parse_json_response(response, assert_status=200) # THEN: SoA of the released study version is the same as the previous draft SoA assert released_soa == r1_soa diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_soa_footnotes.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_soa_footnotes.py index bf880ad8..044fbfe4 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_soa_footnotes.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_soa_footnotes.py @@ -1578,6 +1578,12 @@ def test_update_footnote_library_items_of_relationship_to_value_nodes(api_client "copy_study_visits_study_footnote": False, "copy_study_epochs_study_footnote": True, "copy_study_design_matrix": True, + "copy_study_soa_group": True, + "copy_study_activity_group": False, + "copy_study_activity_subgroup": False, + "copy_study_activity": False, + "copy_study_activity_instance": False, + "copy_study_activity_schedule": False, "validation_mode": ValidationMode.WARNING.value, # TODO: FIX DELETION, FAILING ON INTEGRITY CHECK }, ).json() @@ -1657,6 +1663,12 @@ def test_update_footnote_library_items_of_relationship_to_value_nodes(api_client "copy_study_visits_study_footnote": False, "copy_study_epochs_study_footnote": False, "copy_study_design_matrix": False, + "copy_study_soa_group": False, + "copy_study_activity_group": False, + "copy_study_activity_subgroup": False, + "copy_study_activity": False, + "copy_study_activity_instance": False, + "copy_study_activity_schedule": False, "validation_mode": ValidationMode.WARNING.value, # TODO: FIX DELETION, FAILING ON INTEGRITY CHECK }, ).json() @@ -1682,6 +1694,12 @@ def test_update_footnote_library_items_of_relationship_to_value_nodes(api_client "copy_study_visits_study_footnote": True, "copy_study_epochs_study_footnote": True, "copy_study_design_matrix": True, + "copy_study_soa_group": True, + "copy_study_activity_group": True, + "copy_study_activity_subgroup": True, + "copy_study_activity": True, + "copy_study_activity_instance": True, + "copy_study_activity_schedule": True, "validation_mode": ValidationMode.WARNING.value, # TODO: FIX DELETION, FAILING ON INTEGRITY CHECK }, ).json() @@ -1689,6 +1707,13 @@ def test_update_footnote_library_items_of_relationship_to_value_nodes(api_client cloned_footnotes = api_client.get( f"/studies/{study_cloned['uid']}/study-soa-footnotes" ).json() + _activity_ref_types = { + "StudyActivityGroup", + "StudyActivitySubGroup", + "StudyActivity", + "StudyActivityInstance", + "StudyActivitySchedule", + } cloned_footnotes_any = copy.deepcopy(cloned_footnotes) for i, _ in enumerate(cloned_footnotes_any["items"]): cloned_footnotes_any["items"][i]["study_version"] = mock.ANY @@ -1696,10 +1721,16 @@ def test_update_footnote_library_items_of_relationship_to_value_nodes(api_client cloned_footnotes_any["items"][i]["modified"] = mock.ANY cloned_footnotes_any["items"][i]["uid"] = mock.ANY cloned_footnotes_any["items"][i]["order"] = mock.ANY + cloned_footnotes_any["items"][i]["template"] = mock.ANY for j, __ in enumerate(cloned_footnotes_any["items"][i]["referenced_items"]): cloned_footnotes_any["items"][i]["referenced_items"][j][ "item_uid" ] = mock.ANY + cloned_footnotes_any["items"][i]["referenced_items"] = [ + {**item, "item_uid": mock.ANY, "visible_in_protocol_soa": mock.ANY} + for item in cloned_footnotes_any["items"][i]["referenced_items"] + if item["item_type"] not in _activity_ref_types + ] # Fetch footnotes for the original study final_footnotes = api_client.get(f"/studies/{study.uid}/study-soa-footnotes").json() @@ -1711,7 +1742,7 @@ def test_update_footnote_library_items_of_relationship_to_value_nodes(api_client if [ True for item in footnote["referenced_items"] - if item["item_type"] in {"StudyActivityGroup"} + if item["item_type"] in _activity_ref_types ]: continue footnote.update( @@ -1721,13 +1752,14 @@ def test_update_footnote_library_items_of_relationship_to_value_nodes(api_client "modified": mock.ANY, "uid": mock.ANY, "order": mock.ANY, + "template": mock.ANY, } ) footnote["referenced_items"] = [ {**item, "item_uid": mock.ANY, "visible_in_protocol_soa": mock.ANY} for item in footnote["referenced_items"] - if item["item_type"] not in {"StudyActivityGroup"} + if item["item_type"] not in _activity_ref_types ] normalized_footnotes.append(footnote) diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_versions.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_versions.py index 1285f4b5..09d58ca9 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_versions.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_versions.py @@ -66,7 +66,8 @@ def test_get_snapshot_history(api_client): ) assert_response_status_code(response, 200) res = response.json() - assert res is None + assert res["protocol_header_version"] is None + assert res["has_final_protocol_locked_version"] is False # snapshot history before lock response = api_client.get( @@ -170,7 +171,8 @@ def test_get_snapshot_history(api_client): ) assert_response_status_code(response, 200) res = response.json() - assert res is None + assert res["protocol_header_version"] is None + assert res["has_final_protocol_locked_version"] is False # get all standard versions response = api_client.get( @@ -240,7 +242,8 @@ def test_get_snapshot_history(api_client): ) assert_response_status_code(response, 200) res = response.json() - assert res is None + assert res["protocol_header_version"] is None + assert res["has_final_protocol_locked_version"] is False # get all standard versions response = api_client.get( @@ -307,7 +310,8 @@ def test_get_snapshot_history(api_client): ) assert_response_status_code(response, 200) res = response.json() - assert res == "0.1" + assert res["protocol_header_version"] == "0.1" + assert res["has_final_protocol_locked_version"] is False # snapshot history after release response = api_client.get( @@ -371,7 +375,8 @@ def test_get_snapshot_history(api_client): ) assert_response_status_code(response, 200) res = response.json() - assert res == "0.2" + assert res["protocol_header_version"] == "0.2" + assert res["has_final_protocol_locked_version"] is False # snapshot history after second release response = api_client.get( f"/studies/{study_with_history.uid}/snapshot-history", @@ -444,7 +449,8 @@ def test_get_snapshot_history(api_client): ) assert_response_status_code(response, 200) res = response.json() - assert res == "1.0" + assert res["protocol_header_version"] == "1.0" + assert res["has_final_protocol_locked_version"] is True # snapshot history after lock response = api_client.get( @@ -593,7 +599,8 @@ def test_get_snapshot_history(api_client): ) assert_response_status_code(response, 200) res = response.json() - assert res == "2.0" + assert res["protocol_header_version"] == "2.0" + assert res["has_final_protocol_locked_version"] is True # snapshot history excluding versions without protocol header version response = api_client.get( diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_visits.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_visits.py index 3dec7d46..a080a0e8 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_visits.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/study_selections/test_study_visits.py @@ -2696,6 +2696,6 @@ def assert_get_all_visits_lite_compares( if study_value_version: params["study_value_version"] = study_value_version response = api_client.get(f"/studies/{study_uid}/study-visits", params=params) - results = parse_json_response(response, status=200) + results = parse_json_response(response, assert_status=200) visits = [StudyVisitLite(**item) for item in results["items"]] assert visits == expected_visits diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/test_feature_flags.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/test_feature_flags.py index 1d8015f5..a8954aa1 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/test_feature_flags.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/test_feature_flags.py @@ -91,7 +91,13 @@ def test_get_feature_flag(api_client): def test_create_feature_flag(api_client): - data = {"name": "Name", "enabled": False, "description": "Description"} + data = { + "section": "admin", + "feature": "Feature", + "name": "Name", + "enabled": False, + "description": "Description", + } response = api_client.post("/feature-flags", json=data) assert_response_status_code(response, 201) @@ -103,13 +109,128 @@ def test_create_feature_flag(api_client): def test_update_feature_flag(api_client): - data = {"enabled": True} + data = {"section": "studies", "feature": "Feature XX", "enabled": True} response = api_client.patch("/feature-flags/11", json=data) assert_response_status_code(response, 200) res = response.json() assert res["sn"] == 11 - assert res["enabled"] == data["enabled"] + for field, value in data.items(): + assert res[field] == value + + +def test_patch_feature_flag_only_section(api_client): + """Test that PATCH updates only the section field, leaving others unchanged""" + # Get the original feature flag + original = api_client.get(f"/feature-flags/{feature_flags[1].sn}").json() + + # Update only the section + data = {"section": "library"} + response = api_client.patch(f"/feature-flags/{feature_flags[1].sn}", json=data) + + assert_response_status_code(response, 200) + res = response.json() + assert res["sn"] == feature_flags[1].sn + assert res["section"] == "library" # Updated field + # Verify other fields remain unchanged + assert res["feature"] == original["feature"] + assert res["name"] == original["name"] + assert res["enabled"] == original["enabled"] + assert res["description"] == original["description"] + + +def test_patch_feature_flag_only_feature(api_client): + """Test that PATCH updates only the feature field, leaving others unchanged""" + # Get the original feature flag + original = api_client.get(f"/feature-flags/{feature_flags[2].sn}").json() + + # Update only the feature + data = {"feature": "Updated Feature Name"} + response = api_client.patch(f"/feature-flags/{feature_flags[2].sn}", json=data) + + assert_response_status_code(response, 200) + res = response.json() + assert res["sn"] == feature_flags[2].sn + assert res["feature"] == "Updated Feature Name" # Updated field + # Verify other fields remain unchanged + assert res["section"] == original["section"] + assert res["name"] == original["name"] + assert res["enabled"] == original["enabled"] + assert res["description"] == original["description"] + + +def test_patch_feature_flag_only_enabled(api_client): + """Test that PATCH updates only the enabled field, leaving others unchanged""" + # Get the original feature flag + original = api_client.get(f"/feature-flags/{feature_flags[3].sn}").json() + original_enabled_state = original["enabled"] + + # Toggle the enabled state + data = {"enabled": not original_enabled_state} + response = api_client.patch(f"/feature-flags/{feature_flags[3].sn}", json=data) + + assert_response_status_code(response, 200) + res = response.json() + assert res["sn"] == feature_flags[3].sn + assert res["enabled"] == (not original_enabled_state) # Updated field + # Verify other fields remain unchanged + assert res["section"] == original["section"] + assert res["feature"] == original["feature"] + assert res["name"] == original["name"] + assert res["description"] == original["description"] + + +def test_patch_feature_flag_multiple_fields(api_client): + """Test that PATCH can update multiple fields at once""" + # Get the original feature flag + original = api_client.get(f"/feature-flags/{feature_flags[4].sn}").json() + + # Update multiple fields + data = {"section": "studies", "enabled": True} + response = api_client.patch(f"/feature-flags/{feature_flags[4].sn}", json=data) + + assert_response_status_code(response, 200) + res = response.json() + assert res["sn"] == feature_flags[4].sn + assert res["section"] == "studies" # Updated + assert res["enabled"] is True # Updated + # Verify other fields remain unchanged + assert res["feature"] == original["feature"] + assert res["name"] == original["name"] + assert res["description"] == original["description"] + + +def test_patch_feature_flag_empty_body(api_client): + """Test that PATCH with empty body returns the feature flag unchanged""" + # Get the original feature flag + original = api_client.get(f"/feature-flags/{feature_flags[5].sn}").json() + + # Send empty patch + data = {} + response = api_client.patch(f"/feature-flags/{feature_flags[5].sn}", json=data) + + assert_response_status_code(response, 200) + res = response.json() + # Verify all fields remain unchanged + assert res == original + + +def test_patch_feature_flag_enabled_false(api_client): + """Test that PATCH can explicitly set enabled to False""" + # First set enabled to True + api_client.patch(f"/feature-flags/{feature_flags[6].sn}", json={"enabled": True}) + + # Verify it's True + current = api_client.get(f"/feature-flags/{feature_flags[6].sn}").json() + assert current["enabled"] is True + + # Now set it to False + data = {"enabled": False} + response = api_client.patch(f"/feature-flags/{feature_flags[6].sn}", json=data) + + assert_response_status_code(response, 200) + res = response.json() + assert res["enabled"] is False # Should be explicitly False, not default def test_delete_feature_flag(api_client): @@ -129,7 +250,13 @@ def test_delete_feature_flag(api_client): def test_cannot_create_feature_flag_with_existing_name(api_client): response = api_client.post( "/feature-flags", - json={"name": "Name", "enabled": False, "description": "Description"}, + json={ + "section": "admin", + "feature": "Feature", + "name": "Name", + "enabled": False, + "description": "Description", + }, ) assert_response_status_code(response, 409) diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/test_studies.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/test_studies.py index a0fe33dd..34a3d4a4 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/api/test_studies.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/api/test_studies.py @@ -23,6 +23,9 @@ from fastapi.testclient import TestClient from neomodel import db +from clinical_mdr_api.domains.study_definition_aggregates.study_metadata import ( + StudyStatus, +) from clinical_mdr_api.main import app from clinical_mdr_api.models.concepts.unit_definitions.unit_definition import ( UnitDefinitionModel, @@ -1741,9 +1744,12 @@ def test_remove_study_subpart_from_parent_part(api_client): # pylint: disable=too-many-statements def test_get_audit_trail_of_all_study_subparts_of_study(api_client): - response = api_client.get(f"/studies/{study_with_subparts.uid}/audit-trail") + response = api_client.get( + f"/studies/{study_with_subparts.uid}/audit-trail?page_size=0" + ) assert_response_status_code(response, 200) res = response.json() + items = res["items"] for i in [ "subpart_uid", "subpart_id", @@ -1754,82 +1760,82 @@ def test_get_audit_trail_of_all_study_subparts_of_study(api_client): "change_type", "changes", ]: - assert i in res[0] - - assert len(res) == 36 - - assert res[0]["subpart_id"] == "i" - assert res[0]["subpart_uid"] == "Study_000034" - assert res[1]["subpart_id"] == "h" - assert res[1]["subpart_uid"] == "Study_000033" - assert res[2]["subpart_id"] == "g" - assert res[2]["subpart_uid"] == "Study_000032" - assert res[3]["subpart_id"] == "f" - assert res[3]["subpart_uid"] == "Study_000030" - assert res[4]["subpart_id"] == "e" - assert res[4]["subpart_uid"] == "Study_000029" - assert res[5]["subpart_id"] == "d" - assert res[5]["subpart_uid"] == "Study_000031" - assert res[6]["subpart_id"] == "c" - assert res[6]["subpart_uid"] == "Study_000027" - assert res[7]["subpart_id"] == "b" - assert res[7]["subpart_uid"] == "Study_000026" - assert res[8]["subpart_id"] == "a" - assert res[8]["subpart_uid"] == "Study_000028" - assert res[9]["subpart_id"] == "j" - assert res[9]["subpart_uid"] == "Study_000035" - assert res[10]["subpart_id"] == "a" - assert res[10]["subpart_uid"] == "Study_000035" - assert res[11]["subpart_id"] == "j" - assert res[11]["subpart_uid"] == "Study_000034" - assert res[12]["subpart_id"] == "i" - assert res[12]["subpart_uid"] == "Study_000033" - assert res[13]["subpart_id"] == "h" - assert res[13]["subpart_uid"] == "Study_000032" - assert res[14]["subpart_id"] == "g" - assert res[14]["subpart_uid"] == "Study_000030" - assert res[15]["subpart_id"] == "f" - assert res[15]["subpart_uid"] == "Study_000029" - assert res[16]["subpart_id"] == "e" - assert res[16]["subpart_uid"] == "Study_000031" - assert res[17]["subpart_id"] == "d" - assert res[17]["subpart_uid"] == "Study_000027" - assert res[18]["subpart_id"] == "c" - assert res[18]["subpart_uid"] == "Study_000026" - assert res[19]["subpart_id"] == "b" - assert res[19]["subpart_uid"] == "Study_000028" - assert res[20]["subpart_id"] == "d" - assert res[20]["subpart_uid"] == "Study_000031" - assert res[21]["subpart_id"] == "f" - assert res[21]["subpart_uid"] == "Study_000030" - assert res[22]["subpart_id"] == "e" - assert res[22]["subpart_uid"] == "Study_000029" - assert res[23]["subpart_id"] == "a" - assert res[23]["subpart_uid"] == "Study_000028" - assert res[24]["subpart_id"] == "c" - assert res[24]["subpart_uid"] == "Study_000027" - assert res[25]["subpart_id"] == "b" - assert res[25]["subpart_uid"] == "Study_000026" - assert res[26]["subpart_id"] == "j" - assert res[26]["subpart_uid"] == "Study_000035" - assert res[27]["subpart_id"] == "i" - assert res[27]["subpart_uid"] == "Study_000034" - assert res[28]["subpart_id"] == "h" - assert res[28]["subpart_uid"] == "Study_000033" - assert res[29]["subpart_id"] == "g" - assert res[29]["subpart_uid"] == "Study_000032" - assert res[30]["subpart_id"] == "f" - assert res[30]["subpart_uid"] == "Study_000031" - assert res[31]["subpart_id"] == "e" - assert res[31]["subpart_uid"] == "Study_000030" - assert res[32]["subpart_id"] == "d" - assert res[32]["subpart_uid"] == "Study_000029" - assert res[33]["subpart_id"] == "c" - assert res[33]["subpart_uid"] == "Study_000028" - assert res[34]["subpart_id"] == "b" - assert res[34]["subpart_uid"] == "Study_000027" - assert res[35]["subpart_id"] == "a" - assert res[35]["subpart_uid"] == "Study_000026" + assert i in items[0] + + assert len(items) == 36 + + assert items[0]["subpart_id"] == "i" + assert items[0]["subpart_uid"] == "Study_000034" + assert items[1]["subpart_id"] == "h" + assert items[1]["subpart_uid"] == "Study_000033" + assert items[2]["subpart_id"] == "g" + assert items[2]["subpart_uid"] == "Study_000032" + assert items[3]["subpart_id"] == "f" + assert items[3]["subpart_uid"] == "Study_000030" + assert items[4]["subpart_id"] == "e" + assert items[4]["subpart_uid"] == "Study_000029" + assert items[5]["subpart_id"] == "d" + assert items[5]["subpart_uid"] == "Study_000031" + assert items[6]["subpart_id"] == "c" + assert items[6]["subpart_uid"] == "Study_000027" + assert items[7]["subpart_id"] == "b" + assert items[7]["subpart_uid"] == "Study_000026" + assert items[8]["subpart_id"] == "a" + assert items[8]["subpart_uid"] == "Study_000028" + assert items[9]["subpart_id"] == "j" + assert items[9]["subpart_uid"] == "Study_000035" + assert items[10]["subpart_id"] == "a" + assert items[10]["subpart_uid"] == "Study_000035" + assert items[11]["subpart_id"] == "j" + assert items[11]["subpart_uid"] == "Study_000034" + assert items[12]["subpart_id"] == "i" + assert items[12]["subpart_uid"] == "Study_000033" + assert items[13]["subpart_id"] == "h" + assert items[13]["subpart_uid"] == "Study_000032" + assert items[14]["subpart_id"] == "g" + assert items[14]["subpart_uid"] == "Study_000030" + assert items[15]["subpart_id"] == "f" + assert items[15]["subpart_uid"] == "Study_000029" + assert items[16]["subpart_id"] == "e" + assert items[16]["subpart_uid"] == "Study_000031" + assert items[17]["subpart_id"] == "d" + assert items[17]["subpart_uid"] == "Study_000027" + assert items[18]["subpart_id"] == "c" + assert items[18]["subpart_uid"] == "Study_000026" + assert items[19]["subpart_id"] == "b" + assert items[19]["subpart_uid"] == "Study_000028" + assert items[20]["subpart_id"] == "d" + assert items[20]["subpart_uid"] == "Study_000031" + assert items[21]["subpart_id"] == "f" + assert items[21]["subpart_uid"] == "Study_000030" + assert items[22]["subpart_id"] == "e" + assert items[22]["subpart_uid"] == "Study_000029" + assert items[23]["subpart_id"] == "a" + assert items[23]["subpart_uid"] == "Study_000028" + assert items[24]["subpart_id"] == "c" + assert items[24]["subpart_uid"] == "Study_000027" + assert items[25]["subpart_id"] == "b" + assert items[25]["subpart_uid"] == "Study_000026" + assert items[26]["subpart_id"] == "j" + assert items[26]["subpart_uid"] == "Study_000035" + assert items[27]["subpart_id"] == "i" + assert items[27]["subpart_uid"] == "Study_000034" + assert items[28]["subpart_id"] == "h" + assert items[28]["subpart_uid"] == "Study_000033" + assert items[29]["subpart_id"] == "g" + assert items[29]["subpart_uid"] == "Study_000032" + assert items[30]["subpart_id"] == "f" + assert items[30]["subpart_uid"] == "Study_000031" + assert items[31]["subpart_id"] == "e" + assert items[31]["subpart_uid"] == "Study_000030" + assert items[32]["subpart_id"] == "d" + assert items[32]["subpart_uid"] == "Study_000029" + assert items[33]["subpart_id"] == "c" + assert items[33]["subpart_uid"] == "Study_000028" + assert items[34]["subpart_id"] == "b" + assert items[34]["subpart_uid"] == "Study_000027" + assert items[35]["subpart_id"] == "a" + assert items[35]["subpart_uid"] == "Study_000026" # update study title to be able to lock it response = api_client.patch( @@ -1902,17 +1908,22 @@ def test_get_audit_trail_of_all_study_subparts_of_study(api_client): assert_response_status_code(response, 201) response = api_client.get( - f"/studies/{study_with_subparts.uid}/audit-trail?study_value_version=1" + f"/studies/{study_with_subparts.uid}/audit-trail?study_value_version=1&page_size=0" ) assert_response_status_code(response, 200) res = response.json() - assert all(i["end_date"] for i in res if i["subpart_uid"] == "Study_000026") + assert all( + i["end_date"] for i in res["items"] if i["subpart_uid"] == "Study_000026" + ) def test_get_audit_trail_of_study_subpart(api_client): - response = api_client.get("/studies/Study_000030/audit-trail?is_subpart=true") + response = api_client.get( + "/studies/Study_000030/audit-trail?is_subpart=true&page_size=0" + ) assert_response_status_code(response, 200) res = response.json() + items = res["items"] for i in [ "subpart_uid", "subpart_id", @@ -1923,32 +1934,32 @@ def test_get_audit_trail_of_study_subpart(api_client): "change_type", "changes", ]: - assert i in res[0] - - assert len(res) == 11 - - assert res[0]["subpart_id"] == "e" - assert res[0]["subpart_uid"] == "Study_000030" - assert res[1]["subpart_id"] == "e" - assert res[1]["subpart_uid"] == "Study_000030" - assert res[2]["subpart_id"] == "e" - assert res[2]["subpart_uid"] == "Study_000030" - assert res[3]["subpart_id"] == "e" - assert res[3]["subpart_uid"] == "Study_000030" - assert res[4]["subpart_id"] == "f" - assert res[4]["subpart_uid"] == "Study_000030" - assert res[5]["subpart_id"] == "f" - assert res[5]["subpart_uid"] == "Study_000030" - assert res[6]["subpart_id"] == "f" - assert res[6]["subpart_uid"] == "Study_000030" - assert res[7]["subpart_id"] == "f" - assert res[7]["subpart_uid"] == "Study_000030" - assert res[8]["subpart_id"] == "g" - assert res[8]["subpart_uid"] == "Study_000030" - assert res[9]["subpart_id"] == "f" - assert res[9]["subpart_uid"] == "Study_000030" - assert res[10]["subpart_id"] == "e" - assert res[10]["subpart_uid"] == "Study_000030" + assert i in items[0] + + assert len(items) == 11 + + assert items[0]["subpart_id"] == "e" + assert items[0]["subpart_uid"] == "Study_000030" + assert items[1]["subpart_id"] == "e" + assert items[1]["subpart_uid"] == "Study_000030" + assert items[2]["subpart_id"] == "e" + assert items[2]["subpart_uid"] == "Study_000030" + assert items[3]["subpart_id"] == "e" + assert items[3]["subpart_uid"] == "Study_000030" + assert items[4]["subpart_id"] == "f" + assert items[4]["subpart_uid"] == "Study_000030" + assert items[5]["subpart_id"] == "f" + assert items[5]["subpart_uid"] == "Study_000030" + assert items[6]["subpart_id"] == "f" + assert items[6]["subpart_uid"] == "Study_000030" + assert items[7]["subpart_id"] == "f" + assert items[7]["subpart_uid"] == "Study_000030" + assert items[8]["subpart_id"] == "g" + assert items[8]["subpart_uid"] == "Study_000030" + assert items[9]["subpart_id"] == "f" + assert items[9]["subpart_uid"] == "Study_000030" + assert items[10]["subpart_id"] == "e" + assert items[10]["subpart_uid"] == "Study_000030" def test_cannot_use_a_study_parent_part_as_study_subpart(api_client): @@ -2998,6 +3009,33 @@ def test_study_structure_statistics( assert content["branch_count"] == 0 assert content["epoch_footnote_count"] == 0 assert content["visit_footnote_count"] == 0 + assert content["study_activity_count"] == 0 + assert content["study_activity_schedule_count"] == 0 + + +def test_study_selection_containment(api_client, tst_study): + """Source/target existence and self-comparison (full equality per label).""" + response = api_client.get( + f"studies/123000/study-selection-containment/{tst_study.uid}" + ) + assert_response_status_code(response, 404) + response = api_client.get( + f"studies/{tst_study.uid}/study-selection-containment/123000" + ) + assert_response_status_code(response, 404) + response = api_client.get( + f"studies/{tst_study.uid}/study-selection-containment/{tst_study.uid}" + ) + assert_response_status_code(response, 200) + data = response.json() + assert data["target_contained_in_source"] + for row in data["per_label"]: + assert row["label_contained"] + assert row["target_selection_count"] == row["source_selection_count"] + assert ( + row["target_distinct_ct_term_root_count"] + == row["source_distinct_ct_term_root_count"] + ) # pylint: disable=invalid-name @@ -3018,6 +3056,7 @@ def test_get_studies_list(api_client): "acronym", "number", "title", + "description", "subpart_id", "subpart_acronym", "clinical_programme_name", @@ -3080,6 +3119,44 @@ def test_get_study_complexity_score(api_client): assert res_version_1 >= 0 +@pytest.mark.parametrize( + "study_value_version", + [ + pytest.param(None), + pytest.param("1"), + ], +) +def test_get_study_complexity_score_details(api_client, study_value_version): + params = {"study_value_version": study_value_version} if study_value_version else {} + + # Verify that the `details` endpoint return the same version of the complexity score as the total complexity score endpoint, + # and that the total complexity score is the sum of the individual components returned in the details endpoint. + res_total = api_client.get(f"/studies/{study.uid}/complexity-score", params=params) + res_details = api_client.get( + f"/studies/{study.uid}/complexity-score-details", params=params + ) + assert_response_status_code(res_total, 200) + assert_response_status_code(res_details, 200) + + cs_details = res_details.json() + assert isinstance(cs_details, dict) + for component in ["visits", "assessments"]: + assert component in cs_details + assert isinstance(cs_details[component], list) + for item in cs_details[component]: + assert isinstance(item["type"], str) + assert isinstance(item["burden"], (int, float)) + assert isinstance(item["count"], (int, float)) + assert item["burden"] >= 0 + assert item["count"] >= 0 + + # Assert that the total complexity score is the sum of count * burden for each visit and each assessment + total_calculated = sum( + v["count"] * v["burden"] for v in cs_details["visits"] + ) + sum(a["count"] * a["burden"] for a in cs_details["assessments"]) + assert total_calculated == pytest.approx(res_total.json()) + + def test_get_integrity_check_for_study(api_client): """Test integrity check endpoint for a specific study""" study_int = TestUtils.create_study() @@ -3252,3 +3329,370 @@ def test_cannot_update_study_to_an_already_existing_study_acronym(api_client): res = response.json() assert res["type"] == "AlreadyExistsException" assert res["message"] == "Study with Study Acronym 'study_root' already exists." + + +def test_study_template_create_patch_retire_reactivate(api_client): + def _term_uid(value): + if isinstance(value, dict): + return value["term_uid"] + if hasattr(value, "term_uid"): + return value.term_uid + return value + + lock_reason_uid = _term_uid(test_data_dict["reason_for_lock_terms"][0]) + release_reason_uid = _term_uid(test_data_dict["reason_for_lock_terms"][0]) + unlock_reason_uid = _term_uid(test_data_dict["reason_for_unlock_terms"][0]) + + template_study = TestUtils.create_study() + template_study_title = "Template study title" + TestUtils.set_study_title(template_study.uid, study_title=template_study_title) + lock_response = api_client.post( + f"/studies/{template_study.uid}/locks", + json={ + "change_description": "lock template study", + "reason_for_change_uid": lock_reason_uid, + }, + ) + assert_response_status_code(lock_response, 201) + template_version = lock_response.json()["current_metadata"]["version_metadata"][ + "version_number" + ] + + response = api_client.post( + "/studies/template", + json={ + "study_uid": template_study.uid, + "study_value_version": template_version, + }, + ) + assert_response_status_code(response, 201) + created = response.json() + assert created["study_uid"] == template_study.uid + assert created["study_value_version"] == template_version + assert created["status"] == "Final" + + released_study = TestUtils.create_study() + study_b_title = "Study B title" + TestUtils.set_study_title(released_study.uid, study_title=study_b_title) + lock_response = api_client.post( + f"/studies/{released_study.uid}/locks", + json={ + "change_description": "lock released study", + "reason_for_change_uid": lock_reason_uid, + }, + ) + assert_response_status_code(lock_response, 201) + response = api_client.post( + f"/studies/{released_study.uid}/unlocks", + json={ + "change_description": "Unlock Study B for initial release", + "reason_for_change_uid": unlock_reason_uid, + }, + ) + assert_response_status_code(response, 201) + release_response = api_client.post( + f"/studies/{released_study.uid}/release", + json={ + "change_description": "release study", + "reason_for_change_uid": release_reason_uid, + }, + ) + assert_response_status_code(release_response, 201) + # `release` keeps the Study in DRAFT state, so `current_metadata.version_number` + # in the response can be `None`. Instead, resolve the latest RELEASED snapshot version. + study_history = ( + StudyService().get_study_snapshot_history(study_uid=released_study.uid).items + ) + release_history = sorted( + (item for item in study_history if StudyStatus.RELEASED in item.study_status), + key=lambda item: item.modified_date, + reverse=True, + ) + assert release_history + released_version = release_history[0].metadata_version + assert released_version is not None + + response = api_client.patch( + "/studies/template", + json={ + "study_uid": released_study.uid, + "study_value_version": released_version, + "change_description": "Switch template study", + }, + ) + assert_response_status_code(response, 200) + patched = response.json() + assert patched["study_uid"] == released_study.uid + assert patched["study_value_version"] == released_version + assert patched["status"] == "Final" + + response = api_client.get("/studies/template") + assert_response_status_code(response, 200) + active_template_v1 = response.json() + assert active_template_v1["study_uid"] == released_study.uid + assert active_template_v1["study_value_version"] == released_version + + # Add a second FINAL version for the same StudyB (unlock if needed -> patch short title -> release) + current_status_response = api_client.get(f"/studies/{released_study.uid}") + assert_response_status_code(current_status_response, 200) + current_status = current_status_response.json()["current_metadata"][ + "version_metadata" + ]["study_status"] + if current_status == "LOCKED": + response = api_client.post( + f"/studies/{released_study.uid}/unlocks", + json={ + "change_description": "Unlock Study B for v2", + "reason_for_change_uid": unlock_reason_uid, + }, + ) + assert_response_status_code(response, 201) + + study_short_title_v2 = TestUtils.random_str(8, prefix="study-short-v2-") + response = api_client.patch( + f"/studies/{released_study.uid}", + json={ + "current_metadata": { + "study_description": { + "study_title": study_b_title, + "study_short_title": study_short_title_v2, + } + } + }, + ) + assert_response_status_code(response, 200) + + # Release requires DRAFT; by now the study is expected to be editable. + release_response_v2 = api_client.post( + f"/studies/{released_study.uid}/release", + json={ + "change_description": "Release Study B v2", + "reason_for_change_uid": release_reason_uid, + }, + ) + assert_response_status_code(release_response_v2, 201) + + # `release` keeps the Study in DRAFT state, so `current_metadata.version_number` + # in the response can be `None`. Instead, resolve the latest RELEASED snapshot version. + study_history = ( + StudyService().get_study_snapshot_history(study_uid=released_study.uid).items + ) + release_history = sorted( + (item for item in study_history if StudyStatus.RELEASED in item.study_status), + key=lambda item: item.modified_date, + reverse=True, + ) + assert release_history + released_version_v2 = release_history[0].metadata_version + assert released_version_v2 is not None + + # PATCH the template to point to StudyB v2, then GET immediately to verify persistence + response = api_client.patch( + "/studies/template", + json={ + "study_uid": released_study.uid, + "study_value_version": released_version_v2, + "change_description": "Switch template study to v2", + }, + ) + assert_response_status_code(response, 200) + patched_v2 = response.json() + assert patched_v2["study_uid"] == released_study.uid + assert patched_v2["study_value_version"] == released_version_v2 + assert patched_v2["status"] == "Final" + + response = api_client.get("/studies/template") + assert_response_status_code(response, 200) + active_template_v2 = response.json() + assert active_template_v2["study_uid"] == released_study.uid + assert active_template_v2["study_value_version"] == released_version_v2 + + response = api_client.delete("/studies/template/activations") + assert_response_status_code(response, 200) + retired = response.json() + assert retired["status"] == "Retired" + + response = api_client.get("/studies/template") + assert_response_status_code(response, 200) + retired_template = response.json() + assert retired_template is not None + assert retired_template["status"] == "Retired" + assert retired_template["uid"] == retired["uid"] + assert retired_template["study_uid"] == released_study.uid + assert retired_template["study_value_version"] == released_version_v2 + + response = api_client.post("/studies/template/activations") + assert_response_status_code(response, 200) + reactivated = response.json() + assert reactivated["status"] == "Final" + assert reactivated["study_uid"] == released_study.uid + assert reactivated["study_value_version"] == released_version_v2 + + response = api_client.get("/studies/template") + assert_response_status_code(response, 200) + active_template = response.json() + assert active_template["study_uid"] == released_study.uid + assert active_template["study_value_version"] == released_version_v2 + + +def test_study_template_rejects_draft_study_version(api_client): + def _term_uid(value): + if isinstance(value, dict): + return value["term_uid"] + if hasattr(value, "term_uid"): + return value.term_uid + return value + + draft_study = TestUtils.create_study() + TestUtils.set_study_title(draft_study.uid) + lock_reason_uid = _term_uid(test_data_dict["reason_for_lock_terms"][0]) + unlock_reason_uid = _term_uid(test_data_dict["reason_for_unlock_terms"][0]) + lock_response = api_client.post( + f"/studies/{draft_study.uid}/locks", + json={ + "change_description": "lock to create draft later", + "reason_for_change_uid": lock_reason_uid, + }, + ) + assert_response_status_code(lock_response, 201) + unlock_response = api_client.post( + f"/studies/{draft_study.uid}/unlocks", + json={"reason_for_change_uid": unlock_reason_uid}, + ) + assert_response_status_code(unlock_response, 201) + + response = api_client.patch( + "/studies/template", + json={ + "study_uid": draft_study.uid, + "study_value_version": "1.1", + "change_description": "Try draft version", + }, + ) + assert_response_status_code(response, 404) + assert "version" in response.json()["message"] + + +def test_study_template_rejects_empty_target_fields(api_client): + response = api_client.post( + "/studies/template", + json={"study_uid": "", "study_value_version": ""}, + ) + + assert_response_status_code(response, 400) + assert response.json()["details"] == [ + { + "error_code": "string_too_short", + "field": ["body", "study_uid"], + "msg": "String should have at least 1 character", + "ctx": {"min_length": 1}, + }, + { + "error_code": "string_too_short", + "field": ["body", "study_value_version"], + "msg": "String should have at least 1 character", + "ctx": {"min_length": 1}, + }, + ] + + +def _post_or_patch_study_template( + api_client, + study_uid: str, + study_value_version: str, + *, + change_description: str, +): + response = api_client.post( + "/studies/template", + json={"study_uid": study_uid, "study_value_version": study_value_version}, + ) + if response.status_code == 400: + body = response.json() + if body.get("type") == "BusinessLogicException" and "already exists" in ( + body.get("message") or "" + ): + response = api_client.patch( + "/studies/template", + json={ + "study_uid": study_uid, + "study_value_version": study_value_version, + "change_description": change_description, + }, + ) + return response + + +def test_study_template_patch_clears_target_with_empty_study_uid(api_client): + def _term_uid(value): + if isinstance(value, dict): + return value["term_uid"] + if hasattr(value, "term_uid"): + return value.term_uid + return value + + lock_reason_uid = _term_uid(test_data_dict["reason_for_lock_terms"][0]) + template_study = TestUtils.create_study() + TestUtils.set_study_title(template_study.uid) + lock_response = api_client.post( + f"/studies/{template_study.uid}/locks", + json={ + "change_description": "lock template study", + "reason_for_change_uid": lock_reason_uid, + }, + ) + assert_response_status_code(lock_response, 201) + template_version = lock_response.json()["current_metadata"]["version_metadata"][ + "version_number" + ] + + response = _post_or_patch_study_template( + api_client, + template_study.uid, + template_version, + change_description="Seed template for clear-target test", + ) + assert_response_status_code(response, [200, 201]) + + response = api_client.patch( + "/studies/template", + json={ + "study_uid": "", + "study_value_version": "", + "change_description": "Clear template target", + }, + ) + assert_response_status_code(response, 200) + cleared = response.json() + assert cleared["study_uid"] == "" + assert cleared["study_value_version"] == "" + + response = api_client.get("/studies/template") + assert_response_status_code(response, 200) + persisted = response.json() + assert persisted["study_uid"] == "" + assert persisted["study_value_version"] == "" + + response = api_client.patch( + "/studies/template", + json={ + "study_uid": "", + "study_value_version": "ignored-version", + "change_description": "Clear template target (version ignored)", + }, + ) + assert_response_status_code(response, 200) + assert response.json()["study_value_version"] == "" + + +def test_study_template_patch_requires_version_when_study_uid_set(api_client): + study = TestUtils.create_study() + response = api_client.patch( + "/studies/template", + json={ + "study_uid": study.uid, + "study_value_version": "", + "change_description": "missing version", + }, + ) + assert_response_status_code(response, 400) diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/services/test_study_disease_milestone.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/services/test_study_disease_milestone.py index 04d2533a..8e3d860f 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/services/test_study_disease_milestone.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/services/test_study_disease_milestone.py @@ -102,12 +102,12 @@ def test__reorder_disease_milestones(self): self.assertEqual(dm3.order, 3) with self.assertRaises(exceptions.BusinessLogicException): - disease_milestone_service.reorder(dm3.uid, 0) + disease_milestone_service.reorder(dm3.study_uid, dm3.uid, 0) with self.assertRaises(exceptions.BusinessLogicException): - disease_milestone_service.reorder(dm3.uid, 4) + disease_milestone_service.reorder(dm3.study_uid, dm3.uid, 4) - disease_milestone_service.reorder(dm3.uid, 1) + disease_milestone_service.reorder(dm3.study_uid, dm3.uid, 1) dm_after1 = disease_milestone_service.find_by_uid(dm1.uid) dm_after2 = disease_milestone_service.find_by_uid(dm2.uid) @@ -117,7 +117,7 @@ def test__reorder_disease_milestones(self): self.assertEqual(dm_after2.order, 3) self.assertEqual(dm_after3.order, 1) - disease_milestone_service.reorder(dm1.uid, 3) + disease_milestone_service.reorder(dm1.study_uid, dm1.uid, 3) dm_after1 = disease_milestone_service.find_by_uid(dm1.uid) dm_after2 = disease_milestone_service.find_by_uid(dm2.uid) @@ -137,7 +137,7 @@ def test__reorder_disease_milestones(self): self.assertEqual(dm4.order, 4) dm5 = disease_milestone_service.find_by_uid(disease_milestone_subtype_3.uid) self.assertEqual(dm5.order, 5) - disease_milestone_service.reorder(dm5.uid, 4) + disease_milestone_service.reorder(dm5.study_uid, dm5.uid, 4) dm4 = disease_milestone_service.find_by_uid(disease_milestone_subtype_2.uid) self.assertEqual(dm4.order, 5) dm5 = disease_milestone_service.find_by_uid(disease_milestone_subtype_3.uid) @@ -212,6 +212,7 @@ def test__edit_disease_milestone(self): reason_for_unlock_term_uid=self.reason_for_unlock_term_uid, ) edited_disease_milestone = disease_milestone_service.edit( + study_uid=disease_milestone.study_uid, study_disease_milestone_uid=disease_milestone.uid, study_disease_milestone_input=edit_input, ) @@ -244,6 +245,7 @@ def test__get_versions(self): disease_milestone_type="Disease_Milestone_Type_0002", ) disease_milestone_service.edit( + study_uid=disease_milestone.study_uid, study_disease_milestone_uid=disease_milestone.uid, study_disease_milestone_input=edit_input, ) @@ -265,6 +267,7 @@ def test__get_versions(self): change_description="rules change", ) disease_milestone_service.edit( + study_uid=disease_milestone.study_uid, study_disease_milestone_uid=disease_milestone.uid, study_disease_milestone_input=edit_input, ) @@ -316,7 +319,9 @@ def test__delete_study_disease_milestone(self): reason_for_unlock_term_uid=self.reason_for_unlock_term_uid, ) - disease_milestone_service.delete(study_disease_milestone_uid=dm1.uid) + disease_milestone_service.delete( + study_uid=dm1.study_uid, study_disease_milestone_uid=dm1.uid + ) dm1 = disease_milestone_service.find_by_uid(disease_milestone2.uid) dm2 = disease_milestone_service.find_by_uid(disease_milestone3.uid) @@ -324,7 +329,9 @@ def test__delete_study_disease_milestone(self): self.assertEqual(dm1.order, 2) self.assertEqual(dm2.order, 3) - disease_milestone_service.delete(study_disease_milestone_uid=dm1.uid) + disease_milestone_service.delete( + study_uid=dm1.study_uid, study_disease_milestone_uid=dm1.uid + ) dm1 = disease_milestone_service.find_by_uid(dm2.uid) self.assertEqual(dm1.order, 2) diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/services/test_study_visits.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/services/test_study_visits.py index f108eafa..b9838139 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/services/test_study_visits.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/services/test_study_visits.py @@ -1234,6 +1234,9 @@ def test__create_unscheduled_visit_without_time_data__no_error_is_raised(self): self.assertEqual(all_visits[2].time_reference, None) self.assertEqual(all_visits[2].time_reference, None) self.assertEqual(all_visits[2].visit_number, settings.non_visit_number) + self.assertEqual( + all_visits[2].visit_short_name, f"V{settings.non_visit_number}" + ) self.assertEqual(all_visits[2].min_visit_window_value, -9999) self.assertEqual(all_visits[2].max_visit_window_value, 9999) @@ -1245,7 +1248,6 @@ def test__create_unscheduled_visit_without_time_data__no_error_is_raised(self): "consecutive_visit_group": None, "show_visit": True, "description": "description", - "start_rule": "start_rule", "end_rule": "end_rule", "visit_contact_mode": {"term_uid": "VisitContactMode_0001"}, "visit_type": {"term_uid": "VisitType_0003"}, @@ -1256,6 +1258,11 @@ def test__create_unscheduled_visit_without_time_data__no_error_is_raised(self): unscheduled_visit = visit_service.create( study_uid=self.study.uid, study_visit_input=visit_input ) + # When no start_rule is provided, it should default to settings value + self.assertEqual( + unscheduled_visit.start_rule, + settings.unscheduled_visit_start_rule, + ) with self.assertRaises(ValidationException) as message: non_visit_input.update({"uid": unscheduled_visit.uid}) edit_input = StudyVisitEditInput(**non_visit_input) @@ -1269,9 +1276,14 @@ def test__create_unscheduled_visit_without_time_data__no_error_is_raised(self): message.exception.msg, ) + custom_start_rule = "Custom unscheduled start rule" updated_description = "Updated description" unscheduled_visit_input.update( - {"uid": unscheduled_visit.uid, "description": updated_description} + { + "uid": unscheduled_visit.uid, + "description": updated_description, + "start_rule": custom_start_rule, + } ) edited_unscheduled_visit = visit_service.edit( study_uid=self.study.uid, @@ -1279,6 +1291,19 @@ def test__create_unscheduled_visit_without_time_data__no_error_is_raised(self): study_visit_input=StudyVisitEditInput(**unscheduled_visit_input), ) assert edited_unscheduled_visit.description == updated_description + # When start_rule is provided, it should use the provided value + self.assertEqual( + edited_unscheduled_visit.start_rule, + custom_start_rule, + ) + + all_visits = visit_service.get_all_visits(study_uid=self.study.uid).items + unscheduled = [ + v for v in all_visits if v.visit_class == VisitClass.UNSCHEDULED_VISIT + ][0] + self.assertEqual( + unscheduled.visit_short_name, f"V{settings.unscheduled_visit_number}" + ) def test__create_special_visit(self): visit_service: StudyVisitService = StudyVisitService(study_uid=self.study.uid) diff --git a/clinical-mdr-api/clinical_mdr_api/tests/integration/utils/utils.py b/clinical-mdr-api/clinical_mdr_api/tests/integration/utils/utils.py index c30b52cb..2247da59 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/integration/utils/utils.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/integration/utils/utils.py @@ -7,7 +7,7 @@ import logging from datetime import date, datetime, timedelta, timezone from random import randint -from typing import Any +from typing import Any, Literal from xml.etree import ElementTree import openpyxl @@ -836,12 +836,20 @@ def update_entity_status( @classmethod def create_feature_flag( cls, + section: Literal["admin", "library", "studies"] = "library", + feature: str = "Feature name", name: str = "Feature Flag Name", enabled: bool = False, description: str | None = "Feature Flag Description", ) -> FeatureFlag: service: FeatureFlagService = FeatureFlagService() - payload = FeatureFlagInput(name=name, enabled=enabled, description=description) + payload = FeatureFlagInput( + section=section, + feature=feature, + name=name, + enabled=enabled, + description=description, + ) return service.create_feature_flag(payload) @classmethod diff --git a/clinical-mdr-api/clinical_mdr_api/tests/unit/domain/activity_aggregates/test_activity_instance.py b/clinical-mdr-api/clinical_mdr_api/tests/unit/domain/activity_aggregates/test_activity_instance.py index ff69e934..312ddc9e 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/unit/domain/activity_aggregates/test_activity_instance.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/unit/domain/activity_aggregates/test_activity_instance.py @@ -38,8 +38,11 @@ def create_random_activity_instance_grouping_vo() -> ActivityInstanceGroupingVO: random_activity_instance_grouping_vo = ActivityInstanceGroupingVO( activity_group_uid=random_str(), + activity_group_version=random_str(), activity_subgroup_uid=random_str(), + activity_subgroup_version=random_str(), activity_uid=random_str(), + activity_version=random_str(), ) return random_activity_instance_grouping_vo diff --git a/clinical-mdr-api/clinical_mdr_api/tests/unit/domain_repositories/test_study_definition_repository_base.py b/clinical-mdr-api/clinical_mdr_api/tests/unit/domain_repositories/test_study_definition_repository_base.py index b34ad8cd..11a45cc0 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/unit/domain_repositories/test_study_definition_repository_base.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/unit/domain_repositories/test_study_definition_repository_base.py @@ -245,7 +245,13 @@ def _retrieve_fields_audit_trail( raise NotImplementedError("Study fields audit trail is not yet mocked.") def _retrieve_study_subpart_with_history( - self, uid: str, is_subpart: bool = False, study_value_version: str | None = None + self, + uid: str, + is_subpart: bool = False, + study_value_version: str | None = None, + page_number: int = 1, + page_size: int = 0, + total_count: bool = False, ): raise NotImplementedError("Study Subpart audit trail is not yet mocked.") diff --git a/clinical-mdr-api/clinical_mdr_api/tests/unit/services/soa_test_data.py b/clinical-mdr-api/clinical_mdr_api/tests/unit/services/soa_test_data.py index db07355e..3f49d6e7 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/unit/services/soa_test_data.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/unit/services/soa_test_data.py @@ -385,7 +385,7 @@ study_duration_weeks=None, visit_number=settings.unscheduled_visit_number, unique_visit_number=settings.unscheduled_visit_number, - visit_short_name=str(settings.unscheduled_visit_number), + visit_short_name=f"V{settings.unscheduled_visit_number}", visit_window_unit_name="days", visit_class=VisitClass.UNSCHEDULED_VISIT, visit_subclass=VisitSubclass.SINGLE_VISIT, @@ -415,7 +415,7 @@ study_duration_weeks=None, visit_number=settings.non_visit_number, unique_visit_number=settings.non_visit_number, - visit_short_name=str(settings.non_visit_number), + visit_short_name=f"V{settings.non_visit_number}", visit_window_unit_name="days", visit_class=VisitClass.NON_VISIT, visit_subclass=VisitSubclass.SINGLE_VISIT, diff --git a/clinical-mdr-api/clinical_mdr_api/tests/unit/services/test_study_objectives.py b/clinical-mdr-api/clinical_mdr_api/tests/unit/services/test_study_objectives.py index e08b97f0..a17803e5 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/unit/services/test_study_objectives.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/unit/services/test_study_objectives.py @@ -7,7 +7,6 @@ EndpointUnits, ) from clinical_mdr_api.models.controlled_terminologies.ct_term import ( - CTTermName, SimpleCodelistTermModel, ) from clinical_mdr_api.models.study_selections.study_selection import ( @@ -36,12 +35,6 @@ submission_value="OBJPRIM", order=1, ) -TERM_PRI_END = CTTermName( - term_uid="C98772_OUTMSPRI", - sponsor_preferred_name="Primary Endpoint", - sponsor_preferred_name_sentence_case="primary endpoint", - queried_effective_date=None, -) TERM_PRI_END = SimpleCodelistTermModel( term_uid="C98772", term_name="Primary Endpoint", @@ -83,8 +76,8 @@ ENDPOINT_4 = Endpoint( uid="Endpoint_000004", - name="
Disease control rate of Actrapid + Empagliflozin cohort
", - name_plain="Disease control rate of Actrapid + Empagliflozin cohort", + name="Disease control rate of Drug A
", + name_plain="Disease control rate of Drug A", ) ENDPOINT_3 = Endpoint( uid="Endpoint_000003", diff --git a/clinical-mdr-api/clinical_mdr_api/tests/unit/utils/test_utils.py b/clinical-mdr-api/clinical_mdr_api/tests/unit/utils/test_utils.py index d88300a7..0210dba0 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/unit/utils/test_utils.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/unit/utils/test_utils.py @@ -138,12 +138,12 @@ def test_camel_case_data(self, input_data, expected): assert utils.camel_case_data(input_data) == expected def test_is_attribute_in_model(self): - class _model(BaseModel): + class _SampleModel(BaseModel): z: str = "something" x: int = 123 - assert utils.is_attribute_in_model("x", _model) - assert not utils.is_attribute_in_model("y", _model) + assert utils.is_attribute_in_model("x", _SampleModel) + assert not utils.is_attribute_in_model("y", _SampleModel) @parameterized.expand( [ diff --git a/clinical-mdr-api/clinical_mdr_api/tests/utils/checks.py b/clinical-mdr-api/clinical_mdr_api/tests/utils/checks.py index 3fcd855b..f17071ed 100644 --- a/clinical-mdr-api/clinical_mdr_api/tests/utils/checks.py +++ b/clinical-mdr-api/clinical_mdr_api/tests/utils/checks.py @@ -47,9 +47,16 @@ def assert_json_response(response: httpx.Response): def parse_json_response( - response: httpx.Response, status: int | Iterable[int] = 200 + response: httpx.Response, *, assert_status: int | Iterable[int] | None = None ) -> Any: - """Shorthand of checking status code and Content-Type header and returning parsed JSON response""" - assert_response_status_code(response, status) + """ + Decode response body as JSON, checking Content-Type header, and optionally checking HTTP status code + + :param response: httpx.Response to parse + :param assert_status: Optional HTTP status code or iterable of codes to assert before parsing + :return: Decoded JSON content of the response + """ + if assert_status is not None: + assert_response_status_code(response, assert_status) assert_json_response(response) return response.json() diff --git a/clinical-mdr-api/clinical_mdr_api/utils/db_integrity_checks.py b/clinical-mdr-api/clinical_mdr_api/utils/db_integrity_checks.py index 0726c0ee..19f7a525 100644 --- a/clinical-mdr-api/clinical_mdr_api/utils/db_integrity_checks.py +++ b/clinical-mdr-api/clinical_mdr_api/utils/db_integrity_checks.py @@ -209,7 +209,7 @@ def build_root_summary_return_statement(root_alias, extra_return=None): "All HAS_VERSION relationships with status LOCKED have a matching HAS_VERSION with status RELEASED", """ MATCH (root:StudyRoot {uid: $study_uid})-[hvl:HAS_VERSION {status: "LOCKED"}]->(value) - WHERE NOT (root)-[:HAS_VERSION {change_description: hvl.change_description, status: "RELEASED"}]->(value) + WHERE NOT (root)-[:HAS_VERSION {status: "RELEASED"}]->(value) """ + build_root_summary_return_statement("root"), ), ( diff --git a/clinical-mdr-api/m11-templates/ICH_Step4_M11_Final_TechnicalSpecification_2025_1119.json b/clinical-mdr-api/m11-templates/ICH_Step4_M11_Final_TechnicalSpecification_2025_1119.json new file mode 100644 index 00000000..616036bf --- /dev/null +++ b/clinical-mdr-api/m11-templates/ICH_Step4_M11_Final_TechnicalSpecification_2025_1119.json @@ -0,0 +1,8055 @@ +[ + { + "page": 4, + "term": "Sponsor Confidentiality Statement:", + "data_type": "Text", + "dvh": "H", + "definition": "Heading", + "user_guidance": "N/A", + "conformance": "Optional", + "cardinality": "One to one", + "relationship": "Title Page", + "value": "Sponsor Confidentiality Statement:", + "business_rules": "Value Allowed: No\nRelationship: Table row heading\nConcept: Heading", + "repeating_reuse_rules": "No" + }, + { + "page": 5, + "term": "