From 4db73ebec7c5a4a26cfc6b4b0b8b2259d27b5cdf Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 10 Jan 2025 20:26:30 -0500 Subject: [PATCH 01/23] fix(deps): Upgrading invenio-rdm-records to match upgraded invenio-app-rdm; pinning pytest-invenio to avoid breaking flask_sqlalchemy api change --- Pipfile | 2 +- Pipfile.lock | 305 +++++++----------- site/kcworks/dependencies/invenio-rdm-records | 2 +- 3 files changed, 119 insertions(+), 190 deletions(-) diff --git a/Pipfile b/Pipfile index 1f08b42e8..108b6bac3 100644 --- a/Pipfile +++ b/Pipfile @@ -44,7 +44,7 @@ isbnlib = "*" langdetect = "*" numpy = "*" pip = "*" -pytest-invenio = "*" +pytest-invenio = {version = "<3.0.0"} python-dotenv = "*" python-iso639 = "*" python-stdnum = "*" diff --git a/Pipfile.lock b/Pipfile.lock index b2d2525ee..cdd45ae89 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8263d569b7616cf7553e89df84cb2c1127d557a7500d3214310e5f9edf15a0ae" + "sha256": "3f353ce55b5e6a75c53b87109558c13f8ab9d3c3e70a723595fb5a67533eea58" }, "pipfile-spec": 6, "requires": { @@ -141,10 +141,10 @@ }, "aniso8601": { "hashes": [ - "sha256:1d2b7ef82963909e93c4f24ce48d4de9e66009a21bf1c1e1c85bdd0812fe412f", - "sha256:72e3117667eedf66951bb2d93f4296a56b94b078a8a95905a052611fb3f1b973" + "sha256:3c943422efaa0229ebd2b0d7d223effb5e7c89e24d2267ebe76c61a2d8e290cb", + "sha256:ff1d0fc2346688c62c0151547136ac30e322896ed8af316ef7602c47da9426cf" ], - "version": "==9.0.1" + "version": "==10.0.0" }, "appdirs": { "hashes": [ @@ -258,19 +258,19 @@ }, "boto3": { "hashes": [ - "sha256:516c514fb447d6f216833d06a0781c003fcf43099a4ca2f5a363a8afe0942070", - "sha256:5aa606239f0fe0dca0506e0ad6bbe4c589048e7e6c2486cee5ec22b6aa7ec2f8" + "sha256:7d398f66a11e67777c189d1f58c0a75d9d60f98d0ee51b8817e828930bf19e4e", + "sha256:8e49416216a6e3a62c2a0c44fba4dd2852c85472e7b702516605b1363867d220" ], "markers": "python_version >= '3.8'", - "version": "==1.35.94" + "version": "==1.35.97" }, "botocore": { "hashes": [ - "sha256:2b3309b356541faa4d88bb957dcac1d8004aa44953c0b7d4521a6cc5d3d5d6ba", - "sha256:d784d944865d8279c79d2301fc09ac28b5221d4e7328fb4e23c642c253b9932c" + "sha256:88f2fab29192ffe2f2115d5bafbbd823ff4b6eb2774296e03ec8b5b0fe074f61", + "sha256:fed4f156b1a9b8ece53738f702ba5851b8c6216b4952de326547f349cc494f14" ], "markers": "python_version >= '3.8'", - "version": "==1.35.94" + "version": "==1.35.97" }, "build": { "hashes": [ @@ -893,11 +893,11 @@ }, "faker": { "hashes": [ - "sha256:2abb551a05b75d268780b6095100a48afc43c53e97422002efbfc1272ebf5f26", - "sha256:ae074d9c7ef65817a93b448141a5531a16b2ea2e563dc5774578197c7c84060c" + "sha256:49dde3b06a5602177bc2ad013149b6f60a290b7154539180d37b6f876ae79b20", + "sha256:ac4cf2f967ce02c898efa50651c43180bd658a7707cfd676fcc5410ad1482c03" ], "markers": "python_version >= '3.8'", - "version": "==33.3.0" + "version": "==33.3.1" }, "fastjsonschema": { "hashes": [ @@ -1342,14 +1342,6 @@ "markers": "python_version >= '3.7'", "version": "==3.1.1" }, - "h11": { - "hashes": [ - "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", - "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" - ], - "markers": "python_version >= '3.7'", - "version": "==0.14.0" - }, "halo": { "hashes": [ "sha256:5350488fb7d2aa7c31a1344120cee67a872901ce8858f60da7946cef96c208ab", @@ -1852,11 +1844,11 @@ }, "invenio-userprofiles": { "hashes": [ - "sha256:20387f0c598e7ed9798df38dc41070df8c7af32452ebbc77016001d92a2cd345", - "sha256:5383a20a8a14cf71fe5132f8fb83bfcc91a49d696c563310f6f6c7ff4f8f79b2" + "sha256:434539bf0801f9b1253943d5cb44f3c5a8c9bf3138507f7cc5fcfa6b7a807b93", + "sha256:e9a8b7c8c17416ce66139704bdca339f559462f48e27dc8bba12ff12022e8188" ], "markers": "python_version >= '3.7'", - "version": "==3.0.1" + "version": "==3.0.2" }, "invenio-users-resources": { "hashes": [ @@ -2329,11 +2321,11 @@ }, "marshmallow": { "hashes": [ - "sha256:ddb5c9987017d37be351c184e4e867e7bf55f7331f4da730dedad6b7af662cdd", - "sha256:efdcb656ac8788f0e3d1d938f8dc0f237bf1a99aff8f6dfbffa594981641cea0" + "sha256:50894cd57c6b097a6c6ed2bf216af47d10146990a54db52d03e32edb0448c905", + "sha256:5ba94a4eb68894ad6761a505eb225daf7e5cb7b4c32af62d4a45e9d42665bc31" ], "markers": "python_version >= '3.9'", - "version": "==3.24.1" + "version": "==3.25.0" }, "marshmallow-oneofschema": { "hashes": [ @@ -2361,78 +2353,88 @@ }, "maxminddb": { "hashes": [ - "sha256:058ca89789bc1770fe58d02a88272ca91dabeef9f3fe0011fe506484355f1804", - "sha256:05e873eb82281cef6e787bd40bd1d58b2e496a21b3689346f0d0420988b3cbb1", - "sha256:0b281c0eec3601dde1f169a1c04e2615751c66368141aded9f03131fe635450b", - "sha256:0b480a31589750da4e36d1ba04b77ee3ac3853ac7b94d63f337b9d4d0403043f", - "sha256:12207f0becf3f2bf14e7a4bf86efcaa6e90d665a918915ae228c4e77792d7151", - "sha256:1c096dfd20926c4de7d7fd5b5e75c756eddd4bdac5ab7aafd4bb67d000b13743", - "sha256:1dc2b511c7255f7cbbb01e8ba01ba82e62e9c1213e382d36f9d9b0ee45c2f6b2", - "sha256:20662878bc9514e90b0b4c4eb1a76622ecc7504d012e76bad9cdb7372fc0ef96", - "sha256:28a2eaf9769262c05c486e777016771f3367c843b053c43cd5fde1108755753d", - "sha256:28af9470f28fce2ccb945478235f53fb52d98a505653b1bf4028e34df6149a06", - "sha256:2aaefb62f881151960bb67e5aeb302c159a32bd2d623cf72dad688bda1020869", - "sha256:34b6e8d667d724f60d52635f3d959f793ab4e5d57d78b27fe66f02752d8c6b08", - "sha256:38941a38278491bf95e5ca544969782c7ab33326802f6a93816867289c3f6401", - "sha256:3987e103396e925edebbef4877e94515822f63b3b436027a0b164b500622fccd", - "sha256:39eab93ddd75fd02f8d5ad6b1bd3f8d894828d91d6f6c1a96bb9e87c34e94aaa", - "sha256:47170ec0e1e76787cc5882301c487f495d67f3146318f2f4e2adc281951a96ef", - "sha256:485c0778f6801e1437c2efd6e3b964a7ae71c8819f063e0b5460c3267d977040", - "sha256:5662386db91872d5505fde9e7bb0b9530b6aab7a6f3ece7df59a2b43a7b45d17", - "sha256:58bfd2c55c96aaaa7c4996c704edabfb1bd369dfc1592cedf8957a24062178b1", - "sha256:5c7c520d06d335b288d06a00b786cea9b7e023bd588efb1a6ef485e94ccc7244", - "sha256:6a50bc348c699d8f6a5f0aa35e5096515d642ca2f38b944bd71c3dedda3d3588", - "sha256:6fd1a612110ff182a559d8010e7615e5d05ef9d2c234b5f7de124ee8fdf1ecb9", - "sha256:7607e45f7eca991fa34d57c03a791a1dfbe774ddd9250d0f35cdcc6f17142a15", - "sha256:78c3aa70c62be68ace23f819e7f23258545f2bfbd92cd6c33ee398cd261f6b84", - "sha256:7c1220838ba9b0bcdaa0c5846f9da70a2304df2ac255fe518370f8faf8c18316", - "sha256:7cd7f525eb2331cf05181c5ba562cc3edec3de4b41dbb18a5fee9ad24884b499", - "sha256:7cfdf5c29a2739610700b9fea7f8d68ce81dcf30bb8016f1a1853ef889a2624b", - "sha256:7d842d32e2620abc894b7d79a5a1007a69df2c6cf279a06b94c9c3913f66f264", - "sha256:7e5a90a1cb0c7fd6226aa44e18a87b26fa85b6eebae36d529d7582f93e8dfbd1", - "sha256:80d20683afe01b4d41bad1c1829f87ab12f3d19c68ec230f83318a2fd13871a7", - "sha256:80d7495565d30260c630afbe74d61522b13dd31ed05b8916003ec5b127109a12", - "sha256:80d7f943f6b8bc437eaae5da778a83d8f38e4b7463756fdee04833e1be0bdea2", - "sha256:8101291e5b92bd272a050c25822a5e30860d453dde16b4fffed9d751f0483a82", - "sha256:826a1858b93b193df7fa71e3caca65c3051db20545df0020444f55c02e8ed2c3", - "sha256:85fc9406f42c1311ce8ea9f2c820db5d7ac687a39ab5d932708dc783607378ef", - "sha256:86048ff328793599e584bcc2fc8278c2b7c5d3a4005c70403613449ec93817ef", - "sha256:886af3ba4aa26214ff39214565f53152b62a5abdb6ef9e00c76c194dbfd79231", - "sha256:8cb992da535264177b380e7b81943c884d57dcbfad6b3335d7f633967144746e", - "sha256:93691c8b4b4c448babb37bedc6f3d51523a3f06ab11bdd171da7ffc4005a7897", - "sha256:96a1fa38322bce1d587bb6ce39a0e6ca4c1b824f48fbc5739a5ec507f63aa889", - "sha256:9a2671e8f4161130803cf226cd9cb8b93ec5c4b2493f83a902986177052d95d3", - "sha256:9dccd7a438f81e3df84dfc31a75af4c8d29adefb6082329385bfde604c9ea01b", - "sha256:a74b60cdc61a69b967ec44201c6259fbc48ef2eab2e885fbdc50ec1accaad545", - "sha256:a771df92e599ad867c16ae4acb08cc3763c9d1028f4ca772c0571da97f7f86d2", - "sha256:aa8cb54b01a29a23a0ea6659fbb38deec6f35453588c5decdbf8669feb53b624", - "sha256:add1e55620033516c5f0734b1d9d03848859192d9f3825aabe720dfa8a783958", - "sha256:b0a3b9cab1a94cc633df3da85c6567f0188f10165e3338ec9a6c421de9fe53b9", - "sha256:b31ecf3083b78c77624783bfdf6177e6ac73ae14684ef182855eb5569bc78e7c", - "sha256:b4d9cd7ddd02ee123a44d0d7821166d31540ea85352deb06b29d55e802f32781", - "sha256:c9e9e893f7c0fa44cfdd5ab819a07d93f63ee398c28b792cedd50b94dcfea7c0", - "sha256:cd4530b9604d66cfa5e37eb94c671e54feff87769f8ba7fa997cce959e0cb241", - "sha256:d0970b661c4fac6624b9128057ed5fe35a2d95aa60359272289cd4c7207c9a6d", - "sha256:d15414d251513748cb646d284a2829a5f4c69d8c90963a6e6da53a1a6d0accf7", - "sha256:d32266792b349f5507b0369d3277d45318fcd346a16dcc98b484aadc208e4d74", - "sha256:d8ccca5327cb4e706f669456ec6d556badfa92c0fdacd57a15076f3cdc061560", - "sha256:dc9f1203eb2b139252aa08965960fe13c36cc8b80b536490b94b05c31aa1fca9", - "sha256:dd90c3798e6c347d48d5d9a9c95dc678b52a5a965f1fb72152067fdf52b994da", - "sha256:e1e40449bd278fdca1f351df442f391e72fd3d98b054ccac1672f27d70210642", - "sha256:e2b85ffc9fb2e192321c2f0b34d0b291b8e82de6e51a6ec7534645663678e835", - "sha256:e63649a82926f1d93acdd3df5f7be66dc9473653350afe73f365bb25e5b34368", - "sha256:e9013076deca5d136c260510cd05e82ec2b4ddb9476d63e2180a13ddfd305c3e", - "sha256:eacd65e38bdf4efdf42bbc15cfa734b09eb818ecfef76b7b36e64be382be4c83", - "sha256:eb534333f5fd7180e35c0207b3d95d621e4b9be3b8c1709995d0feb6c752b6f4", - "sha256:ebf9fdf8a8e55862aabb8b2c34a4af31a8a5b686007288eeb561fa20ef348378", - "sha256:ecce0b2d125691e2311f94dbd564c2d61c36c5033d082919431a21e6c694fa3f", - "sha256:eef1c26210155c7b94c4ca28fef65eb44a5ca1584427b1fbdeec1cd3c81e25c5", - "sha256:f2e326a99eaa924ff2fb09d6e44127983a43016228e7780888f15e9ba171d7b3", - "sha256:f412a54f87ef9083911c334267188d3d1b14f2591eac94b94ca32528f21d5f25", - "sha256:fb38aa94e76a87785b654c035f9f3ee39b74a98e9beea9a10b1aa62abdcc4cbd" + "sha256:01773fee182cc36f6d38c277936accf7c85b8f4c20d13bb630666f6b3f087ad8", + "sha256:01b143a38ae38c71ebc9028d67bbcb05c1b954e0f3a28c508eaee46833807903", + "sha256:0348c8dadef9493dbcd45f032ae271c7fd2216ed4bb4bab0aff371ffc522f871", + "sha256:0b140c1db0c218f485b033b51a086d98d57f55f4a4c2b1cb72fe6a5e1e57359a", + "sha256:0d82fbddf3a88e6aa6181bd16bc08a6939d6353f97f143eeddec16bc5394e361", + "sha256:0dd55d2498d287b6cfd6b857deed9070e53c4b22a1acd69615e88dec92d95fb3", + "sha256:1bc2edcef76ce54d4df04f58aec98f4df0377f37aae2217587bfecd663ed5c66", + "sha256:1f0b78c40a12588e9e0ca0ffe5306b6dea028dcd21f2c120d1ceb328a3307a98", + "sha256:27ba5e22bd09fe324f0a4c5ed97e73c1c7c3ab7e3bae4e1e6fcaa15f175b9f5a", + "sha256:2849357de35bfed0011ad1ff14a83c946273ae8c75a8867612d22f559df70e7d", + "sha256:2b0fef825b23df047876d2056cbb69fb8d8e4b965f744f674be75e16fb86a52e", + "sha256:2d25acb42ef8829e8e3491b6b3b4ced9dbb4eea6c4ec24afdc4028051e7b8803", + "sha256:3015afb00e6168837938dbe5fda40ace37442c22b292ccee27c1690fbf6078ed", + "sha256:34943b4b724a35ef63ec40dcf894a100575d233b23b6cd4f8224017ea1195265", + "sha256:39254e173af7b0018c1508c2dd68ecda0c043032176140cfe917587e2d082f42", + "sha256:415dd5de87adc7640d3da2a8e7cf19a313c1a715cb84a3433f0e3b2d27665319", + "sha256:448d062e95242e3088df85fe7ed3f2890a9f4aea924bde336e9ff5d2337ca5fd", + "sha256:45da7549c952f88da39c9f440cb3fa2abbd7472571597699467641af88512730", + "sha256:46096646c284835c8a580ec2ccbf0d6d5398191531fa543bb0437983c75cb7ba", + "sha256:46bdc8dc528a2f64ef34182bf40084e05410344d40097c1e93554d732dfb0e15", + "sha256:47828bed767b82c219ba7aa65f0cb03d7f7443d7270259ce931e133a40691d34", + "sha256:489c5ae835198a228380b83cc537a5ffb1911f1579d7545baf097e4a8eefcd9a", + "sha256:4b80275603bba6a95ed69d859d184dfa60bfd8e83cd4c8b722d7f7eaa9d95f8f", + "sha256:4d470fc4f9c5ed8854a945dc5ea56b2f0644a5c3e5872d0e579d66a5a9238d7f", + "sha256:4e0865069ef76b4f3eb862c042b107088171cbf43fea3dcaae0dd7253effe6e3", + "sha256:4e7e6d5f3c1aa6350303edab8f0dd471e616d69b5d47ff5ecbf2c7c82998b9c6", + "sha256:536a39fb917a44b1cd037da624e3d11d49898b5579dfc00c4d7103a057dc51ab", + "sha256:5a1586260eac831d61c2665b26ca1ae3ad00caca57c8031346767f4527025311", + "sha256:6136dc8ad8c8f7e95a7d84174a990c1b47d5e641e3a3a8ae67d7bde625342dbb", + "sha256:6480ca47db4d8d09296c268e8ff4e6f4c1d455773a67233c9f899dfa6af3e6c6", + "sha256:6887315de47f3a9840df19f498a4e68723c160c9448d276c3ef454531555778e", + "sha256:6c977da32cc72784980da1928a79d38b3e9fe83faa9a40ea9bae598a6bf2f7bb", + "sha256:6cc002099c9e1637309df772789a36db9a4601c4623dd1ace8145d057358c20b", + "sha256:6eb23f842a72ab3096f9f9b1c292f4feb55a8d758567cb6d77637c2257a3187c", + "sha256:77112cb1a2e381de42c443d1bf222c58b9da203183bb2008dd370c3d2a587a4e", + "sha256:7c3209d7a4b2f50d4b28a1d886d95b19094cdc840208e69dbbc40cae2c1cc65b", + "sha256:7c5d15a0546821a7e9104b71ca701c01462390d0a1bee5cad75f583cf26c400b", + "sha256:7d6024d1e40244b5549c5e6063af109399a2f89503a24916b5139c4d0657f1c8", + "sha256:81340e52c743cdc3c0f4a9f45f9cf4e3c2ae87bf4bbb34613c5059a5b829eb65", + "sha256:83d2324788a31a28bbb38b0dbdece5826f56db4df6e1538cf6f4b72f6a3da66c", + "sha256:85763c19246dce43044be58cb9119579c2efd0b85a7b79d865b741a698866488", + "sha256:8868580f34b483d5b74edd4270db417e211906d57fb13bbeeb11ea8d5cd01829", + "sha256:890dd845e371f67edef7b19a2866191d9fff85faf88f4b4c416a0aaa37204416", + "sha256:89afed255ac3652db7f91d8f6b278a4c490c47283ddbff5589c22cfdef4b8453", + "sha256:8ec674a2c2e4b47ab9f582460670a5c1d7725b1cbf16e6cbb94de1ae51ee9edf", + "sha256:9580b2cd017185db07baacd9d629ca01f3fe6f236528681c88a0209725376e9c", + "sha256:98258a295149aadf96ed8d667468722b248fe47bb991891ad01cfa8cb9e9684a", + "sha256:9d913971187326e59be8a63068128b6439f6717b13c7c451e6d9e1723286d9ff", + "sha256:a23c7c88f9df0727a3e56f2385ec19fb5f61bb46dcbebb6ddc5c948cf0b73b0a", + "sha256:a38faf03db15cc285009c0ddaacd04071b84ebd8ff7d773f700c7def695a291c", + "sha256:a59d72bf373c61da156fd43e2be6da802f68370a50a2205de84ee76916e05f9f", + "sha256:a6597599cde3916730d69b023045e6c22ff1c076d9cad7fb63641d36d01e3e93", + "sha256:a6868438d1771c0bd0bbc95d84480c1ae04df72a85879e1ada42762250a00f59", + "sha256:a70d46337c9497a5b3329d9c7fa7f45be33243ffad04924b8f06ffe41a136279", + "sha256:a9ebd373a4ef69218bfbce93e9b97f583cfe681b28d4e32e0d64f76ded148fba", + "sha256:aadb9d12e887a1f52e8214e539e5d78338356fad4ef2a51931f6f7dbe56c2228", + "sha256:acf46e20709a27d2b519669888e3f53a37bc4204b98a0c690664c48ff8cb1364", + "sha256:b09bb7bb98418a620b1ec1881d1594c02e715a68cdc925781de1e79b39cefe77", + "sha256:b29cea50b191784e2242227e0fac5bc985972b3849f97fe96c7f37fb7a7426d7", + "sha256:b4729936fedb4793d9162b92d6de63e267e388c8938e19e700120b6df6a6ae6c", + "sha256:d2c3806baa7aa047aa1bac7419e7e353db435f88f09d51106a84dbacf645d254", + "sha256:d4bac2b7b7609bed8dcf6beef1ef4a1e411e9e39c311070ffc2ace80d6de6444", + "sha256:d69c5493c81f11bca90961b4dfa028c031aa8e7bb156653edf242a03dfc51561", + "sha256:d78a02b70ededb3ba7317c24266217d7b68283e3be04cad0c34ee446a0217ee0", + "sha256:da584edc3e4465f5417a48602ed7e2bee4f2a7a2b43fcf2c40728cfc9f9fd5aa", + "sha256:daa20961ad0fb550038c02dbf76a04e1c1958a3b899fa14a7c412aed67380812", + "sha256:de8415538d778ae4f4bb40e2cee9581e2d5c860abdbdbba1458953f5b314a6b0", + "sha256:deebf098c79ce031069fec1d7202cba0e766b3f12adbb631d16223174994724a", + "sha256:e28622fd7c4ccd298c3f630161d0801182eb38038ca01319693a70264de40b89", + "sha256:e38a449890a976365da1f2c927ac076838aa2715b464593080075a18ae4e0dc8", + "sha256:e441478922c2d311b8bc96f35d6e78306802774149fc20d07d96cc5c3b57dd02", + "sha256:e5a8cfe71db548aa9a520a3f7e92430b6b7900affadef3b0c83c530c759dd12f", + "sha256:e867852037a8a26a24cfcf31b697dce63d488e1617af244c2895568d8f6c7a31", + "sha256:edab18a50470031fc8447bcd9285c9f5f952abef2b6db5579fe50665bdcda941", + "sha256:ef41bfe15692fe15e1799d600366a0faa3673a0d7d7dbe6a305ec3a5b6f07708", + "sha256:efd875d43c4207fb90e10d582e4394d8a04f7b55c83c4d6bc0593a7be450e04f", + "sha256:f06e9c908a9270e882f0d23f041a9674680a7a110412b453f902d22323f86d38", + "sha256:f49eefddad781e088969188c606b7988a7da27592590f6c4cc2b64fd2a85ff28", + "sha256:fa36f1ca12fd3a37ad758afd0666457a749b2c4b16db0eb3f8c953f55ae6325d" ], "markers": "python_version >= '3.8'", - "version": "==2.6.2" + "version": "==2.6.3" }, "maxminddb-geolite2": { "hashes": [ @@ -2793,14 +2795,6 @@ "markers": "python_version >= '3.8'", "version": "==5.2.3" }, - "outcome": { - "hashes": [ - "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", - "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b" - ], - "markers": "python_version >= '3.7'", - "version": "==1.3.0.post0" - }, "packaging": { "hashes": [ "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", @@ -3277,22 +3271,13 @@ "markers": "python_version >= '3.7'", "version": "==1.2.0" }, - "pysocks": { - "hashes": [ - "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299", - "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", - "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.7.1" - }, "pytest": { "hashes": [ - "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", - "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8" + "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7", + "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39" ], "markers": "python_version >= '3.7'", - "version": "==7.4.4" + "version": "==7.1.3" }, "pytest-cov": { "hashes": [ @@ -3320,12 +3305,11 @@ }, "pytest-invenio": { "hashes": [ - "sha256:8ffe0bedf4b5af95a1d6561b394eb74df5bdbe7818e30a2203e86e0e70e40b70", - "sha256:e4ab188c6780df0a740b6d88496f13e2fd51e02f475bd4c00d100f3866707a25" + "sha256:8c40a370f8fb02087a4c1d031f2c23c54d8a1481be5e783532eb823c826a6ec2", + "sha256:b759a791c94fc79d811ad74acf795fa3b5b1293a0cd852527d537d7d40f9a180" ], - "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==3.0.0" + "version": "==2.2.1" }, "pytest-isort": { "hashes": [ @@ -3870,12 +3854,11 @@ }, "selenium": { "hashes": [ - "sha256:5296c425a75ff1b44d0d5199042b36a6d1ef76c04fb775b97b40be739a9caae2", - "sha256:b89b1f62b5cfe8025868556fe82360d6b649d464f75d2655cb966c8f8447ea18" + "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c", + "sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==4.27.1" + "version": "==3.141.0" }, "sentry-sdk": { "extras": [ @@ -3889,11 +3872,11 @@ }, "setuptools": { "hashes": [ - "sha256:84fb203f278ebcf5cd08f97d3fb96d3fbed4b629d500b29ad60d11e00769b183", - "sha256:886ff7b16cd342f1d1defc16fc98c9ce3fde69e087a4e1983d7ab634e5f41f4f" + "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", + "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3" ], "markers": "python_version >= '3.9'", - "version": "==75.7.0" + "version": "==75.8.0" }, "simplejson": { "hashes": [ @@ -4027,14 +4010,6 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.17.0" }, - "sniffio": { - "hashes": [ - "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", - "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" - ], - "markers": "python_version >= '3.7'", - "version": "==1.3.1" - }, "snowballstemmer": { "hashes": [ "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", @@ -4042,13 +4017,6 @@ ], "version": "==2.2.0" }, - "sortedcontainers": { - "hashes": [ - "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", - "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0" - ], - "version": "==2.4.0" - }, "soupsieve": { "hashes": [ "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", @@ -4334,22 +4302,6 @@ "markers": "python_version >= '3.8'", "version": "==5.14.3" }, - "trio": { - "hashes": [ - "sha256:4e547896fe9e8a5658e54e4c7c5fa1db748cbbbaa7c965e7d40505b928c73c05", - "sha256:56d58977acc1635735a96581ec70513cc781b8b6decd299c487d3be2a721cd94" - ], - "markers": "python_version >= '3.9'", - "version": "==0.28.0" - }, - "trio-websocket": { - "hashes": [ - "sha256:18c11793647703c158b1f6e62de638acada927344d534e3c7628eedcb746839f", - "sha256:520d046b0d030cf970b8b2b2e00c4c2245b3807853ecd44214acd33d74581638" - ], - "markers": "python_version >= '3.7'", - "version": "==0.11.1" - }, "types-beautifulsoup4": { "hashes": [ "sha256:158370d08d0cd448bd11b132a50ff5279237a5d4b5837beba074de152a513059", @@ -4485,9 +4437,6 @@ "version": "==4.0.3" }, "urllib3": { - "extras": [ - "socks" - ], "hashes": [ "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32" @@ -4604,14 +4553,6 @@ ], "version": "==0.5.1" }, - "websocket-client": { - "hashes": [ - "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", - "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da" - ], - "markers": "python_version >= '3.8'", - "version": "==1.8.0" - }, "werkzeug": { "hashes": [ "sha256:2e1ccc9417d4da358b9de6f174e3ac094391ea1d4fbef2d667865d819dfd0afe", @@ -4691,14 +4632,6 @@ "markers": "python_version >= '3.8'", "version": "==1.17.0" }, - "wsproto": { - "hashes": [ - "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", - "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==1.2.0" - }, "wtforms": { "hashes": [ "sha256:bf831c042829c8cdbad74c27575098d541d039b1faa74c771545ecac916f2c07", @@ -5341,20 +5274,19 @@ }, "selenium": { "hashes": [ - "sha256:5296c425a75ff1b44d0d5199042b36a6d1ef76c04fb775b97b40be739a9caae2", - "sha256:b89b1f62b5cfe8025868556fe82360d6b649d464f75d2655cb966c8f8447ea18" + "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c", + "sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d" ], "index": "pypi", - "markers": "python_version >= '3.8'", - "version": "==4.27.1" + "version": "==3.141.0" }, "setuptools": { "hashes": [ - "sha256:84fb203f278ebcf5cd08f97d3fb96d3fbed4b629d500b29ad60d11e00769b183", - "sha256:886ff7b16cd342f1d1defc16fc98c9ce3fde69e087a4e1983d7ab634e5f41f4f" + "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", + "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3" ], "markers": "python_version >= '3.9'", - "version": "==75.7.0" + "version": "==75.8.0" }, "six": { "hashes": [ @@ -5536,9 +5468,6 @@ "version": "==4.12.2" }, "urllib3": { - "extras": [ - "socks" - ], "hashes": [ "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32" diff --git a/site/kcworks/dependencies/invenio-rdm-records b/site/kcworks/dependencies/invenio-rdm-records index d57a5d466..d0de681c5 160000 --- a/site/kcworks/dependencies/invenio-rdm-records +++ b/site/kcworks/dependencies/invenio-rdm-records @@ -1 +1 @@ -Subproject commit d57a5d466ff834f6fa628aab8f9b560e48ea9c18 +Subproject commit d0de681c5f615002b7a560b4091676c813c1010c From 5a5d3269a76a9b5c5d0cbb654580a44c846d9c15 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 10 Jan 2025 20:27:10 -0500 Subject: [PATCH 02/23] fix(cli): Minor import fix in users service cli --- site/kcworks/services/users/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/kcworks/services/users/cli.py b/site/kcworks/services/users/cli.py index c91692dfb..946df52ef 100644 --- a/site/kcworks/services/users/cli.py +++ b/site/kcworks/services/users/cli.py @@ -1,5 +1,5 @@ import click -from .service import UserProfileService +from kcworks.services.users.service import UserProfileService from flask.cli import with_appcontext from pprint import pprint From 2b4e5fc949e8cdd2a3b1a3df7961422737728324 Mon Sep 17 00:00:00 2001 From: Bonnie Russell <11507407+bjr70@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:28:57 -0500 Subject: [PATCH 03/23] Update metadata.md Updated the resource types section Signed-off-by: Bonnie Russell <11507407+bjr70@users.noreply.github.com> --- docs/source/metadata.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/source/metadata.md b/docs/source/metadata.md index f79aa64de..902b0ff13 100644 --- a/docs/source/metadata.md +++ b/docs/source/metadata.md @@ -267,6 +267,12 @@ The FAST vocabulary is augmented in KCWorks by the Homosaurus vocabulary (https: #### Resource types +Prior to the start of development on the KCWorks repository we did a deep dive into the metadata structures and resource types supported by the other large scholarly repositories, including Dryad, arXiv, and Zenodo. As technology has changed scholarship has taken on more forms, including virtual reality, podcasts, and even role-playing and video games. The InvenioRDM platform gave us the opportunity to customize our metadata further by resource type (as defined by the Datacite schema). As an open repository that serves a multidisciplinary audience we created a list that combines the resource types from multiple sources. + +We have broken down resource types over eight categories, including support for various art objects, multimedia files, online course files, virtual reality, and software. Each category - Dataset, Image, Instructional Resource, Presentation, Publication, Software, Audiovisual, and Other - has within it specific metadata fields that provide the ability to fully describe the object. + +The eight top resource types and many of the subtypes are derived from the list in [the Datacite schema under resourceTypeGeneral](https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/resourceTypeGeneral/).The rest derived from our original resource types in the CORE repository. Each top level category - Dataset, Image, Instructional Resource, Presentation, Publication, Software, Audiovisual, and Other - has within it specific metadata fields that provide the ability to fully describe the object. As we mapped our existing resource types we had to add two custom types to support legal scholars: Legal Comment and Legal Response, which support the Michigan State University School of Law and their deposits of legal scholarship. Also included were some types taken from the [Variations Metadata](https://dlib.indiana.edu/projects/variations3/metadata/guide/controlVocabs/contributorRoles.html) list, providing the ability to credit those who engage in creative and musical works. + #### Creator/contributor roles ## Identifier Schemes From 706cb7132810e3d78fb0a5eccf7fa97068080a2e Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Tue, 14 Jan 2025 14:56:31 -0500 Subject: [PATCH 04/23] fix(deps, testing): Dependency updates, including adding pytest-mock for testing --- Pipfile | 2 + Pipfile.lock | 203 +++++++++++++++++++++++++++++++-------------------- 2 files changed, 126 insertions(+), 79 deletions(-) diff --git a/Pipfile b/Pipfile index 108b6bac3..9a073cbb7 100644 --- a/Pipfile +++ b/Pipfile @@ -13,6 +13,7 @@ docker-services-cli = "*" sphinx = "*" myst-parser = "*" furo = "*" +pytest-mock = "*" [packages] aiohttp = "*" @@ -59,6 +60,7 @@ uwsgi-tools = ">=1.1.1" uwsgitop = ">=0.11" xmlsec = "<1.3.14" kcworks = {file = "site", editable = true} +flask-iiif = "*" [requires] python_version = "3.9" diff --git a/Pipfile.lock b/Pipfile.lock index cdd45ae89..249a492f5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "3f353ce55b5e6a75c53b87109558c13f8ab9d3c3e70a723595fb5a67533eea58" + "sha256": "c6ac845f78bdb4d19625ede9f784dac43cd4fd261c4188581fcf077c84c64d75" }, "pipfile-spec": 6, "requires": { @@ -994,11 +994,12 @@ }, "flask-iiif": { "hashes": [ - "sha256:8f9df5320811cc2e5903b6748029f0bd214b9852eeb69777e8e94416398db451", - "sha256:a8651407df950efff18f5779afca644d142cd2e0c3439432ef5d4fb0bc7f12d4" + "sha256:5b11b93bbb91d7a6e9f7eacb6291979f04161edb9bc8025bdaab52f301f3b367", + "sha256:8f288697d0eb747652bd9420b49586f2a77714f7805535a5cd2ba62484503344" ], + "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.6.3" + "version": "==1.2.0" }, "flask-kvsession-invenio": { "hashes": [ @@ -1473,11 +1474,11 @@ "opensearch2" ], "hashes": [ - "sha256:3f4e23371981b98f0bc082a80e135cfa6e98c9ad734ae729dd8f8f849c0ffc6f", - "sha256:636b83322756d7e90eba024fd794b413df7a5e8c0cd17052e411bdeb8b8f7d72" + "sha256:56f5791bf2e12cc0ae44e84f4b98b940b2825d298e1d89b8dddf252d921b1caa", + "sha256:e7a709f5a4ffdbdfb34d78cc8bd3a2df80bb07f346639366b2a0fc80187d0569" ], "markers": "python_version >= '3.7'", - "version": "==12.0.8" + "version": "==12.0.12" }, "invenio-assets": { "hashes": [ @@ -1613,9 +1614,6 @@ "version": "==1.1.5" }, "invenio-logging": { - "extras": [ - "sentry-sdk" - ], "hashes": [ "sha256:0e8bbb6a3fe862ac9b3c2ec2d12e5589b836d51b4139d87e41ee538801e6d89c", "sha256:71e0eb80488955a6d7a569074da38de5586e9c4b57a4e6e6de94a25834953026" @@ -2321,11 +2319,11 @@ }, "marshmallow": { "hashes": [ - "sha256:50894cd57c6b097a6c6ed2bf216af47d10146990a54db52d03e32edb0448c905", - "sha256:5ba94a4eb68894ad6761a505eb225daf7e5cb7b4c32af62d4a45e9d42665bc31" + "sha256:ec5d00d873ce473b7f2ffcb7104286a376c354cab0c2fa12f5573dab03e87210", + "sha256:f4debda3bb11153d81ac34b0d582bf23053055ee11e791b54b4b35493468040a" ], "markers": "python_version >= '3.9'", - "version": "==3.25.0" + "version": "==3.25.1" }, "marshmallow-oneofschema": { "hashes": [ @@ -4563,74 +4561,88 @@ }, "wrapt": { "hashes": [ - "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d", - "sha256:0698d3a86f68abc894d537887b9bbf84d29bcfbc759e23f4644be27acf6da301", - "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635", - "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a", - "sha256:0f2a28eb35cf99d5f5bd12f5dd44a0f41d206db226535b37b0c60e9da162c3ed", - "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721", - "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801", - "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b", - "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1", - "sha256:2399408ac33ffd5b200480ee858baa58d77dd30e0dd0cab6a8a9547135f30a88", - "sha256:2a0c23b8319848426f305f9cb0c98a6e32ee68a36264f45948ccf8e7d2b941f8", - "sha256:2dfb7cff84e72e7bf975b06b4989477873dcf160b2fd89959c629535df53d4e0", - "sha256:2f495b6754358979379f84534f8dd7a43ff8cff2558dcdea4a148a6e713a758f", - "sha256:33539c6f5b96cf0b1105a0ff4cf5db9332e773bb521cc804a90e58dc49b10578", - "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7", - "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045", - "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada", - "sha256:4e4b4385363de9052dac1a67bfb535c376f3d19c238b5f36bddc95efae15e12d", - "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b", - "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a", - "sha256:4f763a29ee6a20c529496a20a7bcb16a73de27f5da6a843249c7047daf135977", - "sha256:5ae271862b2142f4bc687bdbfcc942e2473a89999a54231aa1c2c676e28f29ea", - "sha256:5d8fd17635b262448ab8f99230fe4dac991af1dabdbb92f7a70a6afac8a7e346", - "sha256:69c40d4655e078ede067a7095544bcec5a963566e17503e75a3a3e0fe2803b13", - "sha256:69d093792dc34a9c4c8a70e4973a3361c7a7578e9cd86961b2bbf38ca71e4e22", - "sha256:6a9653131bda68a1f029c52157fd81e11f07d485df55410401f745007bd6d339", - "sha256:6ff02a91c4fc9b6a94e1c9c20f62ea06a7e375f42fe57587f004d1078ac86ca9", - "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181", - "sha256:7264cbb4a18dc4acfd73b63e4bcfec9c9802614572025bdd44d0721983fc1d9c", - "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90", - "sha256:74bf625b1b4caaa7bad51d9003f8b07a468a704e0644a700e936c357c17dd45a", - "sha256:81b1289e99cf4bad07c23393ab447e5e96db0ab50974a280f7954b071d41b489", - "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f", - "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504", - "sha256:879591c2b5ab0a7184258274c42a126b74a2c3d5a329df16d69f9cee07bba6ea", - "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569", - "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4", - "sha256:8f8909cdb9f1b237786c09a810e24ee5e15ef17019f7cecb207ce205b9b5fcce", - "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab", - "sha256:92a3d214d5e53cb1db8b015f30d544bc9d3f7179a05feb8f16df713cecc2620a", - "sha256:948a9bd0fb2c5120457b07e59c8d7210cbc8703243225dbd78f4dfc13c8d2d1f", - "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c", - "sha256:9f2939cd4a2a52ca32bc0b359015718472d7f6de870760342e7ba295be9ebaf9", - "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf", - "sha256:a8fc931382e56627ec4acb01e09ce66e5c03c384ca52606111cee50d931a342d", - "sha256:ad47b095f0bdc5585bced35bd088cbfe4177236c7df9984b3cc46b391cc60627", - "sha256:b1ca5f060e205f72bec57faae5bd817a1560fcfc4af03f414b08fa29106b7e2d", - "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4", - "sha256:baa7ef4e0886a6f482e00d1d5bcd37c201b383f1d314643dfb0367169f94f04c", - "sha256:bb90765dd91aed05b53cd7a87bd7f5c188fcd95960914bae0d32c5e7f899719d", - "sha256:bc7f729a72b16ee21795a943f85c6244971724819819a41ddbaeb691b2dd85ad", - "sha256:bdf62d25234290db1837875d4dceb2151e4ea7f9fff2ed41c0fde23ed542eb5b", - "sha256:c30970bdee1cad6a8da2044febd824ef6dc4cc0b19e39af3085c763fdec7de33", - "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371", - "sha256:d751300b94e35b6016d4b1e7d0e7bbc3b5e1751e2405ef908316c2a9024008a1", - "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393", - "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106", - "sha256:e185ec6060e301a7e5f8461c86fb3640a7beb1a0f0208ffde7a65ec4074931df", - "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379", - "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451", - "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b", - "sha256:f28b29dc158ca5d6ac396c8e0a2ef45c4e97bb7e65522bfc04c989e6fe814575", - "sha256:f335579a1b485c834849e9075191c9898e0731af45705c2ebf70e0cd5d58beed", - "sha256:fce6fee67c318fdfb7f285c29a82d84782ae2579c0e1b385b7f36c6e8074fffb", - "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838" + "sha256:08e7ce672e35efa54c5024936e559469436f8b8096253404faeb54d2a878416f", + "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c", + "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", + "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", + "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", + "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c", + "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", + "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", + "sha256:1e1fe0e6ab7775fd842bc39e86f6dcfc4507ab0ffe206093e76d61cde37225c8", + "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", + "sha256:2696993ee1eebd20b8e4ee4356483c4cb696066ddc24bd70bcbb80fa56ff9061", + "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", + "sha256:36ccae62f64235cf8ddb682073a60519426fdd4725524ae38874adf72b5f2aeb", + "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62", + "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984", + "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", + "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2", + "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", + "sha256:410a92fefd2e0e10d26210e1dfb4a876ddaf8439ef60d6434f21ef8d87efc5b7", + "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", + "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", + "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", + "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", + "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", + "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", + "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", + "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563", + "sha256:58455b79ec2661c3600e65c0a716955adc2410f7383755d537584b0de41b1d8a", + "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f", + "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", + "sha256:5c803c401ea1c1c18de70a06a6f79fcc9c5acfc79133e9869e730ad7f8ad8ef9", + "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", + "sha256:612dff5db80beef9e649c6d803a8d50c409082f1fedc9dbcdfde2983b2025b82", + "sha256:62c2caa1585c82b3f7a7ab56afef7b3602021d6da34fbc1cf234ff139fed3cd9", + "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", + "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", + "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", + "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", + "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", + "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7", + "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", + "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", + "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", + "sha256:91bd7d1773e64019f9288b7a5101f3ae50d3d8e6b1de7edee9c2ccc1d32f0c0a", + "sha256:95c658736ec15602da0ed73f312d410117723914a5c91a14ee4cdd72f1d790b3", + "sha256:99039fa9e6306880572915728d7f6c24a86ec57b0a83f6b2491e1d8ab0235b9a", + "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", + "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", + "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", + "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", + "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", + "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", + "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", + "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", + "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", + "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", + "sha256:b4e42a40a5e164cbfdb7b386c966a588b1047558a990981ace551ed7e12ca9c2", + "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22", + "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72", + "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061", + "sha256:ba0f0eb61ef00ea10e00eb53a9129501f52385c44853dbd6c4ad3f403603083f", + "sha256:bb87745b2e6dc56361bfde481d5a378dc314b252a98d7dd19a651a3fa58f24a9", + "sha256:bb90fb8bda722a1b9d48ac1e6c38f923ea757b3baf8ebd0c82e09c5c1a0e7a04", + "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", + "sha256:c86563182421896d73858e08e1db93afdd2b947a70064b813d515d66549e15f9", + "sha256:c958bcfd59bacc2d0249dcfe575e71da54f9dcf4a8bdf89c4cb9a68a1170d73f", + "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", + "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", + "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", + "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", + "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", + "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", + "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", + "sha256:ecc840861360ba9d176d413a5489b9a0aff6d6303d7e733e2c4623cfa26904a6", + "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", + "sha256:f393cda562f79828f38a819f4788641ac7c4085f30f1ce1a68672baa686482bb", + "sha256:f917c1180fdb8623c2b75a99192f4025e412597c50b2ac870f156de8fb101119", + "sha256:fc78a84e2dfbc27afe4b2bd7c80c8db9bca75cc5b85df52bfe634596a1da846b", + "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58" ], "markers": "python_version >= '3.8'", - "version": "==1.17.0" + "version": "==1.17.2" }, "wtforms": { "hashes": [ @@ -5032,6 +5044,14 @@ "markers": "python_version >= '3.8'", "version": "==7.2.1" }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, "jinja2": { "hashes": [ "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", @@ -5171,6 +5191,14 @@ "markers": "python_version >= '3.8'", "version": "==24.2" }, + "pluggy": { + "hashes": [ + "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", + "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" + ], + "markers": "python_version >= '3.8'", + "version": "==1.5.0" + }, "pygments": { "hashes": [ "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", @@ -5196,6 +5224,23 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.7.1" }, + "pytest": { + "hashes": [ + "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", + "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761" + ], + "markers": "python_version >= '3.8'", + "version": "==8.3.4" + }, + "pytest-mock": { + "hashes": [ + "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", + "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==3.14.0" + }, "pyyaml": { "hashes": [ "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff", From b8c6ead9c7cad68d5fdcf9646560a16ce9cadb21 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Tue, 14 Jan 2025 14:57:23 -0500 Subject: [PATCH 05/23] fix(notifications): Minor tweak to email template for new comments --- .../invenio_notifications/comment-request-event.create.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/kcworks/templates/semantic-ui/invenio_notifications/comment-request-event.create.jinja b/site/kcworks/templates/semantic-ui/invenio_notifications/comment-request-event.create.jinja index 22be2dc65..58eabae30 100644 --- a/site/kcworks/templates/semantic-ui/invenio_notifications/comment-request-event.create.jinja +++ b/site/kcworks/templates/semantic-ui/invenio_notifications/comment-request-event.create.jinja @@ -18,7 +18,7 @@ %} {%- block subject -%} -{{ _("KCWorks: New comment on '{request_title}'").format(request_title=request_title) }} +{{ _("KCWorks | New comment on '{request_title}'").format(request_title=request_title) }} {%- endblock subject -%} {%- block html_body -%} From bb1f3a31fc74e062b5f05bce7da8d64100d80743 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Tue, 14 Jan 2025 14:59:22 -0500 Subject: [PATCH 06/23] fix(testing): Updates to notification integration tests--now all passing using async celery --- site/tests/api/test_api_notifications.py | 157 +++++++++++++---------- site/tests/conftest.py | 40 +++++- 2 files changed, 123 insertions(+), 74 deletions(-) diff --git a/site/tests/api/test_api_notifications.py b/site/tests/api/test_api_notifications.py index b1dfb3338..7b16a57e2 100644 --- a/site/tests/api/test_api_notifications.py +++ b/site/tests/api/test_api_notifications.py @@ -1,4 +1,5 @@ import pytest +from celery import shared_task from flask_security import current_user from flask_security.utils import login_user, logout_user import json @@ -23,10 +24,42 @@ from invenio_users_resources.records.api import UserAggregate from invenio_users_resources.services.users.tasks import reindex_users from kcworks.proxies import current_internal_notifications +from pprint import pformat +from typing import Optional + + +@shared_task(bind=True) +def mock_send_remote_api_update( + self, + identity_id: str = "", + record: dict = {}, + is_published: bool = False, + is_draft: bool = False, + is_deleted: bool = False, + parent: Optional[dict] = None, + latest_version_index: Optional[int] = None, + latest_version_id: Optional[str] = None, + current_version_index: Optional[int] = None, + draft: Optional[dict] = None, + endpoint: str = "", + service_type: str = "", + service_method: str = "", + **kwargs, +): + pass + + +@pytest.fixture +def mock_send_remote_api_update_fixture(mocker): + mocker.patch( + "invenio_remote_api_provisioner.components.send_remote_api_update", # noqa: E501 + mock_send_remote_api_update, + ) def test_notify_for_request_acceptance( running_app, + appctx, db, user_factory, minimal_community_factory, @@ -37,9 +70,21 @@ def test_notify_for_request_acceptance( search_clear, admin, mailbox, + celery_worker, + mocker, + mock_send_remote_api_update_fixture, ): """ - Test that the user is notified when a request is accepted. + Test that the user is notified when a collection submission is accepted. + + This integration test uses actual requests and events, and uses a live + asynchronous celery worker to send the notifications. Mail actually passes + through the normal mail sending process, so we can test the templates. But + the mail is intercepted by the `mailbox` fixture before it is actually + sent. + + We patch the `send_remote_api_update` method to avoid actually sending + the search provisioning api message during record publication. """ app = running_app.app admin_id = admin.user.id @@ -77,6 +122,7 @@ def test_notify_for_request_acceptance( # check that the record was created *and* indexed read_draft = records_service.read_draft(system_identity, draft_id) assert read_draft.id == draft_id + app.logger.debug(f"read_draft: {pformat(read_draft)}") indexed_record = records_service.search_drafts( system_identity, @@ -194,25 +240,21 @@ def test_notify_for_request_acceptance( assert mailbox[0].recipients == [user.email] assert mailbox[0].sender == app.config.get("MAIL_DEFAULT_SENDER") assert "accepted" in mailbox[0].subject - # assert ( - # mailbox[0].subject - # == "KCWorks | Collection submission accepted for 'A Romans - # ) + assert ( + mailbox[0].subject + == "KCWorks | Collection submission accepted for 'A Romans story'" + ) # TODO: Test overridden templates assert mailbox[1].recipients == [user.email] assert mailbox[1].sender == app.config.get("MAIL_DEFAULT_SENDER") assert "comment" in mailbox[1].subject - # assert ( - # mailbox[1].subject - # == "KCWorks | New comment on your collection submission for - # ) + assert mailbox[1].subject == "KCWorks | New comment on 'A Romans story'" # by internal notification submitter = current_accounts.datastore.get_user_by_id(user.id) - assert json.loads( - submitter.user_profile.get("unread_notifications") - ) == [ + unread_json = submitter.user_profile.get("unread_notifications") + assert json.loads(unread_json) == [ { "notification_type": "comment-request-event.create", "request_id": request_id, @@ -235,10 +277,23 @@ def test_notify_for_request_decline( search_clear, admin, mailbox, + mocker, + celery_worker, + mock_send_remote_api_update_fixture, ): """ Test that the user is notified when a request is declined. + + This integration test uses actual requests and events, and uses a live + asynchronous celery worker to send the notifications. Mail actually passes + through the normal mail sending process, so we can test the templates. But + the mail is intercepted by the `mailbox` fixture before it is actually + sent. + + We patch the `send_remote_api_update` method to avoid actually sending + the search provisioning api message during record publication. """ + app = running_app.app admin_id = admin.user.id community = minimal_community_factory(owner=admin_id) @@ -408,9 +463,7 @@ def test_notify_for_request_decline( # ) # by internal notification - assert json.loads( - submitter.user_profile.get("unread_notifications") - ) == [ + assert json.loads(submitter.user_profile.get("unread_notifications")) == [ { "request_id": request_id, "notification_type": "comment-request-event.create", @@ -434,6 +487,8 @@ def test_notify_for_request_cancellation( search_clear, admin, mailbox, + celery_worker, + mock_send_remote_api_update_fixture, ): """ Test that the user is notified when a request is cancelled. @@ -588,9 +643,7 @@ def test_notify_for_request_cancellation( assert json.loads(unread_notifications) == [] reviewer = current_accounts.datastore.get_user_by_id(admin_id) - assert json.loads( - reviewer.user_profile.get("unread_notifications") - ) == [ + assert json.loads(reviewer.user_profile.get("unread_notifications")) == [ { "request_id": request_id, "notification_type": "community-submission.cancel", @@ -612,6 +665,8 @@ def test_notify_for_new_request_comment( search_clear, admin, mailbox, + celery_worker, + mock_send_remote_api_update_fixture, ): """ Test that the user is notified when a new comment is added @@ -751,13 +806,9 @@ def test_notify_for_new_request_comment( assert response._record.request_id == request_id comment_data = response.to_dict() assert comment_data.get("created_by").get("user") == str(admin_id) - assert ( - comment_data.get("payload").get("content") == "I have a question." - ) + assert comment_data.get("payload").get("content") == "I have a question." assert comment_data.get("payload").get("format") == "html" - assert ( - comment_data.get("links").get("self").split("/")[-3] == request_id - ) + assert comment_data.get("links").get("self").split("/")[-3] == request_id assert comment_data.get("type") == "C" assert comment_data.get("revision_id") == 1 comment_id = comment_data.get("id") @@ -858,9 +909,7 @@ def test_read_unread_notifications_by_service( identity = get_identity(user) identity.provides.add(SystemRoleNeed("any_user")) - unread_notifications = current_internal_notifications.read_unread( - identity, user.id - ) + unread_notifications = current_internal_notifications.read_unread(identity, user.id) assert len(unread_notifications) == 2 assert unread_notifications == [ { @@ -934,10 +983,7 @@ def test_clear_unread_notifications_by_service( db.session.commit() # check that the user has unread notifications - assert ( - len(json.loads(user.user_profile.get("unread_notifications", "[]"))) - == 2 - ) + assert len(json.loads(user.user_profile.get("unread_notifications", "[]"))) == 2 # clear the unread notifications via service identity = get_identity(user) @@ -948,9 +994,7 @@ def test_clear_unread_notifications_by_service( # check that the user has no unread notifications final_user = current_accounts.datastore.get_user_by_id(user.id) - assert ( - json.loads(final_user.user_profile.get("unread_notifications")) == [] - ) + assert json.loads(final_user.user_profile.get("unread_notifications")) == [] # now try to clear someone else's notifications with pytest.raises(PermissionDeniedError): @@ -991,10 +1035,7 @@ def test_clear_unread_notifications_by_service( db.session.commit() # check that the user has unread notifications - assert ( - len(json.loads(user.user_profile.get("unread_notifications", "[]"))) - == 2 - ) + assert len(json.loads(user.user_profile.get("unread_notifications", "[]"))) == 2 # now system identity can clear the notifications current_internal_notifications.clear_unread( @@ -1004,9 +1045,7 @@ def test_clear_unread_notifications_by_service( # check that the user has no unread notifications final_user = current_accounts.datastore.get_user_by_id(user.id) - assert ( - json.loads(final_user.user_profile.get("unread_notifications")) == [] - ) + assert json.loads(final_user.user_profile.get("unread_notifications")) == [] def test_read_unread_notifications_by_view( @@ -1059,10 +1098,7 @@ def test_read_unread_notifications_by_view( db.session.commit() # check that the user has unread notifications - assert ( - len(json.loads(user.user_profile.get("unread_notifications", "[]"))) - == 2 - ) + assert len(json.loads(user.user_profile.get("unread_notifications", "[]"))) == 2 # set up a logged in client with app.test_client() as client: @@ -1071,8 +1107,7 @@ def test_read_unread_notifications_by_view( # read the unread notifications response = client.get( - f"{app.config['SITE_API_URL']}/users/{user.id}/" - "notifications/unread/list" + f"{app.config['SITE_API_URL']}/users/{user.id}/" "notifications/unread/list" ) assert response.status_code == 200 assert len(json.loads(response.data)) == 2 @@ -1094,8 +1129,7 @@ def test_read_unread_notifications_by_view( with app.test_client() as client: # NOTE: New client has its own session response = client.get( - f"{app.config['SITE_API_URL']}/users/{user.id}/" - "notifications/unread/list" + f"{app.config['SITE_API_URL']}/users/{user.id}/" "notifications/unread/list" ) assert response.status_code == 401 assert json.loads(response.data) == { @@ -1164,10 +1198,7 @@ def test_clear_unread_notifications_by_view( db.session.commit() # check that the user has unread notifications - assert ( - len(json.loads(user.user_profile.get("unread_notifications", "[]"))) - == 2 - ) + assert len(json.loads(user.user_profile.get("unread_notifications", "[]"))) == 2 # set up a logged in client with app.test_client() as client: @@ -1187,10 +1218,7 @@ def test_clear_unread_notifications_by_view( # check that the user has no unread notifications final_user = current_accounts.datastore.get_user_by_id(user.id) - assert ( - json.loads(final_user.user_profile.get("unread_notifications")) - == [] - ) + assert json.loads(final_user.user_profile.get("unread_notifications")) == [] # try to clear someone else's notifications response = client.get( @@ -1277,10 +1305,7 @@ def test_clear_one_unread_notification_by_view( db.session.commit() # check that the user has unread notifications - assert ( - len(json.loads(user.user_profile.get("unread_notifications", "[]"))) - == 2 - ) + assert len(json.loads(user.user_profile.get("unread_notifications", "[]"))) == 2 # set up a logged in client login_user(user) @@ -1341,9 +1366,7 @@ def test_unread_endpoint_bad_methods( "status": 405, } - csrf_token = next( - c.value for c in client.cookie_jar if c.name == "csrftoken" - ) + csrf_token = next(c.value for c in client.cookie_jar if c.name == "csrftoken") headers["X-CSRFToken"] = csrf_token response = client.put( @@ -1402,6 +1425,8 @@ def test_notification_on_first_upload( client, headers, mailbox, + celery_worker, + mock_send_remote_api_update_fixture, ): """ Test that the admin account is notified on a user's first upload. @@ -1444,9 +1469,7 @@ def test_notification_on_first_upload( saml_id=None, ) # admin_id = admin.user.id - admin_role = current_accounts.datastore.find_or_create_role( - name="admin-moderator" - ) + admin_role = current_accounts.datastore.find_or_create_role(name="admin-moderator") current_accounts.datastore.add_role_to_user(admin.user, admin_role) current_accounts.datastore.commit() diff --git a/site/tests/conftest.py b/site/tests/conftest.py index 779f3485d..58be1d5a7 100644 --- a/site/tests/conftest.py +++ b/site/tests/conftest.py @@ -1,3 +1,5 @@ +from celery import Celery +from celery.contrib.testing.worker import start_worker from collections import namedtuple from flask import current_app import os @@ -27,11 +29,9 @@ # Or if we want to create a dictionary of all variables: -config = { - k: v for k, v in invenio_config.__dict__.items() if not k.startswith("_") -} +config = {k: v for k, v in invenio_config.__dict__.items() if not k.startswith("_")} -pytest_plugins = [ +pytest_plugins = ( "celery.contrib.pytest", "tests.fixtures.communities", "tests.fixtures.custom_fields", @@ -49,7 +49,7 @@ "tests.fixtures.vocabularies.roles", "tests.fixtures.vocabularies.subjects", "tests.helpers.sample_records.basic", -] +) def _(x): @@ -80,8 +80,8 @@ def _(x): "force_https": False, }, # "BROKER_URL": "amqp://guest:guest@localhost:5672//", - "CELERY_CACHE_BACKEND": "memory", - "CELERY_RESULT_BACKEND": "cache", + # "CELERY_CACHE_BACKEND": "memory", + # "CELERY_RESULT_BACKEND": "cache", "CELERY_TASK_ALWAYS_EAGER": False, "CELERY_TASK_EAGER_PROPAGATES_EXCEPTIONS": True, # 'DEBUG_TB_ENABLED': False, @@ -156,6 +156,32 @@ class CustomUserProfileSchema(Schema): # } +@pytest.fixture(scope="session") +def celery_config(celery_config): + celery_config["broker_url"] = "amqp://guest:guest@localhost:5672//" + # celery_config["cache_backend"] = "memory" + # celery_config["result_backend"] = "cache" + celery_config["result_backend"] = "redis://localhost:6379/2" + # celery_config["logfile"] = "celery.log" + celery_config["loglevel"] = "DEBUG" + celery_config["task_always_eager"] = True + + return celery_config + + +@pytest.fixture(scope="session") +def flask_celery_app(celery_config): + app = Celery("invenio_app.celery") + app.config_from_object(celery_config) + return app + + +@pytest.fixture(scope="session") +def flask_celery_worker(flask_celery_app): + with start_worker(flask_celery_app, perform_ping_check=False) as worker: + yield worker + + # This is a namedtuple that holds all the fixtures we're likely to need # in a single test. RunningApp = namedtuple( From 9f4c30ee1e225648bed13f917b8a8f2a7125d8e4 Mon Sep 17 00:00:00 2001 From: Bonnie Russell Date: Tue, 14 Jan 2025 17:10:30 -0500 Subject: [PATCH 07/23] updated resource types and creator/contributor --- docs/source/metadata.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/source/metadata.md b/docs/source/metadata.md index f79aa64de..23fb894ae 100644 --- a/docs/source/metadata.md +++ b/docs/source/metadata.md @@ -267,8 +267,19 @@ The FAST vocabulary is augmented in KCWorks by the Homosaurus vocabulary (https: #### Resource types +Prior to the start of development on the KCWorks repository we did a deep dive into the metadata structures and resource types supported by the other large scholarly repositories, including Dryad, arXiv, and Zenodo. As technology has changed scholarship has taken on more forms, including virtual reality, podcasts, and even role-playing and video games. The InvenioRDM platform gave us the opportunity to customize our metadata further by resource type (as defined by the Datacite schema). As an open repository that serves a multidisciplinary audience we created a list that combines the resource types from multiple sources. + +We have broken down resource types over eight categories, including support for various art objects, multimedia files, online course files, virtual reality, and software. Each category - Dataset, Image, Instructional Resource, Presentation, Publication, Software, Audiovisual, and Other - has within it specific metadata fields that provide the ability to fully describe the object. + +The eight top resource types and many of the subtypes are derived from the list in [the Datacite schema under resourceTypeGeneral](https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/resourceTypeGeneral/).The rest derived from our original resource types in the [CORE repository](https://works.hcommons.org/records/f9xww-xwr22), launched in 2016 as part of Humanities Commons. + +Each top level category - Dataset, Image, Instructional Resource, Presentation, Publication, Software, Audiovisual, and Other - has within it specific metadata fields that provide the ability to fully describe the object. As we mapped our existing resource types we had to add two custom types to support legal scholars: Legal Comment and Legal Response, which support the Michigan State University School of Law and their deposits of legal scholarship. + #### Creator/contributor roles +Keeping with our support for a wide variety of objects and disciplines, our creator roles are more diverse than just "author," "editor," or "translator." For contribuors we were influenced by the [CRediT Taxonomy](https://credit.niso.org/), finding ways of recognizing labor even when the contribution is not immediately visible. Included in both creator and contributor roles a selection of types taken from the [Variations Metadata](https://dlib.indiana.edu/projects/variations3/metadata/guide/controlVocabs/contributorRoles.html) taxonomy, providing the ability to credit those who engage in creative and musical works. + + ## Identifier Schemes ### Works From 973f346da20d2a6d6e9032fdbbcbd2fbad2cda0e Mon Sep 17 00:00:00 2001 From: Bonnie Russell Date: Tue, 14 Jan 2025 17:17:47 -0500 Subject: [PATCH 08/23] fixed typo in resource types section. --- docs/source/metadata.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/source/metadata.md b/docs/source/metadata.md index 23fb894ae..1bdcf88bc 100644 --- a/docs/source/metadata.md +++ b/docs/source/metadata.md @@ -271,7 +271,7 @@ Prior to the start of development on the KCWorks repository we did a deep dive i We have broken down resource types over eight categories, including support for various art objects, multimedia files, online course files, virtual reality, and software. Each category - Dataset, Image, Instructional Resource, Presentation, Publication, Software, Audiovisual, and Other - has within it specific metadata fields that provide the ability to fully describe the object. -The eight top resource types and many of the subtypes are derived from the list in [the Datacite schema under resourceTypeGeneral](https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/resourceTypeGeneral/).The rest derived from our original resource types in the [CORE repository](https://works.hcommons.org/records/f9xww-xwr22), launched in 2016 as part of Humanities Commons. +The eight top resource types and many of the subtypes are derived from the list in [the Datacite schema under resourceTypeGeneral](https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/resourceTypeGeneral/). The rest derived from our original resource types in the [CORE repository](https://works.hcommons.org/records/f9xww-xwr22), launched in 2016 as part of Humanities Commons. Each top level category - Dataset, Image, Instructional Resource, Presentation, Publication, Software, Audiovisual, and Other - has within it specific metadata fields that provide the ability to fully describe the object. As we mapped our existing resource types we had to add two custom types to support legal scholars: Legal Comment and Legal Response, which support the Michigan State University School of Law and their deposits of legal scholarship. @@ -279,7 +279,6 @@ Each top level category - Dataset, Image, Instructional Resource, Presentation, Keeping with our support for a wide variety of objects and disciplines, our creator roles are more diverse than just "author," "editor," or "translator." For contribuors we were influenced by the [CRediT Taxonomy](https://credit.niso.org/), finding ways of recognizing labor even when the contribution is not immediately visible. Included in both creator and contributor roles a selection of types taken from the [Variations Metadata](https://dlib.indiana.edu/projects/variations3/metadata/guide/controlVocabs/contributorRoles.html) taxonomy, providing the ability to credit those who engage in creative and musical works. - ## Identifier Schemes ### Works From 779d44ed25e91a825ebc9bf49c986549529344cc Mon Sep 17 00:00:00 2001 From: Bonnie Russell Date: Thu, 16 Jan 2025 15:12:53 -0500 Subject: [PATCH 09/23] Updated ISSN and ISBN sections. --- docs/source/metadata.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/source/metadata.md b/docs/source/metadata.md index 1bdcf88bc..86459d5e0 100644 --- a/docs/source/metadata.md +++ b/docs/source/metadata.md @@ -301,8 +301,12 @@ KCWorks also supports the Handle identifier scheme (https://handle.net/). The Ha #### ISSN (secondary identifier) +An ISSN is an eight digit code that identifies a print or electronic newspaper, journal, magazine, or other periodical. More information on the ISSN can be found on [ISSN.org](https://www.issn.org/understanding-the-issn/what-is-an-issn/). + #### ISBN (secondary identifier) +An ISBN (International Standard Book Number) is a ten (pre-2007) or 13 digit (2007 to present) identifier used to identify both print and electronic published books. More information on the ISBN can be found on [ISBN-international.org](https://www.isbn-international.org/content/what-isbn/10). + ### People #### ORCID (recommended) From 6abb0dc8082dd8889f4452a700f096c63c0fe47a Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 09:19:30 -0500 Subject: [PATCH 10/23] fix(testing): Added workflow for automated test run on github --- .github/workflows/tests.yml | 41 +++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..415052c7d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: Run Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Docker + uses: docker/setup-buildx-action@v3 + + - name: Install pipenv + run: | + python -m pip install --upgrade pip + pip install pipenv + + - name: Install dependencies + run: pipenv install --dev + + - name: Set up Docker Compose + run: | + sudo apt-get update + sudo apt-get install -y docker-compose + + - name: Run tests + run: | + chmod +x site/run_test.sh + bash site/run_test.sh From 8ea61c76c9b9236e86f045c719bfb24c77d0438e Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 09:23:22 -0500 Subject: [PATCH 11/23] fix(testing): Major work on backend integration tests; all passing so far except search provisioning --- Pipfile | 2 +- site/pyproject.toml | 3 +- site/tests/api/__init__.py | 0 site/tests/api/test_accounts.py | 261 +++++++++++++++++++++ site/tests/api/test_api_notifications.py | 35 +-- site/tests/api/test_api_record_ops.py | 97 ++++---- site/tests/api/test_search_provisioning.py | 42 ++-- site/tests/api/test_stats.py | 13 +- site/tests/api/test_user_data_sync.py | 175 ++++++++++++-- site/tests/conftest.py | 55 +++-- site/tests/fixtures/saml.py | 219 ++++++++++++++++- site/tests/fixtures/search_provisioning.py | 69 ++++++ site/tests/fixtures/users.py | 211 +++++++++++++---- 13 files changed, 979 insertions(+), 203 deletions(-) create mode 100644 site/tests/api/__init__.py create mode 100644 site/tests/api/test_accounts.py create mode 100644 site/tests/fixtures/search_provisioning.py diff --git a/Pipfile b/Pipfile index 9a073cbb7..27b982bf6 100644 --- a/Pipfile +++ b/Pipfile @@ -66,4 +66,4 @@ flask-iiif = "*" python_version = "3.9" [pipenv] -allow_prereleases = true +allow_prereleases = false diff --git a/site/pyproject.toml b/site/pyproject.toml index 492d8faa0..6b4de55d1 100644 --- a/site/pyproject.toml +++ b/site/pyproject.toml @@ -49,12 +49,13 @@ testpaths = ["tests", "kcworks"] addopts = "--doctest-glob='*.rst' --doctest-modules --ignore=tests/helpers --ignore=kcworks/dependencies --ignore=kcworks/stats_dashboard" plugins = [ "tests.fixtures.communities", - "tests.fixtures.metadata_fields", "tests.fixtures.custom_fields", "tests.fixtures.identifiers", + "tests.fixtures.metadata_fields", "tests.fixtures.records", "tests.fixtures.roles", "tests.fixtures.saml", + "tests.fixtures.search_provisioning", "tests.fixtures.stats", "tests.fixtures.users", "tests.fixtures.vocabularies.affiliations", diff --git a/site/tests/api/__init__.py b/site/tests/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/site/tests/api/test_accounts.py b/site/tests/api/test_accounts.py new file mode 100644 index 000000000..64245713f --- /dev/null +++ b/site/tests/api/test_accounts.py @@ -0,0 +1,261 @@ +import pytest +import datetime +from flask import Flask +from invenio_accounts import current_accounts +from invenio_accounts.models import User +from invenio_saml.handlers import acs_handler_factory +import json +from kcworks.services.accounts.saml import ( + knowledgeCommons_account_info, + knowledgeCommons_account_setup, +) +import pytz +from requests_mock.adapter import _Matcher as Matcher +from types import SimpleNamespace +from typing import Callable, Optional +from ..fixtures.saml import idp_responses +from ..fixtures.users import user_data_set, AugmentedUserFixture + + +@pytest.mark.parametrize( + "attributes,output,user_data,api_call_count", + [ + ( + idp_responses["joanjett"]["raw_data"], + idp_responses["joanjett"]["extracted_data"], + user_data_set["joanjett"], + 0, + ), + ( + idp_responses["user1"]["raw_data"], + idp_responses["user1"]["extracted_data"], + user_data_set["user1"], + 0, + ), + ( + idp_responses["user2"]["raw_data"], + idp_responses["user2"]["extracted_data"], + user_data_set["user2"], + 0, + ), + ( + idp_responses["user3"]["raw_data"], + idp_responses["user3"]["extracted_data"], + user_data_set["user3"], + 1, + ), + ( + idp_responses["user4"]["raw_data"], + idp_responses["user4"]["extracted_data"], + user_data_set["user4"], + 0, + ), + ], +) +def test_knowledgeCommons_account_info( + running_app, + appctx, + db, + attributes: dict, + output: dict, + user_data: dict, + mock_user_data_api: Callable, + user_data_to_remote_data: Callable, + api_call_count: int, +) -> None: + """ + Test the custom handler + """ + + mock_adapter: Matcher = mock_user_data_api( + user_data["saml_id"], + user_data_to_remote_data(user_data["saml_id"], user_data["email"], user_data), + ) + + info: dict = knowledgeCommons_account_info( + attributes, remote_app="knowledgeCommons" + ) + if api_call_count == 1: # Here the api is only called if no email is provided + assert mock_adapter.called + assert mock_adapter.call_count == 1 + else: + assert not mock_adapter.called + assert mock_adapter.call_count == 0 + + expected_result_email: str = ( + output["user"]["email"] + if output["user"]["email"] + else user_data["email"] + # To handle the case where the IDP response does not have an email + # and we are retrieving it from the api + ) + + assert info["user"]["email"] == expected_result_email + assert ( + info["user"]["profile"]["full_name"] == output["user"]["profile"]["full_name"] + ) + assert info["user"]["profile"]["username"] == output["user"]["profile"]["username"] + assert info["external_id"] == output["external_id"] + assert info["external_method"] == output["external_method"] + assert info["active"] == output["active"] + assert datetime.datetime.now(tz=pytz.timezone("US/Eastern")) - info[ + "confirmed_at" + ] < datetime.timedelta(seconds=10) + + +@pytest.mark.parametrize( + "user_data,idp_data", + [ + (user_data_set["joanjett"], idp_responses["joanjett"]["extracted_data"]), + (user_data_set["user1"], idp_responses["user1"]["extracted_data"]), + (user_data_set["user2"], idp_responses["user2"]["extracted_data"]), + (user_data_set["user3"], idp_responses["user3"]["extracted_data"]), + (user_data_set["user4"], idp_responses["user4"]["extracted_data"]), + ], +) +def test_knowledgeCommons_account_setup( + running_app, appctx, db, user_factory: Callable, user_data: dict, idp_data: dict +) -> None: + """ + Test the account setup function + + Test that the user is activated and the user data is updated in the db + based on the data from the (mocked) remote service api call. + """ + + u: AugmentedUserFixture = user_factory( + email=user_data["email"], + password="password", + saml_src="knowledgeCommons", + saml_id=user_data["saml_id"], + new_remote_data=user_data, + ) + assert isinstance(u.user, User) + mock_adapter: Optional[Matcher] = u.mock_adapter + assert isinstance(mock_adapter, Matcher) + # Ensure that any group roles are being added by the setup function + assert u.user.roles == [] + # Deactivate the user if it is already active so that we can test the + # activation + if u.user.active: + assert current_accounts.datastore.deactivate_user(u.user) + assert not mock_adapter.called + + synced: bool = knowledgeCommons_account_setup(u.user, idp_data) + assert synced + assert mock_adapter.called # The user data api call was made + assert mock_adapter.call_count == 1 + + user: User = current_accounts.datastore.get_user_by_id(u.user.id) + assert user.active # The user was activated + assert user.confirmed_at is not None + assert user.confirmed_at - datetime.datetime.now() < datetime.timedelta( + hours=5, seconds=10 + ) + assert user.email == user_data.get("email") # TODO: Test updating email + assert user.username == f"knowledgeCommons-{user_data['saml_id']}" + assert user.user_profile.get("full_name") == user_data["name"] + assert user.user_profile.get("affiliations") == user_data.get( + "institutional_affiliation" + ) + assert user.user_profile.get("identifier_kc_username") == user_data["saml_id"] + assert user.user_profile.get("identifier_orcid") == (user_data.get("orcid") or None) + assert json.loads(user.user_profile.get("name_parts")) == { + "first": user_data["first_name"], + "last": user_data["last_name"], + } + assert user.external_identifiers[0].id == user_data["saml_id"] + assert user.external_identifiers[0].id_user == user.id + assert user.external_identifiers[0].method == "knowledgeCommons" + assert [r.name for r in user.roles] == ( + [f"knowledgeCommons---{g['id']}|{g['role']}" for g in user_data["groups"]] + if "groups" in user_data.keys() + else [] + ) + + +@pytest.mark.parametrize( + "idp_data,user_data,api_call_count", + [ + (idp_responses["joanjett"]["raw_data"], user_data_set["joanjett"], 1), + (idp_responses["user1"]["raw_data"], user_data_set["user1"], 1), + (idp_responses["user2"]["raw_data"], user_data_set["user2"], 1), + ( + idp_responses["user3"]["raw_data"], + user_data_set["user3"], + 2, + ), # IDP response has no email + (idp_responses["user4"]["raw_data"], user_data_set["user4"], 1), + ], +) +def test_account_register_on_login( + running_app, + appctx, + db, + idp_data, + user_data, + mocker, + mock_user_data_api: Callable, + user_data_to_remote_data: Callable, + api_call_count: int, + mailbox, + celery_worker, +) -> None: + """ + Test the registration function if a user is not already registered. + + Tests that: + - The new user is created from SAML data + - The new user's data is synced from remote + - The new user is activated + - The new user is sent a welcome email + """ + app: Flask = running_app.app + mock_current_user = SimpleNamespace(is_authenticated=False) + mocker.patch("invenio_saml.handlers.current_user", mock_current_user) + mock_auth = SimpleNamespace(get_attributes=lambda: idp_data) + handler = acs_handler_factory( + "knowledgeCommons", + account_info=knowledgeCommons_account_info, + account_setup=knowledgeCommons_account_setup, + ) + + mock_adapter: Matcher = mock_user_data_api( + user_data["saml_id"], + user_data_to_remote_data(user_data["saml_id"], user_data["email"], user_data), + ) + next_url: str = handler(mock_auth, "https://localhost/next-url.com") + assert mock_adapter.called + assert mock_adapter.call_count == api_call_count + + assert len(mailbox) == 1 + assert mailbox[0].subject == "Welcome to KCWorks!" + assert mailbox[0].recipients == [user_data["email"]] + assert mailbox[0].sender == app.config["MAIL_DEFAULT_SENDER"] + + user: User = current_accounts.datastore.get_user_by_email(user_data["email"]) + assert user.email == user_data["email"] + assert user.active + assert user.confirmed_at is not None + assert user.confirmed_at - datetime.datetime.now() < datetime.timedelta( + hours=5, seconds=10 + ) + assert user.username == f"knowledgeCommons-{user_data['saml_id']}" + assert user.user_profile.get("full_name") == user_data["name"] + assert user.user_profile.get("affiliations") == user_data.get( + "institutional_affiliation", "" + ) + assert user.user_profile.get("identifier_kc_username") == user_data["saml_id"] + assert user.user_profile.get("identifier_orcid") == ( + user_data.get("orcid") if user_data.get("orcid") != "" else None + ) + assert user.external_identifiers[0].id == user_data["saml_id"] + assert user.external_identifiers[0].id_user == user.id + assert user.external_identifiers[0].method == "knowledgeCommons" + assert [r.name for r in user.roles] == ( + [f"knowledgeCommons---{g['id']}|{g['role']}" for g in user_data["groups"]] + if "groups" in user_data.keys() + else [] + ) + + assert next_url == "https://localhost/next-url.com" diff --git a/site/tests/api/test_api_notifications.py b/site/tests/api/test_api_notifications.py index 7b16a57e2..d8cca986f 100644 --- a/site/tests/api/test_api_notifications.py +++ b/site/tests/api/test_api_notifications.py @@ -1,5 +1,4 @@ import pytest -from celery import shared_task from flask_security import current_user from flask_security.utils import login_user, logout_user import json @@ -25,36 +24,7 @@ from invenio_users_resources.services.users.tasks import reindex_users from kcworks.proxies import current_internal_notifications from pprint import pformat -from typing import Optional - - -@shared_task(bind=True) -def mock_send_remote_api_update( - self, - identity_id: str = "", - record: dict = {}, - is_published: bool = False, - is_draft: bool = False, - is_deleted: bool = False, - parent: Optional[dict] = None, - latest_version_index: Optional[int] = None, - latest_version_id: Optional[str] = None, - current_version_index: Optional[int] = None, - draft: Optional[dict] = None, - endpoint: str = "", - service_type: str = "", - service_method: str = "", - **kwargs, -): - pass - - -@pytest.fixture -def mock_send_remote_api_update_fixture(mocker): - mocker.patch( - "invenio_remote_api_provisioner.components.send_remote_api_update", # noqa: E501 - mock_send_remote_api_update, - ) +import time def test_notify_for_request_acceptance( @@ -234,7 +204,10 @@ def test_notify_for_request_acceptance( comment = comments.get("hits", {}).get("hits", [])[-1] comment_id = comment.get("id") + time.sleep(10) + # check that the user is notified by mail + assert app.config.get("MAIL_SUPPRESS_SEND") is False assert len(mailbox) == 2 # 1 for accept, 1 for comment # TODO: Test overridden templates assert mailbox[0].recipients == [user.email] diff --git a/site/tests/api/test_api_record_ops.py b/site/tests/api/test_api_record_ops.py index 9f3f9c5ca..6b629b240 100644 --- a/site/tests/api/test_api_record_ops.py +++ b/site/tests/api/test_api_record_ops.py @@ -1,6 +1,7 @@ import json import pytest import re +from ..fixtures.users import user_data_set import arrow @@ -39,23 +40,22 @@ def test_draft_creation( minimal_record, headers, search_clear, + celery_worker, ): """Test that a user can create a draft record.""" app = running_app.app u = user_factory( - email="test@example.com", + email=user_data_set["user1"]["email"], password="test", token=True, admin=True, - saml_src="knowledgeCommons", - saml_id="user1", ) user = u.user # identity = u.identity # print(identity) token = u.allowed_token - + minimal_record.update({"files": {"enabled": False}}) with app.test_client() as client: logged_in_client, _ = client_with_login(client, user) response = logged_in_client.post( @@ -117,9 +117,7 @@ def test_draft_creation( assert not actual_draft["is_published"] assert actual_draft["is_draft"] assert ( - arrow.get(actual_draft["expires_at"]).format( - "YYYY-MM-DD HH:mm:ss.SSSSSS" - ) + arrow.get(actual_draft["expires_at"]).format("YYYY-MM-DD HH:mm:ss.SSSSSS") == actual_draft["expires_at"] ) assert actual_draft["pids"] == {} @@ -141,9 +139,7 @@ def test_draft_creation( assert actual_draft["metadata"]["title"] == "A Romans story" assert actual_draft["metadata"]["publisher"] == "Acme Inc" assert ( - arrow.get(actual_draft["metadata"]["publication_date"]).format( - "YYYY-MM-DD" - ) + arrow.get(actual_draft["metadata"]["publication_date"]).format("YYYY-MM-DD") == "2020-06-01" ) assert actual_draft["custom_fields"] == {} @@ -168,9 +164,7 @@ def test_draft_creation( "entries": {}, } assert actual_draft["status"] == "draft" - publication_date = arrow.get( - actual_draft["metadata"]["publication_date"] - ) + publication_date = arrow.get(actual_draft["metadata"]["publication_date"]) assert actual_draft["ui"][ "publication_date_l10n_medium" ] == publication_date.format("MMM D, YYYY") @@ -178,13 +172,13 @@ def test_draft_creation( "publication_date_l10n_long" ] == publication_date.format("MMMM D, YYYY") created_date = arrow.get(actual_draft["created"]) - assert actual_draft["ui"][ - "created_date_l10n_long" - ] == created_date.format("MMMM D, YYYY") + assert actual_draft["ui"]["created_date_l10n_long"] == created_date.format( + "MMMM D, YYYY" + ) updated_date = arrow.get(actual_draft["updated"]) - assert actual_draft["ui"][ - "updated_date_l10n_long" - ] == updated_date.format("MMMM D, YYYY") + assert actual_draft["ui"]["updated_date_l10n_long"] == updated_date.format( + "MMMM D, YYYY" + ) assert actual_draft["ui"]["resource_type"] == { "id": "image-photograph", "title_l10n": "Photo", @@ -221,27 +215,27 @@ def test_draft_creation( assert actual_draft["ui"]["is_draft"] # publish the record - publish_response = logged_in_client.post( - f"{app.config['SITE_API_URL']}/records/{actual_draft_id}/draft" - "/actions/publish", - headers={**headers, "Authorization": f"Bearer {token}"}, - ) - assert publish_response.status_code == 202 - - actual_published = publish_response.json - assert actual_published["id"] == actual_draft_id - assert actual_published["is_published"] - assert not actual_published["is_draft"] - assert actual_published["revision_id"] == 3 - assert actual_published["versions"]["is_latest"] - assert actual_published["versions"]["is_latest_draft"] - assert actual_published["versions"]["index"] == 1 - assert actual_published["status"] == "published" - assert actual_published["ui"]["version"] == "v1" - assert not actual_published["ui"]["is_draft"] - - -@pytest.mark.skip(reason="Not implemented") + # publish_response = logged_in_client.post( + # f"{app.config['SITE_API_URL']}/records/{actual_draft_id}/draft" + # "/actions/publish", + # headers={**headers, "Authorization": f"Bearer {token}"}, + # ) + # assert publish_response.status_code == 202 + + # actual_published = publish_response.json + # assert actual_published["id"] == actual_draft_id + # assert actual_published["is_published"] + # assert not actual_published["is_draft"] + # assert actual_published["revision_id"] == 3 + # assert actual_published["versions"]["is_latest"] + # assert actual_published["versions"]["is_latest_draft"] + # assert actual_published["versions"]["index"] == 1 + # assert actual_published["status"] == "published" + # assert actual_published["ui"]["version"] == "v1" + # assert not actual_published["ui"]["is_draft"] + + +# @pytest.mark.skip(reason="Not implemented") def test_record_publication( running_app, db, @@ -250,25 +244,24 @@ def test_record_publication( headers, user_factory, search_clear, + celery_worker, + mock_send_remote_api_update_fixture, ): - app = running_app.app - u = user_factory( - email="test@example.com", + email=user_data_set["user1"]["email"], password="test", token=True, admin=True, - saml_src="knowledgeCommons", - saml_id="user1", ) user = u.user + token = u.allowed_token # identity = u.identity # print(identity) - token = u.allowed_token with app.test_client() as client: logged_in_client, _ = client_with_login(client, user) + minimal_record.update({"files": {"enabled": False}}) response = logged_in_client.post( f"{app.config['SITE_API_URL']}/records", data=json.dumps(minimal_record), @@ -279,6 +272,10 @@ def test_record_publication( actual_draft = response.json actual_draft_id = actual_draft["id"] + # mock_search_api_request( + # "POST", actual_draft_id, minimal_record, app.config["SITE_API_URL"] + # ) + publish_response = logged_in_client.post( f"{app.config['SITE_API_URL']}/records/{actual_draft_id}/draft" "/actions/publish", @@ -289,12 +286,12 @@ def test_record_publication( actual_published = publish_response.json assert actual_published["id"] == actual_draft_id assert actual_published["is_published"] + assert not actual_published["is_draft"] assert actual_published["versions"]["is_latest"] - assert actual_published["versions"]["is_latest_draft"] is False - assert actual_published["versions"]["index"] == 2 + assert actual_published["versions"]["is_latest_draft"] is True + assert actual_published["versions"]["index"] == 1 assert actual_published["status"] == "published" - assert actual_published["ui"]["is_published"] - assert actual_published["ui"]["version"] == "v2" + assert actual_published["ui"]["version"] == "v1" assert not actual_published["ui"]["is_draft"] diff --git a/site/tests/api/test_search_provisioning.py b/site/tests/api/test_search_provisioning.py index 44a349888..be54ebb08 100644 --- a/site/tests/api/test_search_provisioning.py +++ b/site/tests/api/test_search_provisioning.py @@ -24,24 +24,31 @@ def test_search_provisioning_at_publication( # Create a new draft service = current_rdm_records.records_service + minimal_record["files"] = {"enabled": False} draft = service.create(system_identity, minimal_record) draft_id = draft.id app.logger.warning(f"draft: {pformat(draft.to_dict())}") + app.logger.debug( + f"CELERY_TASK_ALWAYS_EAGER: {app.config['CELERY_TASK_ALWAYS_EAGER']}" + ) # Verify that no API call was made during draft creation assert requests_mock.call_count == 0 # Construct the mocked api_url using the factory function config = app.config["REMOTE_API_PROVISIONER_EVENTS"]["rdm_record"][ - list(app.config["REMOTE_API_PROVISIONER_EVENTS"]["rdm_record"].keys())[ - 0 - ] + list(app.config["REMOTE_API_PROVISIONER_EVENTS"]["rdm_record"].keys())[0] ]["publish"] api_url = config["url_factory"](system_identity, record=draft) + app.logger.debug(f"api_url: {api_url}") # Choose the HTTP method using the factory function http_method_factory = config["http_method"] - http_method = http_method_factory(system_identity, record=draft) + # NOTE: In service component, the draft and record are both dicts + http_method = http_method_factory( + system_identity, draft=draft.to_dict(), record=draft.to_dict() + ) # noqa: E501 + app.logger.debug(f"http_method: {http_method}") # Mock the external API call for publication mock_response = { @@ -61,21 +68,20 @@ def test_search_provisioning_at_publication( for c in minimal_record["metadata"]["creators"] ], "primary_url": f"{app.config['SITE_UI_URL']}/records/{draft_id}", - "other_urls": [ - f"{app.config['SITE_API_URL']}/records/{draft_id}/files" - ], + "other_urls": [f"{app.config['SITE_API_URL']}/records/{draft_id}/files"], "publication_date": minimal_record["metadata"]["publication_date"], "modified_date": "2024-06-07", "content_type": "work", "network_node": "works", } - requests_mock.request(http_method, api_url, json=mock_response) + mock_adapter = requests_mock.request( + http_method, "https://search.hcommons-dev.org/v1/documents", json=mock_response + ) # noqa: E501 + app.logger.debug(f"mock_adapter: {dir(mock_adapter)}") # Publish the draft published_record = service.publish(system_identity, draft_id) - app.logger.warning( - f"published_record: {pformat(published_record.to_dict())}" - ) + app.logger.warning(f"published_record: {pformat(published_record.to_dict())}") # Allow time for the background task to complete import time @@ -87,10 +93,7 @@ def test_search_provisioning_at_publication( last_request = requests_mock.last_request assert last_request.url == api_url assert last_request.method == http_method - assert ( - last_request.headers["Authorization"] - == f"Bearer {config['auth_token']}" - ) + assert last_request.headers["Authorization"] == f"Bearer {config['auth_token']}" # Check if the payload was correct payload_formatter = config["payload"] @@ -107,13 +110,8 @@ def test_search_provisioning_at_publication( record = service.read(system_identity, draft_id) # Check if the record ID was recorded correctly - assert ( - record["custom_fields"]["kcr:commons_search_recid"] - == mock_response["_id"] - ) + assert record["custom_fields"]["kcr:commons_search_recid"] == mock_response["_id"] # Check if the timestamp was recorded (within a 10-second window) - recorded_time = arrow.get( - record["custom_fields"]["kcr:commons_search_updated"] - ) + recorded_time = arrow.get(record["custom_fields"]["kcr:commons_search_updated"]) assert (arrow.utcnow() - recorded_time).total_seconds() < 10 diff --git a/site/tests/api/test_stats.py b/site/tests/api/test_stats.py index 647f87877..7752d9960 100644 --- a/site/tests/api/test_stats.py +++ b/site/tests/api/test_stats.py @@ -13,9 +13,7 @@ @pytest.mark.skip("Not implemented") def test_stat_creation(running_app, db, search_clear, minimal_record): draft = current_rdm_records_service.create(system_identity, minimal_record) - published = current_rdm_records_service.publish( - system_identity, draft["id"] - ) + published = current_rdm_records_service.publish(system_identity, draft["id"]) record_id = published["id"] metadata_record = published["metadata"] pid = published["pid"] @@ -29,14 +27,11 @@ def test_stats_backend_processing( minimal_record, user_factory, create_stats_indices, + celery_worker, + mock_send_remote_api_update_fixture, ): - app = running_app.app - # u = user_factory() - # identity = get_identity(u.user) draft = current_rdm_records_service.create(system_identity, minimal_record) - published = current_rdm_records_service.publish( - system_identity, draft["id"] - ) + published = current_rdm_records_service.publish(system_identity, draft["id"]) record_id = published.id metadata_record = published.to_dict() dt = arrow.utcnow() diff --git a/site/tests/api/test_user_data_sync.py b/site/tests/api/test_user_data_sync.py index f19093f06..6c6705de2 100644 --- a/site/tests/api/test_user_data_sync.py +++ b/site/tests/api/test_user_data_sync.py @@ -1,12 +1,19 @@ from flask_login import login_user +from invenio_accounts.models import User from invenio_accounts.proxies import current_accounts +from invenio_remote_user_data_kcworks.tasks import do_user_data_update import json import os + +# from pprint import pprint import pytest import requests +from requests_mock.adapter import _Matcher as Matcher +from typing import Callable # from invenio_accounts.testutils import login_user_via_session from kcworks.services.accounts.saml import knowledgeCommons_account_setup +from ..fixtures.users import AugmentedUserFixture, user_data_set def test_user_data_kc_endpoint(): @@ -14,17 +21,18 @@ def test_user_data_kc_endpoint(): The focus here is on the json schema being returned """ - url = "https://hcommons.org/wp-json/commons/v1/users/gihctester" - headers = { - "Authorization": f"Bearer {os.environ.get('COMMONS_API_TOKEN_PROD')}" - } + protocol = os.environ.get("INVENIO_COMMONS_API_REQUEST_PROTOCOL", "http") + base_url = f"{protocol}://hcommons.org/wp-json/commons/v1/users" + url = f"{base_url}/gihctester" + token = os.environ.get("COMMONS_API_TOKEN_PROD") + headers = {"Authorization": f"Bearer {token}"} response = requests.get(url, headers=headers) assert response.status_code == 200 actual_resp = response.json() assert actual_resp["username"] == "gihctester" - assert actual_resp["email"] == "ghosthc@email.ghostinspector.com" + assert actual_resp["email"] == "ghosthc@lblyoehp.mailosaur.net" assert actual_resp["name"] == "Ghost Hc" assert actual_resp["first_name"] == "Ghost" assert actual_resp["last_name"] == "Hc" @@ -43,8 +51,141 @@ def test_group_data_kc_endpoint(): pass +@pytest.mark.parametrize( + "starting_email,user_data,groups_changes", + [ + ( + user_data_set["user1"]["email"], + user_data_set["user1"], + { + "added_groups": [ + "knowledgeCommons---12345|admin", + "knowledgeCommons---67891|member", + ], + "dropped_groups": [], + "unchanged_groups": [], + }, + ), + ( + "emailtobechanged@example.com", + user_data_set["user2"], + { + "added_groups": [], + "dropped_groups": [], + "unchanged_groups": [], + }, + ), + ], +) +def test_do_user_data_update_task( + running_app, + appctx, + db, + user_factory: Callable, + starting_email: str, + user_data: dict, + groups_changes: dict, + user_data_to_remote_data: Callable, + requests_mock, + celery_worker, +): + """ + Test that the do_user_data_update task does what it's supposed to do. + + - It should return the correct data + - It should call the remote api if the user has an IDP + - It should update the user in the db if the user has an IDP + """ + # Mock additional user data from the remote service + # api response + new_data_payload = user_data_to_remote_data( + user_data["saml_id"], user_data["email"], user_data + ) + print(f"new_data_payload: {new_data_payload}") + # Create a test user + u: AugmentedUserFixture = user_factory( + email=starting_email, + saml_src="knowledgeCommons", + saml_id=user_data["saml_id"], + new_remote_data=new_data_payload, + ) + assert isinstance(u.user, User) + user = u.user + user_id: int = user.id + assert user.username is None # FIXME: Why no username yet? + assert user.email == starting_email + assert user.user_profile == {} + assert user.roles == [] + assert isinstance(u.mock_adapter, Matcher) + mock_adapter: Matcher = u.mock_adapter + assert not mock_adapter.called + assert mock_adapter.call_count == 0 + + result: tuple[User, dict, list[str], dict] = do_user_data_update( + user_id=user_id, idp="knowledgeCommons", remote_id=user_data["saml_id"] + ) + assert isinstance(result[0], User) + # assert result[0].id == user_id # FIXME: Why does this trigger detached error? + + # the result[1] is a dictionary of the updated user data (including only + # the changed keys and values). + expected_updated_data = { + "user_profile": { + "affiliations": user_data["institutional_affiliation"], + "full_name": user_data["name"], + "identifier_kc_username": user_data["saml_id"], + "identifier_orcid": user_data["orcid"], + "name_parts": ( + '{"first": "' + + user_data["first_name"] + + '", "last": "' + + user_data["last_name"] + + '"}' + ), + }, + "username": f"knowledgeCommons-{user_data['saml_id']}", + } + if starting_email != user_data["email"]: + expected_updated_data["email"] = user_data["email"] + assert result[1] == expected_updated_data + # the result[2] is a complete list of the updated user's group memberships. + assert result[2] == ( + [f"knowledgeCommons---{g['id']}|{g['role']}" for g in user_data["groups"]] + if "groups" in user_data.keys() and user_data["groups"] + else [] + ) + # the result[3] is a dictionary of the changes to the user's group + # memberships (with the keys "added_groups", "dropped_groups", and + # "unchanged_groups"). + assert result[3] == groups_changes + assert mock_adapter.called + assert mock_adapter.call_count == 1 + + # Check that the user data was updated in the db + user = current_accounts.datastore.get_user_by_id(user_id) + assert user.email == user_data["email"] + assert user.user_profile.get("full_name") == user_data["name"] + assert user.user_profile.get("identifier_kc_username") == user_data["saml_id"] + assert user.user_profile.get("identifier_orcid") == user_data["orcid"] + assert json.loads(user.user_profile.get("name_parts")) == { + "first": user_data["first_name"], + "last": user_data["last_name"], + } + assert [r.name for r in user.roles] == ( + [f"knowledgeCommons---{g['id']}|{g['role']}" for g in user_data["groups"]] + if "groups" in user_data.keys() + else [] + ) + + def test_user_data_sync_on_login( - running_app, db, user_factory, user1_data, search_clear + running_app, + db, + user_factory, + user1_data, + search_clear, + celery_worker, + mock_send_remote_api_update_fixture, ): """ Test that the user data is synced when a user logs in. @@ -80,7 +221,7 @@ def test_user_data_sync_on_login( assert profile.get("full_name") == user1_data["name"] assert ( profile.get("affiliations") == user1_data["institutional_affiliation"] - ) + ) # noqa: E501 assert profile.get("identifier_orcid") == user1_data["orcid"] assert profile.get("identifier_kc_username") == user1_data["saml_id"] assert json.loads(profile.get("name_parts")) == { @@ -117,9 +258,10 @@ def test_user_data_sync_on_webhook( requests_mock, headers, search_clear, + celery_worker, + mock_send_remote_api_update_fixture, ): app = running_app.app - # Create a user # The user is created with a saml auth record because saml_src # and saml_id are supplied. @@ -142,7 +284,8 @@ def test_user_data_sync_on_webhook( mock_remote_data["username"] = user1_data["saml_id"] # Mock the remote api call. - base_url = "https://hcommons-dev.org/wp-json/commons/v1/users" + protocol = os.environ.get("INVENIO_COMMONS_API_REQUEST_PROTOCOL", "http") + base_url = f"{protocol}://hcommons-dev.org/wp-json/commons/v1/users" remote_url = f"{base_url}/{user1_data['saml_id']}" mock_adapter = requests_mock.get( remote_url, @@ -151,6 +294,7 @@ def test_user_data_sync_on_webhook( ) # Ping the webhook endpoint (no data is sent) + app.logger.debug(f"SITE_API_URL: {app.config['SITE_API_URL']}") response = client.get( f"{app.config['SITE_API_URL']}/webhooks/user_data_update", ) @@ -196,8 +340,8 @@ def test_user_data_sync_on_webhook( assert user.user_profile.get("full_name") == user1_data["name"] assert ( user.user_profile.get("identifier_kc_username") - == user1_data["saml_id"] - ) + == user1_data["saml_id"] # noqa: E501 + ) # noqa: E501 assert user.user_profile.get("identifier_orcid") == user1_data["orcid"] assert json.loads(user.user_profile.get("name_parts")) == { "first": user1_data["first_name"], @@ -210,7 +354,7 @@ def test_user_data_sync_on_webhook( ] assert ( user.user_profile.get("affiliations") - == user1_data["institutional_affiliation"] + == user1_data["institutional_affiliation"] # noqa: E501 ) @@ -218,8 +362,11 @@ def test_user_data_sync_on_account_setup( running_app, db, user_factory, requests_mock, search_clear ): # Mock the remote API endpoint + protocol = os.environ.get("INVENIO_COMMONS_API_REQUEST_PROTOCOL", "http") + base_url = f"{protocol}://hcommons-dev.org/wp-json/commons/v1/users" + remote_url = f"{base_url}/testuser" requests_mock.get( - "https://hcommons-dev.org/wp-json/commons/v1/users/testuser", + remote_url, json={ "username": "testuser", "email": "testuser@example.com", @@ -263,7 +410,7 @@ def test_user_data_sync_on_account_setup( assert updated_user.user_profile.get("affiliations") == "Test University" assert ( updated_user.user_profile.get("identifier_orcid") - == "0000-0001-2345-6789" + == "0000-0001-2345-6789" # noqa: E501 ) assert json.loads(updated_user.user_profile.get("name_parts")) == { "first": "Test", diff --git a/site/tests/conftest.py b/site/tests/conftest.py index 58be1d5a7..e6204fa7c 100644 --- a/site/tests/conftest.py +++ b/site/tests/conftest.py @@ -1,14 +1,12 @@ from celery import Celery from celery.contrib.testing.worker import start_worker from collections import namedtuple -from flask import current_app import os from pathlib import Path import importlib from invenio_app.factory import create_app as create_ui_api from invenio_queues import current_queues -from invenio_notifications.ext import InvenioNotifications -from invenio_search.proxies import current_search, current_search_client +from invenio_search.proxies import current_search_client import jinja2 from marshmallow import Schema, fields import pytest @@ -37,6 +35,7 @@ "tests.fixtures.custom_fields", "tests.fixtures.records", "tests.fixtures.roles", + "tests.fixtures.search_provisioning", "tests.fixtures.stats", "tests.fixtures.users", "tests.fixtures.vocabularies.affiliations", @@ -79,11 +78,12 @@ def _(x): "content_security_policy": {"default-src": []}, "force_https": False, }, - # "BROKER_URL": "amqp://guest:guest@localhost:5672//", + "BROKER_URL": "amqp://guest:guest@localhost:5672//", # "CELERY_CACHE_BACKEND": "memory", # "CELERY_RESULT_BACKEND": "cache", - "CELERY_TASK_ALWAYS_EAGER": False, + "CELERY_TASK_ALWAYS_EAGER": True, "CELERY_TASK_EAGER_PROPAGATES_EXCEPTIONS": True, + "CELERY_LOGLEVEL": "DEBUG", # 'DEBUG_TB_ENABLED': False, "INVENIO_INSTANCE_PATH": "/opt/invenio/var/instance", "MAIL_SUPPRESS_SEND": False, @@ -113,6 +113,7 @@ def _(x): test_config["LOGGING_FS_LEVEL"] = "DEBUG" test_config["LOGGING_FS_LOGFILE"] = str(log_file_path) +test_config["CELERY_LOGFILE"] = str(log_folder_path / "celery.log") # enable DataCite DOI provider test_config["DATACITE_ENABLED"] = True @@ -158,28 +159,32 @@ class CustomUserProfileSchema(Schema): @pytest.fixture(scope="session") def celery_config(celery_config): - celery_config["broker_url"] = "amqp://guest:guest@localhost:5672//" - # celery_config["cache_backend"] = "memory" - # celery_config["result_backend"] = "cache" - celery_config["result_backend"] = "redis://localhost:6379/2" - # celery_config["logfile"] = "celery.log" + celery_config["logfile"] = str(log_folder_path / "celery.log") celery_config["loglevel"] = "DEBUG" celery_config["task_always_eager"] = True + celery_config["cache_backend"] = "memory" + celery_config["result_backend"] = "cache" + celery_config["task_eager_propagates_exceptions"] = True return celery_config @pytest.fixture(scope="session") -def flask_celery_app(celery_config): - app = Celery("invenio_app.celery") - app.config_from_object(celery_config) - return app +def celery_enable_logging(): + return True -@pytest.fixture(scope="session") -def flask_celery_worker(flask_celery_app): - with start_worker(flask_celery_app, perform_ping_check=False) as worker: - yield worker +# @pytest.fixture(scope="session") +# def flask_celery_app(celery_config): +# app = Celery("invenio_app.celery") +# app.config_from_object(celery_config) +# return app + + +# @pytest.fixture(scope="session") +# def flask_celery_worker(flask_celery_app): +# with start_worker(flask_celery_app, perform_ping_check=False) as worker: +# yield worker # This is a namedtuple that holds all the fixtures we're likely to need @@ -279,15 +284,21 @@ def template_loader(): """Fixture providing overloaded and custom templates to test app.""" def load_tempates(app): - site_path = Path(__file__).parent.parent - templates_path = site_path / "kcworks" / "templates" / "semantic-ui" + site_path = ( + Path(__file__).parent.parent / "kcworks" / "templates" / "semantic-ui" + ) + root_path = Path(__file__).parent.parent.parent / "templates" + for path in ( + site_path, + root_path, + ): + assert path.exists() custom_loader = jinja2.ChoiceLoader( [ app.jinja_loader, - jinja2.FileSystemLoader([str(templates_path)]), + jinja2.FileSystemLoader([str(site_path), str(root_path)]), ] ) - assert templates_path.exists() app.jinja_loader = custom_loader app.jinja_env.loader = custom_loader diff --git a/site/tests/fixtures/saml.py b/site/tests/fixtures/saml.py index 2c79cacce..aff988dba 100644 --- a/site/tests/fixtures/saml.py +++ b/site/tests/fixtures/saml.py @@ -1,3 +1,4 @@ +import datetime from invenio_saml.handlers import acs_handler_factory test_config_saml = { @@ -53,16 +54,13 @@ "singleSignOnService": { # URL Target of the IdP where the Authentication # Request Message will be sent. - "url": ( - "https://proxy.hcommons-dev.org/Saml2/sso/redirect" - ), + "url": ("https://proxy.hcommons-dev.org/Saml2/sso/redirect"), # SAML protocol binding to be used when returning the # message. OneLogin Toolkit supports # the HTTP-Redirect binding # only for this endpoint. "binding": ( - "urn:oasis:names:tc:SAML:2.0:bindings:" - "HTTP-Redirect" + "urn:oasis:names:tc:SAML:2.0:bindings:" "HTTP-Redirect" ), }, # SLO endpoint info of the IdP. @@ -75,8 +73,7 @@ # the HTTP-Redirect binding # only for this endpoint. "binding": ( - "urn:oasis:names:tc:SAML:2.0:bindings:" - "HTTP-Redirect" + "urn:oasis:names:tc:SAML:2.0:bindings:" "HTTP-Redirect" ), }, # Public X.509 certificate of the IdP @@ -109,9 +106,7 @@ "wantMessagesSigned": False, "wantNameId": True, "wantNameIdEncrypted": False, - "digestAlgorithm": ( - "http://www.w3.org/2001/04/xmlenc#sha256" - ), + "digestAlgorithm": ("http://www.w3.org/2001/04/xmlenc#sha256"), }, }, # Account Mapping @@ -120,9 +115,7 @@ # "name": "urn:oid:2.5.4.3", # "cn" "name": "urn:oid:2.5.4.42", # "givenName" "surname": "urn:oid:2.5.4.4", # "sn" - "external_id": ( - "urn:oid:2.16.840.1.113730.3.1.3" - ), # "employeeNumber" + "external_id": ("urn:oid:2.16.840.1.113730.3.1.3"), # "employeeNumber" }, # FIXME: new entity id url, assertion consumer service url, # certificate # "title", 'urn:oid:2.5.4.12': ['Hc Developer'], @@ -146,3 +139,203 @@ } } } + + +idp_responses = { + "joanjett": { # ORCID login + "raw_data": { + "urn:oid:2.5.4.4": ["Jett"], + "urn:oid:2.5.4.3": ["Joan Jett"], + "urn:oid:2.5.4.12": ["Masters Student"], + "urn:oid:2.5.4.42": ["Joan"], + "urn:oid:2.5.4.10": ["Uc Davis"], + "urn:oid:0.9.2342.19200300.100.1.3": ["jj@inveniosoftware.com"], + "urn:oid:2.16.840.1.113730.3.1.3": ["joanjett"], + "urn:oid:0.9.2342.19200300.100.1.1": [ + "0000-0001-5847-8734@orcid-gateway.hcommons.org" + ], + "urn:oid:1.3.6.1.4.1.5923.1.5.1.1": ["CO:COU:HC:members:active"], + "urn:oid:1.3.6.1.4.1.49574.110.13": [ + "https://orcid-gateway.hcommons.org/simplesaml/saml2/idp" + ], + "urn:oid:1.3.6.1.4.1.49574.110.10": ["ORCID Login"], + "urn:oid:1.3.6.1.4.1.49574.110.11": ["Humanities Commons"], + "urn:oid:1.3.6.1.4.1.49574.110.12": ["Humanities Commons"], + }, + "extracted_data": { + "user": { + "email": "jj@inveniosoftware.com", + "profile": { + "username": "knowledgeCommons-joanjett", + "full_name": "Joan Jett", + }, + }, + "external_id": "joanjett", + "external_method": "knowledgeCommons", + "active": True, + "confirmed_at": datetime.datetime( + 2025, 1, 14, 4, 28, 58, 725756, tzinfo=datetime.timezone.utc + ), + }, + }, + "user1": { # Google login + "raw_data": { + "urn:oid:2.5.4.4": ["One"], # surname + "urn:oid:2.5.4.3": ["User Number One"], # registered name (single value) + "urn:oid:2.5.4.12": ["Independent Scholar"], # title (role or job title) + "urn:oid:2.5.4.42": ["User Number"], # givenName + "urn:oid:0.9.2342.19200300.100.1.3": ["user1@inveniosoftware.org"], # mail + "urn:oid:2.16.840.1.113730.3.1.3": ["user1"], # employeeNumber + "urn:oid:0.9.2342.19200300.100.1.1": [ # uid + "100103028069838784737+google.com@google-gateway.hcommons.org" + ], + "urn:oid:1.3.6.1.4.1.5923.1.5.1.1": [ + "CO:COU:HC:members:active" + ], # isMemberOf + "urn:oid:1.3.6.1.4.1.49574.110.13": [ + "https://google-gateway.hcommons.org/idp/shibboleth" + ], + "urn:oid:1.3.6.1.4.1.49574.110.10": ["Log in with Google"], + "urn:oid:1.3.6.1.4.1.49574.110.11": ["Humanities Commons"], + "urn:oid:1.3.6.1.4.1.49574.110.12": ["Humanities Commons"], + }, + "extracted_data": { + "user": { + "email": "user1@inveniosoftware.org", + "profile": { + "username": "knowledgeCommons-user1", + "full_name": "User Number One", + "affiliations": "Independent Scholar", + }, + }, + "external_id": "user1", + "external_method": "knowledgeCommons", + "active": True, + "confirmed_at": datetime.datetime( + 2025, 1, 15, 15, 27, 0, 60172, tzinfo=datetime.timezone.utc + ), + }, + }, + "user2": { # MSU okta login + "raw_data": { + "urn:oid:2.5.4.4": ["Doe"], # surname + "urn:oid:2.5.4.3": ["Jane Doe"], # registered name (single value) + "urn:oid:2.5.4.12": ["Assistant Professor"], # title (role or job title) + "urn:oid:2.5.4.42": ["Jane"], # givenName + "urn:oid:2.5.4.10": ["College Of Human Medicine"], # department + "urn:oid:0.9.2342.19200300.100.1.3": ["jane.doe@msu.edu"], # mail + "urn:oid:2.16.840.1.113730.3.1.3": ["janedoe"], # employeeNumber + "urn:oid:0.9.2342.19200300.100.1.1": ["jane.doe@msu.edu"], # uid + "urn:oid:1.3.6.1.4.1.5923.1.5.1.1": [ + "CO:COU:HC:members:active" + ], # isMemberOf + "urn:oid:1.3.6.1.4.1.49574.110.13": [ + "http://www.okta.com/exk14psg1ywhVgvYL358" + ], + }, + "extracted_data": { + "user": { + "email": "jane.doe@msu.edu", + "profile": { + "username": "knowledgeCommons-janedoe", + "full_name": "Jane Doe", + "affiliations": "College Of Human Medicine", + }, + }, + "external_id": "janedoe", + "external_method": "knowledgeCommons", + "active": True, + "confirmed_at": datetime.datetime( + 2025, 1, 15, 15, 24, 45, 598091, tzinfo=datetime.timezone.utc + ), + }, + }, + "user3": { # Local KC login + # FIXME: Unobfuscated email not sent by KC because no email + # address is marked as "official" + "raw_data": { + "urn:oid:2.5.4.4": ["Hc"], # surname + "urn:oid:2.5.4.3": ["Ghost Hc"], # registered name (single value) + "urn:oid:2.5.4.12": ["Tester"], # title (role or job title) + "urn:oid:2.5.4.42": ["Ghost"], # givenName + "urn:oid:2.16.840.1.113730.3.1.3": ["gihctester"], # employeeNumber + "urn:oid:0.9.2342.19200300.100.1.1": [ + "gihctester@hc-idp.hcommons.org" + ], # uid + "urn:oid:1.3.6.1.4.1.5923.1.5.1.1": [ + "CO:COU:ARLISNA:members:active", + "CO:COU:HASTAC:members:active", + "CO:COU:HC:members:active", + "CO:COU:MLA:members:active", + "CO:COU:MSU:members:active", + "CO:COU:SAH:members:active", + "CO:COU:STEMEDPLUS:members:active", + "CO:COU:UP:members:active", + "Humanities Commons:HASTAC_Educational and Cultural Institutions", + "Humanities Commons:HASTAC_Humanities, Arts, and Media", + "Humanities Commons:HASTAC_Publishing and Archives", + "Humanities Commons:HASTAC_Social and Political Issues", + "Humanities Commons:HASTAC_Teaching and Learning", + "Humanities Commons:HASTAC_Technology, Networks, and Sciences", + ], # isMemberOf + "urn:oid:1.3.6.1.4.1.49574.110.13": [ + "https://hc-idp.hcommons.org/idp/shibboleth" + ], + "urn:oid:1.3.6.1.4.1.49574.110.10": ["HC Login"], + "urn:oid:1.3.6.1.4.1.49574.110.11": ["Humanities Commons IdPofLR"], + "urn:oid:1.3.6.1.4.1.49574.110.12": ["Humanities Commons IdPofLR"], + }, + "extracted_data": { + "user": { + "email": None, # FIXME: Unobfuscated email not sent by + # KC because no email marked as official + "profile": { + "full_name": "Ghost Hc", + "username": "knowledgeCommons-gihctester", + }, + }, + "external_id": "gihctester", + "external_method": "knowledgeCommons", + "active": True, + "confirmed_at": datetime.datetime( + 2025, 1, 15, 15, 24, 45, 598091, tzinfo=datetime.timezone.utc + ), + }, + }, + "user4": { # Local KC login (this time with official email) + "raw_data": { + "urn:oid:2.5.4.4": ["Tester"], + "urn:oid:2.5.4.3": ["Ghost Tester"], + "urn:oid:2.5.4.12": ["Tester"], + "urn:oid:2.5.4.42": ["Ghost"], + "urn:oid:2.5.4.10": ["Knowledge Commons"], + "urn:oid:0.9.2342.19200300.100.1.3": [ + "jrghosttester@email.ghostinspector.com" + ], + "urn:oid:2.16.840.1.113730.3.1.3": ["ghostrjtester"], + "urn:oid:0.9.2342.19200300.100.1.1": ["ghostrjtester@hc-idp.hcommons.org"], + "urn:oid:1.3.6.1.4.1.5923.1.5.1.1": ["CO:COU:HC:members:active"], + "urn:oid:1.3.6.1.4.1.49574.110.13": [ + "https://hc-idp.hcommons.org/idp/shibboleth" + ], + "urn:oid:1.3.6.1.4.1.49574.110.10": ["HC Login"], + "urn:oid:1.3.6.1.4.1.49574.110.11": ["Humanities Commons IdPofLR"], + "urn:oid:1.3.6.1.4.1.49574.110.12": ["Humanities Commons IdPofLR"], + }, + "extracted_data": { + "user": { + "email": "jrghosttester@email.ghostinspector.com", + "profile": { + "full_name": "Ghost Tester", + "username": "knowledgeCommons-ghostrjtester", + }, + }, + "external_id": "ghostrjtester", + "external_method": "knowledgeCommons", + "active": True, + "confirmed_at": datetime.datetime( + 2025, 1, 15, 15, 40, 18, 235822, tzinfo=datetime.timezone.utc + ), + }, + }, +} diff --git a/site/tests/fixtures/search_provisioning.py b/site/tests/fixtures/search_provisioning.py new file mode 100644 index 000000000..ba7971aac --- /dev/null +++ b/site/tests/fixtures/search_provisioning.py @@ -0,0 +1,69 @@ +import pytest +from celery import shared_task +from typing import Optional + + +@shared_task(bind=True) +def mock_send_remote_api_update( + self, + identity_id: str = "", + record: dict = {}, + is_published: bool = False, + is_draft: bool = False, + is_deleted: bool = False, + parent: Optional[dict] = None, + latest_version_index: Optional[int] = None, + latest_version_id: Optional[str] = None, + current_version_index: Optional[int] = None, + draft: Optional[dict] = None, + endpoint: str = "", + service_type: str = "", + service_method: str = "", + **kwargs, +): + pass + + +@pytest.fixture +def mock_send_remote_api_update_fixture(mocker): + mocker.patch( + "invenio_remote_api_provisioner.components.send_remote_api_update", # noqa: E501 + mock_send_remote_api_update, + ) + + +@pytest.fixture +def mock_search_api_request(requests_mock): + + def mock_request(http_method, draft_id, metadata, api_url): + mock_response = { + "_internal_id": draft_id, + "_id": "y-5ExZIBwjeO8JmmunDd", + "title": metadata["metadata"]["title"], + "description": metadata["metadata"].get("description", ""), + "owner": {"url": "https://hcommons.org/profiles/myuser"}, + "contributors": [ + { + "name": f"{c['person_or_org'].get('family_name', '')}, " + f"{c['person_or_org'].get('given_name', '')}", + "username": "user1", + "url": "https://hcommons.org/profiles/user1", + "role": "author", + } + for c in metadata["metadata"]["creators"] + ], + "primary_url": f"{api_url}/records/{draft_id}", + "other_urls": [f"{api_url}/records/{draft_id}/files"], + "publication_date": metadata["metadata"]["publication_date"], + "modified_date": "2024-06-07", + "content_type": "work", + "network_node": "works", + } + mock_adapter = requests_mock.request( + http_method, + "https://search.hcommons-dev.org/v1/documents", + json=mock_response, + ) # noqa: E501 + return mock_adapter + + return mock_request diff --git a/site/tests/fixtures/users.py b/site/tests/fixtures/users.py index 37cb52d57..462ce4af8 100644 --- a/site/tests/fixtures/users.py +++ b/site/tests/fixtures/users.py @@ -1,5 +1,6 @@ -from typing import Callable, Optional +from typing import Callable, Optional, Union from flask_login import login_user +from flask_principal import Identity from flask_security.utils import hash_password from invenio_access.models import ActionRoles, Role from invenio_access.permissions import superuser_access @@ -10,11 +11,69 @@ from invenio_oauth2server.models import Token import os import pytest +from pytest_invenio.fixtures import UserFixtureBase +from requests_mock.adapter import _Matcher as Matcher + + +@pytest.fixture(scope="function") +def mock_user_data_api(requests_mock) -> Callable: + """Mock the user data api.""" + + def mock_api_call(saml_id: str, mock_remote_data: dict) -> Matcher: + protocol = os.environ.get( + "INVENIO_COMMONS_API_REQUEST_PROTOCOL", "http" + ) # noqa: E501 + base_url = f"{protocol}://hcommons-dev.org/wp-json/commons/v1/users" + remote_url = f"{base_url}/{saml_id}" + mock_adapter = requests_mock.get( + remote_url, + json=mock_remote_data, + ) + return mock_adapter + + return mock_api_call + + +@pytest.fixture(scope="function") +def user_data_to_remote_data(requests_mock): + + def convert_user_data_to_remote_data( + saml_id: str, email: str, user_data: dict + ) -> dict[str, Union[str, list[dict[str, str]]]]: + mock_remote_data = { + "username": saml_id, + "email": email, + "name": user_data.get("name", ""), + "first_name": user_data.get("first_name", ""), + "last_name": user_data.get("last_name", ""), + "institutional_affiliation": user_data.get("institutional_affiliation", ""), + "orcid": user_data.get("orcid", ""), + "preferred_language": user_data.get("preferred_language", ""), + "time_zone": user_data.get("time_zone", ""), + "groups": user_data.get("groups", ""), + } + return mock_remote_data + + return convert_user_data_to_remote_data + + +class AugmentedUserFixture(UserFixtureBase): + """Augmented UserFixtureBase class.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.mock_adapter: Optional[Matcher] = None + self.allowed_token: Optional[str] = None @pytest.fixture(scope="function") def user_factory( - UserFixture, app, db, admin_role_need, requests_mock + app, + db, + admin_role_need, + requests_mock, + mock_user_data_api, + user_data_to_remote_data, ) -> Callable: """Factory for creating test users. @@ -30,7 +89,7 @@ def make_user( saml_src: Optional[str] = "knowledgeCommons", saml_id: Optional[str] = "myuser", new_remote_data: dict = {}, - ) -> UserFixture: + ) -> AugmentedUserFixture: """Create a user. Args: @@ -51,32 +110,13 @@ def make_user( """ # Mock remote data that's already in the user fixture. - mock_remote_data = { - "username": saml_id, - "email": email, - "name": new_remote_data.get("name", ""), - "first_name": new_remote_data.get("first_name", ""), - "last_name": new_remote_data.get("last_name", ""), - "institutional_affiliation": new_remote_data.get( - "institutional_affiliation", "" - ), - "orcid": new_remote_data.get("orcid", ""), - "preferred_language": new_remote_data.get( - "preferred_language", "" - ), - "time_zone": new_remote_data.get("time_zone", ""), - "groups": new_remote_data.get("groups", ""), - } - - # Mock the remote api call. - base_url = "https://hcommons-dev.org/wp-json/commons/v1/users" - remote_url = f"{base_url}/{saml_id}" - mock_adapter = requests_mock.get( - remote_url, - json=mock_remote_data, + mock_remote_data = user_data_to_remote_data( + saml_id, new_remote_data.get("email") or email, new_remote_data ) + # Mock the remote api call. + mock_adapter = mock_user_data_api(saml_id, mock_remote_data) - u = UserFixture( + u = AugmentedUserFixture( email=email, password=hash_password(password), ) @@ -117,9 +157,7 @@ def admin_role_need(db): role = Role(name="administration-access") db.session.add(role) - action_role = ActionRoles.create( - action=administration_access_action, role=role - ) + action_role = ActionRoles.create(action=administration_access_action, role=role) db.session.add(action_role) db.session.commit() @@ -127,10 +165,10 @@ def admin_role_need(db): @pytest.fixture(scope="function") -def admin(user_factory): +def admin(user_factory) -> AugmentedUserFixture: """Admin user for requests.""" - u = user_factory( + u: AugmentedUserFixture = user_factory( email="admin@inveniosoftware.org", password="password", admin=True, @@ -163,7 +201,7 @@ def superuser_role_need(db): @pytest.fixture(scope="function") -def superuser_identity(admin, superuser_role_need): +def superuser_identity(admin: AugmentedUserFixture, superuser_role_need) -> Identity: """Superuser identity fixture.""" identity = admin.identity identity.provides.add(superuser_role_need) @@ -171,17 +209,17 @@ def superuser_identity(admin, superuser_role_need): @pytest.fixture(scope="module") -def user1_data(): +def user1_data() -> dict: """Data for user1.""" return { "saml_id": "user1", "email": "user1@inveniosoftware.org", - "name": "User One", - "first_name": "User", + "name": "User Number One", + "first_name": "User Number", "last_name": "One", "institutional_affiliation": "Michigan State University", - "orcid": "123-456-7891", + "orcid": "0000-0002-1825-0097", # official dummy orcid "preferred_language": "en", "time_zone": "UTC", "groups": [ @@ -191,6 +229,96 @@ def user1_data(): } +user_data_set = { + "joanjett": { + "saml_id": "joanjett", + "email": "jj@inveniosoftware.com", + "name": "Joan Jett", + "first_name": "Joan", + "last_name": "Jett", + "institutional_affiliation": "Uc Davis", + "orcid": "", + "groups": [], + }, + "user1": { + "saml_id": "user1", + "email": "user1@inveniosoftware.org", + "name": "User Number One", + "first_name": "User Number", + "last_name": "One", + "institutional_affiliation": "Michigan State University", + "orcid": "0000-0002-1825-0097", # official dummy orcid + "preferred_language": "en", + "time_zone": "UTC", + "groups": [ + {"id": 12345, "name": "awesome-mock", "role": "admin"}, + {"id": 67891, "name": "admin", "role": "member"}, + ], + }, + "user2": { + "saml_id": "janedoe", + "email": "jane.doe@msu.edu", + "name": "Jane Doe", + "first_name": "Jane", + "last_name": "Doe", + "institutional_affiliation": "College Of Human Medicine", + "orcid": "0000-0002-1825-0097", # official dummy orcid + }, + "user3": { + "saml_id": "gihctester", + "email": "ghosthc@lblyoehp.mailosaur.net", + # FIXME: Unobfuscated email not sent by + # KC because no email marked as official. + # Also, different email address than shown in KC profile. + "name": "Ghost Hc", + "first_name": "Ghost", + "last_name": "Hc", + "groups": [ + {"id": 1004089, "name": "Teaching and Learning", "role": "member"}, + {"id": 1004090, "name": "Humanities, Arts, and Media", "role": "member"}, + { + "id": 1004091, + "name": "Technology, Networks, and Sciences", + "role": "member", + }, + {"id": 1004092, "name": "Social and Political Issues", "role": "member"}, + { + "id": 1004093, + "name": "Educational and Cultural Institutions", + "role": "member", + }, + {"id": 1004094, "name": "Publishing and Archives", "role": "member"}, + {"id": 1004651, "name": "Hidden Testing Group New Name", "role": "admin"}, + {"id": 1004939, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004940, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004941, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004942, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004943, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004944, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004945, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004946, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004947, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004948, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004949, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004950, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004951, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004952, "name": "GI Hidden Group for testing", "role": "admin"}, + {"id": 1004953, "name": "GI Hidden Group for testing", "role": "admin"}, + ], + }, + "user4": { + "saml_id": "ghostrjtester", + "email": "jrghosttester@email.ghostinspector.com", + "name": "Ghost Tester", + "first_name": "Ghost", + "last_name": "Tester", + "institutional_affiliation": "Michigan State University", + "orcid": "0000-0002-1825-0097", # official dummy orcid + "groups": [], + }, +} + + @pytest.fixture(scope="function") def client_with_login(requests_mock, app): """Log in a user to the client. @@ -220,11 +348,11 @@ def log_in_user( "last_name": user.user_profile.get("last_name", ""), "institutional_affiliation": user.user_profile.get( "affiliations", "" - ), + ), # noqa: E501 "orcid": user.user_profile.get("orcid", ""), "preferred_language": user.user_profile.get( "preferred_language", "" - ), + ), # noqa: E501 "time_zone": user.user_profile.get("time_zone", ""), "groups": user.user_profile.get("groups", ""), } @@ -233,7 +361,10 @@ def log_in_user( mock_remote_data.update(new_remote_data) # Mock the remote api call. - base_url = "https://hcommons-dev.org/wp-json/commons/v1/users" + protocol = os.environ.get( + "INVENIO_COMMONS_API_REQUEST_PROTOCOL", "http" + ) # noqa: E501 + base_url = f"{protocol}://hcommons-dev.org/wp-json/commons/v1/users" remote_url = f"{base_url}/{saml_id}" mock_adapter = requests_mock.get( remote_url, From 11e88be8d38b861fd6e2d130c9b0e451450e22e0 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 09:35:04 -0500 Subject: [PATCH 12/23] fix(user-data): Fixes to user data sync errors when affiliation missing; allow fetching missing email during saml authentication; ensure email address changes are propogated --- invenio.cfg | 17 ++----- .../invenio-remote-user-data-kcworks | 2 +- site/kcworks/services/accounts/saml.py | 49 ++++++++++++++----- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/invenio.cfg b/invenio.cfg index 08ea1bec3..d34667a97 100644 --- a/invenio.cfg +++ b/invenio.cfg @@ -1557,19 +1557,10 @@ SSO_SAML_IDPS = { "external_id": ( "urn:oid:2.16.840.1.113730.3.1.3" ), # "employeeNumber" - }, # FIXME: new entity id url, assertion consumer service url, - # certificate - # "title", 'urn:oid:2.5.4.12': ['Hc Developer'], - # 'urn:oid:0.9.2342.19200300.100.1.1': - # ['100103028069838784737+google.com@commons.mla.org'], - # "isMemberOf", 'urn:oid:1.3.6.1.4.1.5923.1.5.1.1': - # ['CO:COU:HC:members:active'], - # 'urn:oid:1.3.6.1.4.1.49574.110.13': - # ['https://google-gateway.hcommons-dev.org/idp/shibboleth'], - # 'urn:oid:1.3.6.1.4.1.49574.110.10': ['Google login'], - # 'urn:oid:1.3.6.1.4.1.49574.110.11': ['Humanities Commons'], - # 'urn:oid:1.3.6.1.4.1.49574.110.12': ['Humanities Commons']} - # Inject your remote_app to handler + "title": "urn:oid:2.5.4.12", # "Hc Developer" + "isMemberOf": "urn:oid:1.3.6.1.4.1.5923.1.5.1.1", + # ['CO:COU:HC:members:active'] + }, # Note: keep in mind the string should match # given name for authentication provider "acs_handler": acs_handler_factory( diff --git a/site/kcworks/dependencies/invenio-remote-user-data-kcworks b/site/kcworks/dependencies/invenio-remote-user-data-kcworks index fba8c135c..4eef862a8 160000 --- a/site/kcworks/dependencies/invenio-remote-user-data-kcworks +++ b/site/kcworks/dependencies/invenio-remote-user-data-kcworks @@ -1 +1 @@ -Subproject commit fba8c135cc45ebf1948e1d75da87ecb513a3e1bd +Subproject commit 4eef862a88edf36276d76f7890c70681b412e7c6 diff --git a/site/kcworks/services/accounts/saml.py b/site/kcworks/services/accounts/saml.py index 5e3262a17..db69a5d0b 100644 --- a/site/kcworks/services/accounts/saml.py +++ b/site/kcworks/services/accounts/saml.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone from flask import current_app from invenio_access.permissions import system_identity +from invenio_accounts.models import User from invenio_accounts.proxies import current_accounts from invenio_oauthclient.errors import AlreadyLinkedError from invenio_remote_user_data_kcworks.proxies import ( @@ -9,7 +10,7 @@ from invenio_saml.invenio_accounts.utils import account_link_external_id -def knowledgeCommons_account_setup(user, account_info): +def knowledgeCommons_account_setup(user: User, account_info: dict) -> bool: """SAML account setup which extends invenio_saml default. The default only links ``User`` and ``UserIdentity``. This @@ -44,7 +45,7 @@ def knowledgeCommons_account_setup(user, account_info): return False -def knowledgeCommons_account_info(attributes, remote_app): +def knowledgeCommons_account_info(attributes: dict, remote_app: str) -> dict: """Return account info for remote user. This function uses the mappings configuration variable inside your IdP @@ -64,20 +65,44 @@ def knowledgeCommons_account_info(attributes, remote_app): mappings = remote_app_config["mappings"] - name = attributes[mappings["name"]][0] - surname = attributes[mappings["surname"]][0] - email = attributes[mappings["email"]][0] - external_id = attributes[mappings["external_id"]][0].lower() - username = ( - remote_app + "-" + external_id.split("@")[0].lower() - if "@" in external_id - else remote_app + "-" + external_id.lower() - ) + try: + external_id = attributes[mappings["external_id"]][0].lower() + username = ( + remote_app + "-" + external_id.split("@")[0].lower() + if "@" in external_id + else remote_app + "-" + external_id.lower() + ) + name = attributes.get(mappings["name"], [None])[0] + surname = attributes.get(mappings["surname"], [None])[0] + email = attributes.get(mappings["email"], [None])[0] + affiliations = "" + + if email is None: + remote_data: dict = current_remote_user_data_service.fetch_from_remote_api( + remote_app, external_id + ) + print(f"Remote data: {remote_data}") + email: str = remote_data.get("users", {}).get("email", None) + assert email is not None + except KeyError: + raise ValueError( + f"Missing required KC account username in SAML response from IDP: no " + f"entity with key {mappings['external_id']}" + ) + except AssertionError: + raise ValueError( + f"Missing required KC account email in SAML response from IDP: no " + f"entity with key {mappings['email']} and fetch from KC api failed" + ) return dict( user=dict( email=email, - profile=dict(username=username, full_name=name + " " + surname), + profile=dict( + username=username, + full_name=name + " " + surname, + affiliations=affiliations, + ), ), external_id=external_id, external_method=remote_app, From 2b5839c9a900503d15ded4a8ee14246b599ab7e7 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 09:35:53 -0500 Subject: [PATCH 13/23] fix(search-provisioning): WIP changes to get search provisioning tests to pass --- site/kcworks/api_helpers.py | 52 +++++++------------ .../invenio-remote-api-provisioner | 2 +- 2 files changed, 19 insertions(+), 35 deletions(-) diff --git a/site/kcworks/api_helpers.py b/site/kcworks/api_helpers.py index 2a4a4df27..0188141a3 100644 --- a/site/kcworks/api_helpers.py +++ b/site/kcworks/api_helpers.py @@ -66,9 +66,7 @@ def format_commons_search_payload( **kwargs, ) -> dict: """Format payload for external service.""" - UI_URL_BASE = os.environ.get( - "INVENIO_SITE_UI_URL", "http://works.kcommons.org" - ) + UI_URL_BASE = os.environ.get("INVENIO_SITE_UI_URL", "http://works.kcommons.org") API_URL_BASE = os.environ.get( "INVENIO_SITE_API_URL", "http://works.kcommons.org/api" ) @@ -96,9 +94,7 @@ def format_commons_search_payload( if data.get("metadata", {}): meta = { "title": re.sub("<.*?>", "", data["metadata"].get("title", "")), - "description": re.sub( - "<.*?>", "", data["metadata"].get("description", "") - ), + "description": re.sub("<.*?>", "", data["metadata"].get("description", "")), "publication_date": data["metadata"].get("publication_date", ""), "modified_date": arrow.utcnow().format("YYYY-MM-DD"), "contributors": [], @@ -164,9 +160,7 @@ def format_commons_search_payload( payload["other_urls"].append(u["identifier"]) if "files" in data.keys() and data["files"].get("enabled") is True: - payload["other_urls"].append( - f"{API_URL_BASE}/records/{data['id']}/files" - ) + payload["other_urls"].append(f"{API_URL_BASE}/records/{data['id']}/files") # FIXME: use marshmallow schema to validate payload here return payload @@ -185,9 +179,7 @@ def format_commons_search_collection_payload( current_app.logger.debug("owner") current_app.logger.debug(owner) - UI_URL_BASE = os.environ.get( - "INVENIO_SITE_UI_URL", "http://works.kcommons.org" - ) + UI_URL_BASE = os.environ.get("INVENIO_SITE_UI_URL", "http://works.kcommons.org") API_URL_BASE = os.environ.get( "INVENIO_SITE_API_URL", "http://works.kcommons.org/api" ) @@ -233,9 +225,7 @@ def format_commons_search_collection_payload( } if data.get("metadata", {}): meta = { - "title": re.sub( - "<.*?>", "", data["metadata"].get("title", "") - ), + "title": re.sub("<.*?>", "", data["metadata"].get("title", "")), "description": re.sub( "<.*?>", "", data["metadata"].get("description", "") ), @@ -250,9 +240,7 @@ def format_commons_search_collection_payload( else: payload["publication_date"] = arrow.utcnow().format("YYYY-MM-DD") if data.get("updated"): - payload["modified_date"] = arrow.get(data["updated"]).format( - "YYYY-MM-DD" - ) + payload["modified_date"] = arrow.get(data["updated"]).format("YYYY-MM-DD") else: payload["modified_date"] = arrow.utcnow().format("YYYY-MM-DD") # FIXME: Add contributors??? @@ -265,11 +253,13 @@ def format_commons_search_collection_payload( @shared_task( ignore_result=False, + bind=True, # autoretry_for=(Exception,), # retry_backoff=True, # retry_kwargs={"max_retries": 1}, ) def record_commons_search_recid( + self, response_json: dict, service_type: str = "", service_method: str = "", @@ -294,9 +284,7 @@ def record_commons_search_recid( editing_draft = service.edit(system_identity, id_=draft["id"]) new_metadata = editing_draft.to_dict() - search_id = new_metadata.get("custom_fields", {}).get( - "kcr:commons_search_recid" - ) + search_id = new_metadata.get("custom_fields", {}).get("kcr:commons_search_recid") if record.get("access", {}).get("record") != "public": if search_id: @@ -308,9 +296,9 @@ def record_commons_search_recid( if response_json.get("_id"): # NOTE: No id is returned for updates if not search_id or search_id != response_json["_id"]: - new_metadata["custom_fields"]["kcr:commons_search_recid"] = ( - response_json["_id"] - ) + new_metadata["custom_fields"]["kcr:commons_search_recid"] = response_json[ + "_id" + ] new_metadata["custom_fields"][ "kcr:commons_search_updated" ] = arrow.utcnow().isoformat() @@ -326,9 +314,7 @@ def record_commons_search_recid( data=new_metadata, ) - new_draft = service.read_draft( - system_identity, draft["id"] - ).to_dict() + new_draft = service.read_draft(system_identity, draft["id"]).to_dict() published = service.publish(system_identity, draft["id"]) @@ -350,9 +336,11 @@ def record_commons_search_recid( @shared_task( - ignore_result=False, # retry_backoff=True, retry_kwargs={"max_retries": 5} + ignore_result=False, + bind=True, # retry_backoff=True, retry_kwargs={"max_retries": 5} ) def record_commons_search_collection_recid( + self, response_json: dict, service_type: str = "", service_method: str = "", @@ -374,13 +362,9 @@ def record_commons_search_collection_recid( if response_json.get("_id"): # No id is returned for updates try: time.sleep(5) - current_app.logger.debug( - f"Record ID: {record_id}, draft ID: {draft_id}" - ) + current_app.logger.debug(f"Record ID: {record_id}, draft ID: {draft_id}") try: - record_data = service.read( - system_identity, record_id - ).to_dict() + record_data = service.read(system_identity, record_id).to_dict() except PIDDoesNotExistError: records = service.search( system_identity, q=f"slug:{payload_object['_internal_id']}" diff --git a/site/kcworks/dependencies/invenio-remote-api-provisioner b/site/kcworks/dependencies/invenio-remote-api-provisioner index f4a0f8909..7326d85bf 160000 --- a/site/kcworks/dependencies/invenio-remote-api-provisioner +++ b/site/kcworks/dependencies/invenio-remote-api-provisioner @@ -1 +1 @@ -Subproject commit f4a0f890905ca0400ea4b2be2cb3793152fd15be +Subproject commit 7326d85bf9e9567e84941f336661585a26ed09f2 From c921f9ff0bbc0f08df24590130285cb32095b4e6 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 10:44:13 -0500 Subject: [PATCH 14/23] fix(docs): Fleshed out resource type docs --- docs/source/metadata.md | 119 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 11 deletions(-) diff --git a/docs/source/metadata.md b/docs/source/metadata.md index 86459d5e0..d62493640 100644 --- a/docs/source/metadata.md +++ b/docs/source/metadata.md @@ -2,10 +2,11 @@ The default metadata schema for InvenioRDM records is defined in the `invenio-rdm-records` package and documented [here](https://inveniordm.docs.cern.ch/reference/metadata/). It also includes a number of optional metadata fields which have been enabled in KCWorks, documented [here](https://inveniordm.docs.cern.ch/reference/metadata/optional_metadata/). -Beyond these InvenioRDM fields, KCWorks adds a number of custom metadata fields to the schema using InvenioRDM's custom field mechanism. These are all located in the top-level `custom_fields` field of the record metadata. They are prefixed with two different namespaces: - -- `kcr`: custom fields that are used to store data from the KC system. These fields **may** be used for new data, but are not required. -- `hclegacy`: custom fields that are used to store data from the legacy CORE repository. These fields **must not** be used for new data. +In this documentation we provide +1. A full example of a KCWorks record metadata object +2. A list of the controlled vocabularies and identifier schemes supported by KCWorks +3. Discussion of how some of the standard InvenioRDM metadata fields are used in KCWorks +4. A list of the custom metadata fields KCWorks adds to the base InvenioRDM schema ## Example metadata record @@ -267,17 +268,108 @@ The FAST vocabulary is augmented in KCWorks by the Homosaurus vocabulary (https: #### Resource types -Prior to the start of development on the KCWorks repository we did a deep dive into the metadata structures and resource types supported by the other large scholarly repositories, including Dryad, arXiv, and Zenodo. As technology has changed scholarship has taken on more forms, including virtual reality, podcasts, and even role-playing and video games. The InvenioRDM platform gave us the opportunity to customize our metadata further by resource type (as defined by the Datacite schema). As an open repository that serves a multidisciplinary audience we created a list that combines the resource types from multiple sources. - -We have broken down resource types over eight categories, including support for various art objects, multimedia files, online course files, virtual reality, and software. Each category - Dataset, Image, Instructional Resource, Presentation, Publication, Software, Audiovisual, and Other - has within it specific metadata fields that provide the ability to fully describe the object. - -The eight top resource types and many of the subtypes are derived from the list in [the Datacite schema under resourceTypeGeneral](https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/resourceTypeGeneral/). The rest derived from our original resource types in the [CORE repository](https://works.hcommons.org/records/f9xww-xwr22), launched in 2016 as part of Humanities Commons. + As an open repository that serves a multidisciplinary audience, KCWorks uses a custom vocabulary of resource types designed (a) to support the wide variety of scholarly materials we accept and (b) to facilitate ease of use for depositors. The terms in this vocabulary are mapped to DataCite's [resourceTypeGeneral](https://schema.datacite.org/meta/kernel-4.4/doc/DataCite_Schema_v4.4.pdf) vocabulary and a number of other resource type vocabularies (COAR, CSL, EUREPO, Schema.org). This allows correct export of metadata to DataCite and in other metadata formats. + +InvenioRDM employs a hierarchical structure of resource types, each of which has a number of subtypes. In KCWorks the 8 top-level resource types are: + +- audiovisual +- dataset +- image +- instructionalResource +- presentation +- software +- textDocument +- other + +We selected these top-level types in part to allow division of the many subtypes into manageable groups. This allows us to provide a wide range of resource types while also allowing users to easily find the resource type that best fits their deposit. + +Beneath these top-level types are a number of subtypes, which are listed below. Where the DataCite schema allows free-text, arbitrary subtypes, we have followed InvenioRDM's approach of using a controlled vocabulary of subtypes. Where our list of top-level types is short, we have erred on the side of including more subtypes. Again, this allows us to support a wide range of materials without forcing them to choose a subtype that does not fit. It also allows us to tailor the user interface of the upload form to the specific subtype of the record being deposited, preventing the confusion and overwhelm of users being presented with many metadata fields which are not relevant to their material. + +The following is the complete list of KCWorks resource types with their subtypes. This list may be expanded in the future. + +- audiovisual + - documentary + - interviewRecording + - musicalRecording + - other + - performance + - podcastEpisode + - audioRecording + - videoRecording +- dataset +- image + - chart + - diagram + - figure + - map + - visualArt + - photograph + - other +- instructionalResource + - curriculum + - lessonPlan + - syllabus + - other +- presentation + - conferencePaper + - conferencePoster + - presentationText + - slides + - other +- software + - 3DModel + - application + - computationalModel + - computationalNotebook + - service + - other +- textDocument + - abstract + - bibliography + - blogPost + - book + - bookSection + - conferenceProceeding + - dataManagementPlan + - documentation + - editorial + - essay + - interviewTranscript + - journalArticle + - legalComment + - legalResponse + - magazineArticle + - monograph + - newspaperArticle + - onlinePublication + - poeticWork + - preprint + - report + - workingPaper + - review + - technicalStandard + - thesis + - whitePaper + - other +- other + - catalog + - collection + - event + - interactiveResource + - notes + - patent + - peerReview + - physicalObject + - workflow + +Note that (like with the base InvenioRDM resource types), neither the list of top-level resource types nor the list of subtypes exactly matches the vocabulary provided by DataCite under [resourceTypeGeneral](https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/resourceTypeGeneral/). Those types are all included in the KCWorks vocabulary--some as top-level types, some as subtypes. But because we do not follow DataCite in allowing arbitrary free-text subtypes, we have needed to greatly expand the list of subtypes to support the wide variety of materials we accept. As mentioned above, however, each subtype is mapped to a DataCite resourceTypeGeneral value for correct export to DataCite and other metadata formats. + +You can compare the KCWorks resource types with the list from the original Humanities Commons CORE repository [here](https://works.hcommons.org/records/f9xww-xwr22). The KCWorks resource type vocabulary is not structured in the same way as the CORE vocabulary (which was a flat list), but the KCWorks subtypes encompass all of the original CORE types. -Each top level category - Dataset, Image, Instructional Resource, Presentation, Publication, Software, Audiovisual, and Other - has within it specific metadata fields that provide the ability to fully describe the object. As we mapped our existing resource types we had to add two custom types to support legal scholars: Legal Comment and Legal Response, which support the Michigan State University School of Law and their deposits of legal scholarship. #### Creator/contributor roles -Keeping with our support for a wide variety of objects and disciplines, our creator roles are more diverse than just "author," "editor," or "translator." For contribuors we were influenced by the [CRediT Taxonomy](https://credit.niso.org/), finding ways of recognizing labor even when the contribution is not immediately visible. Included in both creator and contributor roles a selection of types taken from the [Variations Metadata](https://dlib.indiana.edu/projects/variations3/metadata/guide/controlVocabs/contributorRoles.html) taxonomy, providing the ability to credit those who engage in creative and musical works. +Keeping with our support for a wide variety of objects and disciplines, our creator roles are more diverse than just "author," "editor," or "translator." For contribuors we were influenced by the [CRediT Taxonomy](https://credit.niso.org/), finding ways of recognizing labor even when the contribution is not immediately visible. Included in both creator and contributor roles a selection of types taken from the [Variations Metadata](https://dlib.indiana.edu/projects/variations3/metadata/guide/controlVocabs/contributorRoles.html) taxonomy, providing the ability to credit those who engage in creative and musical works. ## Identifier Schemes @@ -421,6 +513,11 @@ Example: ## KCWorks Custom Fields (kcworks/site/metadata_fields) +Beyond the standard InvenioRDM metadata fields, KCWorks adds a number of custom metadata fields to the schema using InvenioRDM's custom field mechanism. These are all located in the top-level `custom_fields` field of the record metadata. They are prefixed with two different namespaces: + +- `kcr`: custom fields that are used to store data from the KC system. These fields **may** be used for new data, but are not required. +- `hclegacy`: custom fields that are used to store data from the legacy CORE repository. These fields **must not** be used for new data. + ### kcr:ai_usage Type: `Object[boolean, string]` From 0b9f2e1a3e51a04402fed437755aebee22ce13b1 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 10:54:14 -0500 Subject: [PATCH 15/23] fix(docs): Fleshed out creator role docs --- docs/source/metadata.md | 76 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/docs/source/metadata.md b/docs/source/metadata.md index d62493640..b6e773452 100644 --- a/docs/source/metadata.md +++ b/docs/source/metadata.md @@ -371,6 +371,82 @@ You can compare the KCWorks resource types with the list from the original Human Keeping with our support for a wide variety of objects and disciplines, our creator roles are more diverse than just "author," "editor," or "translator." For contribuors we were influenced by the [CRediT Taxonomy](https://credit.niso.org/), finding ways of recognizing labor even when the contribution is not immediately visible. Included in both creator and contributor roles a selection of types taken from the [Variations Metadata](https://dlib.indiana.edu/projects/variations3/metadata/guide/controlVocabs/contributorRoles.html) taxonomy, providing the ability to credit those who engage in creative and musical works. +The complete list of creator/contributor roles is: + +- actor +- adaptor +- annotator +- analyst +- arranger +- artisan +- artist +- attributedName +- author +- authorOfIntroduction +- authorOfForeword +- authorOfAfterword +- committeeChair +- choreographer +- cinematographer +- collaborator +- collector +- committeeMember +- composer +- conductor +- consultant +- contactperson +- correspondent +- datacollector +- datacurator +- datamanager +- dedicatee +- designer +- director +- distributor +- donor +- drafter +- editor +- examiner +- formerOwner +- hostinginstitution +- illustrator +- interviewee +- interviewer +- inventor +- juror +- licensee +- lyricist +- manufacturer +- organizer +- owner +- performer +- photographer +- printer +- producer +- projectOrTeamLeader +- projectOrTeamManager +- projectOrTeamMember +- recording engineer +- referee +- registrationagency +- registrationauthority +- relatedperson +- reporter +- researcher +- researchgroup +- researchParticipant +- rightsholder +- screenplayAuthor +- speaker +- supervisor +- transcriber +- translator +- witness +- workpackageleader +- writerOfAccompanying + +Note that where InvenioRDM provides distinct custom vocabularies for creators and contributors, KCWorks employs a single creator/contributor vocabulary. This is in keeping with our handling of the `creators` and `contributors` fields, discussed below. + ## Identifier Schemes ### Works From aff2fcd58caa08bf1519bf2a6966aa9508a779b9 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 10:56:21 -0500 Subject: [PATCH 16/23] fix(docs): Header level tweak --- docs/source/metadata.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/metadata.md b/docs/source/metadata.md index b6e773452..a8d671d88 100644 --- a/docs/source/metadata.md +++ b/docs/source/metadata.md @@ -266,7 +266,7 @@ The FAST controlled vocabulary (https://www.oclc.org/research/areas/data-science The FAST vocabulary is augmented in KCWorks by the Homosaurus vocabulary (https://homosaurus.org/) for subjects related to sexuality and gender identity. See the [metadata.subjects](#metadata.subjects) section for information about how to include Homosaurus subjects in a KCWorks record. -#### Resource types +## Resource types As an open repository that serves a multidisciplinary audience, KCWorks uses a custom vocabulary of resource types designed (a) to support the wide variety of scholarly materials we accept and (b) to facilitate ease of use for depositors. The terms in this vocabulary are mapped to DataCite's [resourceTypeGeneral](https://schema.datacite.org/meta/kernel-4.4/doc/DataCite_Schema_v4.4.pdf) vocabulary and a number of other resource type vocabularies (COAR, CSL, EUREPO, Schema.org). This allows correct export of metadata to DataCite and in other metadata formats. @@ -367,7 +367,7 @@ Note that (like with the base InvenioRDM resource types), neither the list of to You can compare the KCWorks resource types with the list from the original Humanities Commons CORE repository [here](https://works.hcommons.org/records/f9xww-xwr22). The KCWorks resource type vocabulary is not structured in the same way as the CORE vocabulary (which was a flat list), but the KCWorks subtypes encompass all of the original CORE types. -#### Creator/contributor roles +## Creator/contributor roles Keeping with our support for a wide variety of objects and disciplines, our creator roles are more diverse than just "author," "editor," or "translator." For contribuors we were influenced by the [CRediT Taxonomy](https://credit.niso.org/), finding ways of recognizing labor even when the contribution is not immediately visible. Included in both creator and contributor roles a selection of types taken from the [Variations Metadata](https://dlib.indiana.edu/projects/variations3/metadata/guide/controlVocabs/contributorRoles.html) taxonomy, providing the ability to credit those who engage in creative and musical works. From 410db4aa4bae2f7fb4747dc5aac111a5b3782fe6 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 12:50:35 -0500 Subject: [PATCH 17/23] fix(testing): Corrected python version for test run --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 415052c7d..f58d9d9ff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,10 +14,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python 3.9 uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.9' - name: Set up Docker uses: docker/setup-buildx-action@v3 From cb1931903216042f09f7857bd4b5c733700646e0 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 14:35:08 -0500 Subject: [PATCH 18/23] fix(testing): Updates to test runner workflow --- .github/workflows/tests.yml | 102 ++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 21 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f58d9d9ff..331b50cdb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,37 +5,97 @@ on: branches: [ main ] pull_request: branches: [ main ] + schedule: + # * is a special character in YAML so you have to quote this string + - cron: "0 3 * * 6" workflow_dispatch: + inputs: + reason: + description: "Reason" + required: false + default: "Manual trigger" jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Set up Python 3.9 - uses: actions/setup-python@v5 - with: - python-version: '3.9' + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: '3.9' - - name: Set up Docker - uses: docker/setup-buildx-action@v3 + - name: Install prerequisites + run: | + sudo apt-get install -y pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl - - name: Install pipenv - run: | - python -m pip install --upgrade pip - pip install pipenv + - name: set up docker + uses: docker/setup-buildx-action@v3 - - name: Install dependencies - run: pipenv install --dev + - name: Set up Docker Compose + run: | + sudo apt-get update + sudo apt-get install -y docker-compose - - name: Set up Docker Compose - run: | - sudo apt-get update - sudo apt-get install -y docker-compose + - name: Install pipenv + run: | + python -m pip install --upgrade pip + pip install pipenv - - name: Run tests - run: | - chmod +x site/run_test.sh - bash site/run_test.sh + - name: Install python dependencies + run: pipenv install --dev + + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + + - name: Create config files + run: | + echo "[cli]" >> .invenio.private && \ + echo "services_setup=False" >> .invenio.private && \ + echo "instance_path=/opt/invenio/var/instance" >> .invenio.private + touch site/tests/.env + + - name: Run tests + env: + COMMONS_API_TOKEN: ${{ secrets.TEST_COMMONS_API_TOKEN }} + COMMONS_API_TOKEN_PROD: ${{ secrets.TEST_COMMONS_API_TOKEN_PROD }} + COMMONS_SEARCH_API_TOKEN: ${{ secrets.TEST_COMMONS_SEARCH_API_TOKEN }} + INVENIO_SEARCH_DOMAIN: ${{ vars.INVENIO_SEARCH_DOMAIN }} + INVENIO_ADMIN_EMAIL: ${{ secrets.TEST_INVENIO_ADMIN_EMAIL }} + SQLALCHEMY_DATABASE_URI: ${{ vars.TEST_SQLALCHEMY_DATABASE_URI }} + INVENIO_SQLALCHEMY_DATABASE_URI: ${{ vars.TEST_SQLALCHEMY_DATABASE_URI }} + POSTGRESQL_USER: ${{ vars.POSTGRES_USER }} + POSTGRESQL_PASSWORD: ${{ vars.POSTGRES_DB }} + POSTGRESQL_DB: ${{ vars.POSTGRES_DB }} + INVENIO_COMMONS_API_REQUEST_PROTOCOL: https + INVENIO_MAIL_SUPPRESS_SEND: False + SPARKPOST_API_KEY: ${{ secrets.TEST_SPARKPOST_API_KEY }} + SPARKPOST_USERNAME: ${{ secrets.TEST_SPARKPOST_USERNAME }} + run: | + chmod +x site/run_test.sh + cd site + bash run_tests.sh + + # TODO: Add frontend tests + # - name: Run eslint test + # run: ./run-js-linter.sh -i + + # - name: Run translations test + # run: ./run-i18n-tests.sh + + # - name: Install deps for frontend tests + # working-directory: ./site/kcworks/assets/semantic-ui/js/kcworks + # run: npm install + + # - name: Install deps for frontend tests - translations + # working-directory: ./translations/kcworks + # run: npm install + + # - name: Run frontend tests + # working-directory: ./site/kcworks/assets/semantic-ui/js/kcworks + # run: npm test From 7e0ec50208493a743c3966efa08082b0cb2818b4 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 14:53:24 -0500 Subject: [PATCH 19/23] fix(testing): Updates to test runner workflow --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 331b50cdb..778b0c3a2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: true - name: Set up Python 3.9 uses: actions/setup-python@v5 From 0c9f6ceebe8dc5a3e15ac80835ad28ddd0e594ba Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 15:47:09 -0500 Subject: [PATCH 20/23] fix(testing): Updates to test runner workflow --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 778b0c3a2..c7bee8f18 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -79,7 +79,7 @@ jobs: SPARKPOST_API_KEY: ${{ secrets.TEST_SPARKPOST_API_KEY }} SPARKPOST_USERNAME: ${{ secrets.TEST_SPARKPOST_USERNAME }} run: | - chmod +x site/run_test.sh + chmod +x site/run_tests.sh cd site bash run_tests.sh From 6e5df027eb1c3622cefe8045f6b0f0b3f3ccabf7 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Fri, 17 Jan 2025 20:02:16 -0500 Subject: [PATCH 21/23] fix(testing, search-provisioning): Significant work on integration tests for search provisioning; Fixes for provisioning changes at record deletion --- site/kcworks/api_helpers.py | 6 +- .../invenio-remote-api-provisioner | 2 +- site/tests/api/test_api_record_ops.py | 87 +- site/tests/api/test_search_provisioning.py | 828 ++++++++++++++++-- site/tests/fixtures/search_provisioning.py | 19 + site/tests/helpers/fake_datacite_client.py | 15 +- 6 files changed, 864 insertions(+), 93 deletions(-) diff --git a/site/kcworks/api_helpers.py b/site/kcworks/api_helpers.py index 0188141a3..ed290821c 100644 --- a/site/kcworks/api_helpers.py +++ b/site/kcworks/api_helpers.py @@ -418,8 +418,10 @@ def record_publish_url_factory( # NOTE: This condition catches both updates to published records and # removal of records from the commons search index when they are # no longer publicly visible - if draft.get("is_published") and draft.get("custom_fields", {}).get( - "kcr:commons_search_recid" + if ( + record.get("is_published") + and record.get("custom_fields", {}).get("kcr:commons_search_recid") + or draft.get("access", {}).get("record") != "public" ): url = ( f"{protocol}://search.{domain}/v1/documents/" diff --git a/site/kcworks/dependencies/invenio-remote-api-provisioner b/site/kcworks/dependencies/invenio-remote-api-provisioner index 7326d85bf..8faa8ae25 160000 --- a/site/kcworks/dependencies/invenio-remote-api-provisioner +++ b/site/kcworks/dependencies/invenio-remote-api-provisioner @@ -1 +1 @@ -Subproject commit 7326d85bf9e9567e84941f336661585a26ed09f2 +Subproject commit 8faa8ae25b5cb8253325408fcf46a8673f37d4c1 diff --git a/site/tests/api/test_api_record_ops.py b/site/tests/api/test_api_record_ops.py index 6b629b240..27d966168 100644 --- a/site/tests/api/test_api_record_ops.py +++ b/site/tests/api/test_api_record_ops.py @@ -1,6 +1,8 @@ import json import pytest import re +from invenio_access.permissions import system_identity +from invenio_rdm_records.proxies import current_rdm_records_service as records_service from ..fixtures.users import user_data_set import arrow @@ -236,7 +238,7 @@ def test_draft_creation( # @pytest.mark.skip(reason="Not implemented") -def test_record_publication( +def test_record_publication_metadata_only_api( running_app, db, client_with_login, @@ -295,8 +297,50 @@ def test_record_publication( assert not actual_published["ui"]["is_draft"] +def test_record_publication_metadata_only_service( + running_app, + db, + client_with_login, + minimal_record, + headers, + user_factory, + search_clear, + celery_worker, + mock_send_remote_api_update_fixture, +): + """Test that a system user can create a draft record internally.""" + app = running_app.app + + minimal_record.update({"files": {"enabled": False}}) + response = records_service.create(system_identity, minimal_record) + actual_draft = response.to_dict() + actual_draft_id = actual_draft["id"] + + publish_response = records_service.publish(system_identity, actual_draft_id) + + actual_published = publish_response.to_dict() + assert actual_published["id"] == actual_draft_id + assert actual_published["is_published"] + assert not actual_published["is_draft"] + assert actual_published["versions"]["is_latest"] + assert actual_published["versions"]["is_latest_draft"] is True + assert actual_published["versions"]["index"] == 1 + assert actual_published["status"] == "published" + + read_result = records_service.read(system_identity, actual_draft_id) + actual_read = read_result.to_dict() + assert actual_read["id"] == actual_draft_id + assert actual_read["metadata"]["title"] == "A Romans story" + assert actual_read["is_published"] + assert not actual_read["is_draft"] + assert actual_read["versions"]["is_latest"] + assert actual_read["versions"]["is_latest_draft"] is True + assert actual_read["versions"]["index"] == 1 + assert actual_read["status"] == "published" + + @pytest.mark.skip(reason="Not implemented") -def test_record_draft_update( +def test_record_draft_update_metadata_only_api( running_app, db, client_with_login, @@ -308,6 +352,45 @@ def test_record_draft_update( pass +def test_record_draft_update_metadata_only_service( + running_app, + db, + client_with_login, + minimal_record, + headers, + user_factory, + search_clear, + celery_worker, + mock_send_remote_api_update_fixture, +): + app = running_app.app + + minimal_record.update({"files": {"enabled": False}}) + response = records_service.create(system_identity, minimal_record) + actual_draft = response.to_dict() + actual_draft_id = actual_draft["id"] + + minimal_edited = minimal_record.copy() + minimal_edited["metadata"]["title"] = "A Romans Story 2" + edited_draft = records_service.update_draft( + system_identity, actual_draft_id, minimal_edited + ) + actual_edited = edited_draft.to_dict() + actual_edited["metadata"]["title"] = "A Romans Story 2" + + publish_response = records_service.publish(system_identity, actual_edited["id"]) + + actual_published = publish_response.to_dict() + assert actual_published["id"] == actual_draft_id + assert actual_published["metadata"]["title"] == "A Romans Story 2" + assert actual_published["is_published"] + assert not actual_published["is_draft"] + assert actual_published["versions"]["is_latest"] + assert actual_published["versions"]["is_latest_draft"] is True + assert actual_published["versions"]["index"] == 1 + assert actual_published["status"] == "published" + + @pytest.mark.skip(reason="Not implemented") def test_record_published_update( running_app, diff --git a/site/tests/api/test_search_provisioning.py b/site/tests/api/test_search_provisioning.py index be54ebb08..8a81432fa 100644 --- a/site/tests/api/test_search_provisioning.py +++ b/site/tests/api/test_search_provisioning.py @@ -1,117 +1,775 @@ +import pytest import arrow -from invenio_accounts.testutils import login_user_via_session -from invenio_rdm_records.proxies import current_rdm_records from invenio_access.permissions import system_identity +from invenio_communities.proxies import current_communities +from invenio_rdm_records.proxies import current_rdm_records +from invenio_remote_api_provisioner.signals import remote_api_provisioning_triggered +from invenio_queues.proxies import current_queues +import json +from kcworks.api_helpers import ( + format_commons_search_payload, + format_commons_search_collection_payload, +) +import os from pprint import pformat +import time -def test_search_provisioning_at_publication( +def test_trigger_search_provisioning_at_publication( running_app, + search_clear, db, - client, requests_mock, + monkeypatch, + minimal_record, user_factory, + create_records_custom_fields, + celery_worker, + mocker, +): + """Test draft creation. + + This should not prompt any remote API operations. + """ + app = running_app.app + assert app.config["DATACITE_TEST_MODE"] is True + monkeypatch.setenv("MOCK_SIGNAL_SUBSCRIBER", "True") + + mocker.patch( + "invenio_remote_api_provisioner.ext.on_remote_api_provisioning_triggered", + return_value=None, + ) + + rec_url = list(app.config["REMOTE_API_PROVISIONER_EVENTS"]["rdm_record"].keys())[0] + remote_response = { + "_internal_id": "1234AbCD?", # can't mock because set at runtime + "_id": "2E9SqY0Bdd2QL-HGeUuA", + "title": "A Romans Story 2", + "primary_url": "http://works.kcommons.org/records/1234", + } + mock_adapter = requests_mock.request( + "POST", + "https://search.hcommons-dev.org/v1/documents", + json=remote_response, + headers={"Authorization": "Bearer 12345"}, + ) # noqa: E501 + + service = current_rdm_records.records_service + + # Draft creation, no remote API operations should be prompted + draft = service.create(system_identity, minimal_record) + actual_draft = draft.data + assert actual_draft["metadata"]["title"] == "A Romans story" + assert mock_adapter.call_count == 0 + + # Draft edit, no remote API operations should be prompted + minimal_edited = minimal_record.copy() + minimal_edited["metadata"]["title"] = "A Romans Story 2" + edited_draft = service.update_draft(system_identity, draft.id, minimal_edited) + actual_edited = edited_draft.data.copy() + + assert actual_edited["metadata"]["title"] == "A Romans Story 2" + app.logger.debug("actual_edited['id']:") + app.logger.debug(pformat(actual_edited["id"])) + assert mock_adapter.call_count == 0 + app.logger.debug(f"actual_edited: {pformat(actual_edited)}") + + # Publish, now this should prompt a remote API operation + record = service.publish(system_identity, actual_edited["id"]) + actual_published = record.data.copy() + assert actual_published["metadata"]["title"] == "A Romans Story 2" + assert mock_adapter.call_count == 1 + + # variable IS set by subscriber (so then reset to True) + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "rdm_record|publish" + monkeypatch.setenv("MOCK_SIGNAL_SUBSCRIBER", "True") + + read_record = service.read(system_identity, record.id) + assert read_record.data["metadata"]["title"] == "A Romans Story 2" + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "True" # wasn't set by subscriber + assert mock_adapter.call_count == 1 + + # draft new version + # no remote API operation should be prompted + new_version = service.new_version(system_identity, record.id) + app.logger.debug(pformat(new_version.data)) + assert new_version.data["metadata"]["title"] == "A Romans Story 2" + assert new_version.data["status"] == "new_version_draft" + assert new_version.data["is_published"] is False + assert new_version.data["id"] != actual_published["id"] + assert new_version.data["parent"]["id"] == actual_published["parent"]["id"] + assert new_version.data["versions"]["index"] == 2 + assert new_version.data["versions"]["is_latest"] is False + assert new_version.data["versions"]["is_latest_draft"] is True + assert mock_adapter.call_count == 1 + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "True" # wasn't set by subscriber + + # edited draft new version + # no remote API operation should be prompted + new_edited_data = new_version.data.copy() + new_edited_data["metadata"]["publication_date"] = arrow.now().format("YYYY-MM-DD") + new_edited_data["metadata"]["title"] = "A Romans Story 3" + new_edited_data["custom_fields"]["kcr:commons_search_recid"] = remote_response[ + "_id" + ] # simulate the result of previous remote API operation + new_edited_version = service.update_draft( + system_identity, new_version.id, new_edited_data + ) + assert new_edited_version.data["metadata"]["title"] == "A Romans Story 3" + # assert requests_mock.call_count == 1 + assert new_edited_version.data["status"] == "new_version_draft" + assert new_edited_version.data["is_published"] is False + assert new_edited_version.data["versions"]["index"] == 2 + assert new_edited_version.data["versions"]["is_latest"] is False + assert new_edited_version.data["versions"]["is_latest_draft"] is True + assert ( + new_edited_version.data["custom_fields"].get("kcr:commons_search_recid") + == remote_response["_id"] + ) + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "True" # wasn't set by subscriber + + # publish new version + # this should trigger a remote API operation + remote_response_2 = { + "_id": "2E9SqY0Bdd2QL-HGeUuA", + "title": "A Romans Story 3", + "_internal_id": new_edited_version.data["id"], + "content": "", + "content_type": "work", + "contributors": [ + {"name": "Troy Brown", "role": ""}, + {"name": "Troy Inc.", "role": ""}, + ], + "description": "", + "modified_date": "2025-01-17", + "network_node": "works", + "other_urls": [], + "owner": { + "name": "", + "owner_username": None, + "url": "https://hcommons-dev.org/members/None", + }, + "primary_url": "https://localhost/records/sx7xz-c4895", + "publication_date": "2025-01-17", + "thumbnail_url": "", + } + mock_adapter2 = requests_mock.put( + rec_url + "/" + remote_response["_id"], + json=remote_response_2, + headers={"Authorization": "Bearer 12345"}, + status_code=200, + ) + + new_published_version = service.publish(system_identity, new_version.id) + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "rdm_record|publish" + monkeypatch.setenv("MOCK_SIGNAL_SUBSCRIBER", "True") + assert mock_adapter2.call_count == 1 + # assert mock_adapter2.last_request.json() == remote_response_2 + # FIXME: Why no match? + assert mock_adapter2.last_request.method == "PUT" + assert new_published_version.data["metadata"]["title"] == "A Romans Story 3" + + read_new_version = service.read(system_identity, new_published_version.id) + assert ( + read_new_version.data["custom_fields"].get("kcr:commons_search_recid") + == remote_response_2["_id"] + ) + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "True" # wasn't set by subscriber + + remote_response_3 = {"message": "Document deleted"} + mock_adapter3 = requests_mock.delete( + rec_url + "/" + remote_response["_id"], + json=remote_response_3, + status_code=200, # NOTE: Delete still returns 200 + headers={"Authorization": "Bearer 12345"}, + ) + + deleted_record = service.delete_record( + system_identity, new_published_version.id, data={} + ) + # variable is NOT set by mock subscriber because there's no + # callback defined for DELETE operations + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "True" + assert mock_adapter3.call_count == 1 + assert mock_adapter3.last_request.method == "DELETE" + # assert mock_adapter3.last_request.json() # FIXME: Why is this empty? + deleted_actual_data = { + k: v + for k, v in deleted_record.data.items() + if k + not in [ + "created", + "updated", + "links", + ] + } + assert deleted_actual_data == { + "access": { + "embargo": {"active": False, "reason": None}, + "files": "public", + "record": "public", + "status": "metadata-only", + }, + "custom_fields": {"kcr:commons_search_recid": "2E9SqY0Bdd2QL-HGeUuA"}, + "deletion_status": {"is_deleted": True, "status": "D"}, + "files": { + "count": 0, + "enabled": False, + "entries": {}, + "order": [], + "total_bytes": 0, + }, + "id": read_new_version.data["id"], + "is_draft": False, + "is_published": True, + "media_files": { + "count": 0, + "enabled": False, + "entries": {}, + "order": [], + "total_bytes": 0, + }, + "metadata": { + "creators": [ + { + "person_or_org": { + "family_name": "Brown", + "given_name": "Troy", + "name": "Brown, Troy", + "type": "personal", + } + }, + { + "person_or_org": { + "family_name": "Troy Inc.", + "name": "Troy Inc.", + "type": "organizational", + } + }, + ], + "publication_date": read_new_version.data["metadata"]["publication_date"], + "publisher": "Acme Inc", + "resource_type": { + "id": "image-photograph", + "title": {"en": "Photo"}, + }, + "title": "A Romans Story 3", + }, + "parent": { + "access": { + "grants": [], + "links": [], + "owned_by": None, + "settings": { + "accept_conditions_text": None, + "allow_guest_requests": False, + "allow_user_requests": False, + "secret_link_expiration": 0, + }, + }, + "communities": {}, + "id": read_new_version.data["parent"]["id"], + "pids": { + "doi": { + "client": "datacite", + "identifier": read_new_version.data["parent"]["pids"]["doi"][ + "identifier" + ], + "provider": "datacite", + }, + }, + }, + "pids": { + "doi": { + "client": "datacite", + "identifier": read_new_version.data["pids"]["doi"]["identifier"], + "provider": "datacite", + }, + "oai": { + "identifier": read_new_version.data["pids"]["oai"]["identifier"], + "provider": "oai", + }, + }, + "revision_id": 3, + "stats": { + "all_versions": { + "data_volume": 0.0, + "downloads": 0, + "unique_downloads": 0, + "unique_views": 0, + "views": 0, + }, + "this_version": { + "data_volume": 0.0, + "downloads": 0, + "unique_downloads": 0, + "unique_views": 0, + "views": 0, + }, + }, + "status": "published", + "tombstone": { + "citation_text": ( + f"Brown, T., & Troy Inc. ({arrow.now().year}). A Romans Story 3. " + f"Acme Inc. https://doi.org/" + f"{read_new_version.data['pids']['doi']['identifier']}" + ), + "is_visible": True, + "note": "", + "removal_date": (arrow.now().shift(days=1)).format("YYYY-MM-DD"), + "removed_by": {"user": "system"}, + }, + "versions": {"index": 2, "is_latest": True}, + } + + # TODO: restore record + # restored_record = service.restore_record(system_identity, deleted_record.id) + + # # any extra queue events? + # assert ( + # len( + # [ + # c + # for c in current_queues.queues[ + # "remote-api-provisioning-events" + # ].consume() + # ] + # ) + # == 0 + # ) + + # assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "rdm_record|restore_record" + # restored_actual_data = { + # k: v + # for k, v in restored_record.data.items() + # if k + # not in [ + # "created", + # "updated", + # "links", + # ] + # } + # restored_expected_data = deleted_actual_data.copy() + # del restored_expected_data["tombstone"] + # restored_expected_data["deletion_status"] = { + # "is_deleted": False, + # "status": "P", + # } + # restored_expected_data["revision_id"] = 9 + # restored_expected_data["versions"]["is_latest"] = True + # restored_expected_data["versions"]["is_latest_draft"] = True + # assert restored_actual_data == restored_expected_data + + monkeypatch.delenv("MOCK_SIGNAL_SUBSCRIBER") + + +def test_component_community_publish_signal( + running_app, + minimal_community, + admin, + superuser_role_need, + location, + community_type_v, + search, + search_clear, + db, + requests_mock, + monkeypatch, + mock_signal_subscriber, + create_communities_custom_fields, +): + """Test signal emission for correct community events. + + This should not prompt any remote API operations. + """ + app = running_app.app + + monkeypatch.setenv("MOCK_SIGNAL_SUBSCRIBER", "True") + rec_url = list(app.config["REMOTE_API_PROVISIONER_EVENTS"]["community"].keys())[0] + remote_response = { + "_internal_id": "1234AbCD?", # can't mock because set at runtime + "_id": "2E9SqY0Bdd2QL-HGeUuA", + "title": "My Community", + "primary_url": "http://works.kcommons.org/records/1234", + } + requests_mock.post( + rec_url, + json=remote_response, + headers={"Authorization": "Bearer 12345"}, + ) + + service = current_communities.service + app.logger.debug(service) + app.logger.debug(service.config.components[-1]) + app.logger.debug(dir(service.config.components[-1])) + + assert admin.user.roles + app.logger.debug(admin.user.roles) + admin.identity.provides.add(superuser_role_need) + + # Creation, + # API operations should be prompted + new = service.create(admin.identity, minimal_community) + actual_new = new.data + assert actual_new["metadata"]["title"] == "My Community" + assert requests_mock.call_count == 1 # user update at token login + + read_record = service.read(admin.identity, actual_new["id"]) + app.logger.debug(pformat(read_record.data)) + assert read_record.data["metadata"]["title"] == "My Community" + assert ( + os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "community|create" + ) # wasn't set by subscriber + monkeypatch.setenv("MOCK_SIGNAL_SUBSCRIBER", "True") + + # Edit + # now this should prompt a remote API operation + minimal_edited = minimal_community.copy() + minimal_edited["metadata"]["title"] = "My Community 2" + # simulate the result of previous remote API operation + minimal_edited["custom_fields"]["kcr:commons_search_recid"] = remote_response["_id"] + minimal_edited["custom_fields"][ + "kcr:commons_search_updated" + ] = arrow.utcnow().format( + "YYYY-MM-DDTHH:mm:ssZ" + ) # simulate the result of previous remote API operation + + time.sleep(5) + edited_new = service.update(system_identity, new.id, minimal_edited) + actual_edited = edited_new.data + assert actual_edited["metadata"]["title"] == "My Community 2" + assert requests_mock.call_count == 1 # user update at token login + # confirm that no actual calls are being made during test + assert ( + edited_new.data["custom_fields"].get("kcr:commons_search_recid") + == remote_response["_id"] + ) + minimal_edited["custom_fields"][ + "kcr:commons_search_updated" + ] = arrow.utcnow().format( + "YYYY-MM-DDTHH:mm:ssZ" + ) # simulate the result of previous remote API operation + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "community|update" + monkeypatch.setenv("MOCK_SIGNAL_SUBSCRIBER", "True") + + read_edited = service.read(admin.identity, edited_new.id) + assert ( + read_edited.data["custom_fields"].get("kcr:commons_search_recid") + == remote_response["_id"] + ) + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "True" # read doesn't trigger signal + + time.sleep(5) + deleted = service.delete_community(system_identity, read_edited.id, data={}) + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "community|delete" + # deleted_actual_data = { + # k: v + # for k, v in deleted.data.items() + # if k + # not in [ + # "created", + # "updated", + # "links", + # ] + # } + monkeypatch.setenv("MOCK_SIGNAL_SUBSCRIBER", "True") + + time.sleep(5) + restored = service.restore_community(admin.identity, deleted.id) + + # any extra queue events? + # assert ( + # len( + # [ + # c + # for c in current_queues.queues[ + # "remote-api-provisioning-events" + # ].consume() + # ] + # ) + # == 0 + # ) + + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "community|restore" + # restored_actual_data = { + # k: v + # for k, v in restored.data.items() + # if k + # not in [ + # "created", + # "updated", + # "links", + # ] + # } + # restored_expected_data = deleted_actual_data.copy() + # del restored_expected_data["tombstone"] + # restored_expected_data["deletion_status"] = { + # "is_deleted": False, + # "status": "P", + # } + # restored_expected_data["revision_id"] = 9 + # assert restored_actual_data == restored_expected_data + + monkeypatch.delenv("MOCK_SIGNAL_SUBSCRIBER") + + +def test_ext_on_search_provisioning_triggered( + running_app, minimal_record, + location, search, search_clear, - create_stats_indices, + db, + monkeypatch, + requests_mock, + create_records_custom_fields, ): app = running_app.app - # Create a new user - u = user_factory() - login_user_via_session(client, u.user) + from invenio_vocabularies.proxies import ( + current_service as vocabulary_service, + ) + + vocab_item = vocabulary_service.read( + system_identity, ("resourcetypes", "image-photograph") + ) + app.logger.debug("got vocab item") + app.logger.debug(pformat(vocab_item.data)) + # Temporarily set flag to mock signal subscriber + # We want to test the signal subscriber with an existing record + monkeypatch.setenv("MOCK_SIGNAL_SUBSCRIBER", "True") - # Create a new draft + # Set up minimal record to update after search provisioning service = current_rdm_records.records_service - minimal_record["files"] = {"enabled": False} - draft = service.create(system_identity, minimal_record) - draft_id = draft.id - app.logger.warning(f"draft: {pformat(draft.to_dict())}") - app.logger.debug( - f"CELERY_TASK_ALWAYS_EAGER: {app.config['CELERY_TASK_ALWAYS_EAGER']}" - ) - - # Verify that no API call was made during draft creation - assert requests_mock.call_count == 0 - - # Construct the mocked api_url using the factory function - config = app.config["REMOTE_API_PROVISIONER_EVENTS"]["rdm_record"][ - list(app.config["REMOTE_API_PROVISIONER_EVENTS"]["rdm_record"].keys())[0] - ]["publish"] - api_url = config["url_factory"](system_identity, record=draft) - app.logger.debug(f"api_url: {api_url}") - - # Choose the HTTP method using the factory function - http_method_factory = config["http_method"] - # NOTE: In service component, the draft and record are both dicts - http_method = http_method_factory( - system_identity, draft=draft.to_dict(), record=draft.to_dict() - ) # noqa: E501 - app.logger.debug(f"http_method: {http_method}") + record = service.create(system_identity, minimal_record) + published_record = service.publish(system_identity, record.id) + read_record = service.read(system_identity, published_record.id) + assert read_record.data["metadata"]["title"] == "A Romans Story" + assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "rdm_record|publish" + + # Now switch to live signal subscriber to test its behaviour + monkeypatch.delenv("MOCK_SIGNAL_SUBSCRIBER") - # Mock the external API call for publication + # Mock remote API response mock_response = { - "_internal_id": draft_id, - "_id": "y-5ExZIBwjeO8JmmunDd", - "title": minimal_record["metadata"]["title"], - "description": minimal_record["metadata"].get("description", ""), - "owner": {"url": "https://hcommons.org/profiles/myuser"}, + "_internal_id": read_record.data["id"], + "_id": "2E9SqY0Bdd2QL-HGeUuA", + "title": "A Romans Story 2", + "primary_url": f"http://works.kcommons.org/records/{read_record.data['id']}", + } + resp_url = list(app.config["REMOTE_API_PROVISIONER_EVENTS"]["rdm_record"].keys())[0] + requests_mock.post( + resp_url, + json=mock_response, + headers={"Authorization ": "Bearer 12345"}, + ) + + # Trigger signal + owner = { + "id": "1", + "email": "admin@inveniosoftware.org", + "username": "myuser", + "name": "My User", + "orcid": "888888", + } + events = [ + { + "service_type": "rdm_record", + "service_method": "publish", + "request_url": "https://search.hcommons-dev.org/api/v1/documents", + "http_method": "POST", + "payload_object": format_commons_search_payload( + system_identity, data=read_record.data, owner=owner + ), + "record_id": read_record.data["id"], + "draft_id": read_record.data["id"], + "request_headers": {"Authorization": "Bearer 12345"}, + } + ] + current_queues.queues["remote-api-provisioning-events"].publish(events) + remote_api_provisioning_triggered.send(app._get_current_object()) + + # Check that the remote API was called correctly + assert ( + requests_mock.call_count == 2 + ) # 1 for user update at token login, 1 for remote API + h = requests_mock.request_history + assert h[1].url == resp_url + assert h[1].method == "POST" + assert h[1].headers["Authorization"] == "Bearer 12345" + publish_payload = { + "_internal_id": read_record.data["id"], + "content": "", + "content_type": "work", "contributors": [ - { - "name": f"{c['person_or_org'].get('family_name', '')}, " - f"{c['person_or_org'].get('given_name', '')}", - "username": "user1", - "url": "https://hcommons.org/profiles/user1", - "role": "author", - } - for c in minimal_record["metadata"]["creators"] + {"name": "Troy Brown", "role": ""}, + {"name": "Troy Inc.", "role": ""}, ], - "primary_url": f"{app.config['SITE_UI_URL']}/records/{draft_id}", - "other_urls": [f"{app.config['SITE_API_URL']}/records/{draft_id}/files"], - "publication_date": minimal_record["metadata"]["publication_date"], - "modified_date": "2024-06-07", - "content_type": "work", + "description": "", + "modified_date": arrow.utcnow().format("YYYY-MM-DD"), "network_node": "works", + "other_urls": [], + "owner": { + "name": "", + "owner_username": None, + "url": "http://hcommons.org/profiles/None", + }, + "primary_url": f"http://works.kcommons.org/records/{read_record.data['id']}", + "publication_date": "2020-06-01", + "thumbnail_url": "", + "title": "A Romans Story", } - mock_adapter = requests_mock.request( - http_method, "https://search.hcommons-dev.org/v1/documents", json=mock_response - ) # noqa: E501 - app.logger.debug(f"mock_adapter: {dir(mock_adapter)}") + assert json.loads(h[1].body) == publish_payload - # Publish the draft - published_record = service.publish(system_identity, draft_id) - app.logger.warning(f"published_record: {pformat(published_record.to_dict())}") + # Check that the record was updated with the remote API info and timestamp + app.logger.debug(f"Reading final record {read_record.data['id']}") + final_read_record = service.read(system_identity, read_record.data["id"]) + assert ( + final_read_record.data["custom_fields"]["kcr:commons_search_recid"] + == "2E9SqY0Bdd2QL-HGeUuA" + ) + assert arrow.get( + final_read_record.data["custom_fields"]["kcr:commons_search_updated"] + ) >= arrow.utcnow().shift(seconds=-10) - # Allow time for the background task to complete - import time - time.sleep(10) +def test_ext_on_search_provisioning_triggered_community( + running_app, + superuser_role_need, + minimal_community, + location, + search, + search_clear, + db, + monkeypatch, + requests_mock, + create_communities_custom_fields, +): + app = running_app.app + # assert admin.user.roles + # admin.identity.provides.add(superuser_role_need) - # Verify that the API call was made during publication - assert requests_mock.call_count == 1 - last_request = requests_mock.last_request - assert last_request.url == api_url - assert last_request.method == http_method - assert last_request.headers["Authorization"] == f"Bearer {config['auth_token']}" + # Temporarily set flag to mock signal subscriber + # We want to test the signal subscriber with an existing record + # monkeypatch.setenv("MOCK_SIGNAL_SUBSCRIBER", "True") - # Check if the payload was correct - payload_formatter = config["payload"] - expected_payload = payload_formatter( - system_identity, - record=published_record.to_dict(), # FIXME: not exactly accurate - owner={"email": "", "id": "system", "username": "system"}, - data={}, - draft=published_record.to_dict(), + # Mock remote API response + mock_response = { + "_internal_id": "", + "_id": "2E9SqY0Bdd2QL-HGeUuA", + "title": "My Community", + "primary_url": "http://works.kcommons.org/collections/my-community", + } + resp_url = list(app.config["REMOTE_API_PROVISIONER_EVENTS"]["community"].keys())[0] + requests_mock.post( + resp_url, + json=mock_response, + headers={"Authorization ": "Bearer 12345"}, ) - assert last_request.json() == expected_payload - # Retrieve the published record - record = service.read(system_identity, draft_id) + # Set up minimal record to update after search provisioning + service = current_communities.service + record = service.create(system_identity, minimal_community) + read_record = service.read(system_identity, record.id) + assert read_record.data["metadata"]["title"] == "My Community" + # assert os.getenv("MOCK_SIGNAL_SUBSCRIBER") == "community|create" + + # Check that the remote API was called correctly + assert ( + requests_mock.call_count == 2 + ) # 1 for user update at token login, 1 for remote API + h = requests_mock.request_history + assert h[1].url == resp_url + assert h[1].method == "POST" + assert h[1].headers["Authorization"] == "Bearer 12345" + publish_payload = { + "_internal_id": "", + "content": "", + "content_type": "works_collection", + "contributors": [], + "description": "", + "modified_date": arrow.utcnow().format("YYYY-MM-DD"), + "network_node": "works", + "other_urls": [], + "owner": { + "name": "", + "owner_username": None, + "url": "", + }, + "primary_url": "http://works.kcommons.org/collections/my-collection", + "publication_date": arrow.utcnow().format("YYYY-MM-DD"), + "thumbnail_url": "", + "title": "My Community", + } + assert json.loads(h[1].body) == publish_payload + + # Now switch to live signal subscriber to test its behaviour + # monkeypatch.delenv("MOCK_SIGNAL_SUBSCRIBER") + + # Trigger signal again + owner = { + "id": "1", + "email": "admin@inveniosoftware.org", + "username": "myuser", + "name": "My User", + "orcid": "888888", + } + events = [ + { + "service_type": "community", + "service_method": "update", + "request_url": f"https://search.hcommons-dev.org/api/v1/documents/{read_record.data['custom_fields']['kcr:commons_search_recid']}", + "http_method": "PUT", + "payload_object": format_commons_search_collection_payload( + system_identity, data=read_record.data, owner=owner + ), + "record_id": read_record.data["id"], + "draft_id": read_record.data["id"], # FIXME: is this right? + "request_headers": {"Authorization": "Bearer 12345"}, + } + ] + current_queues.queues["remote-api-provisioning-events"].publish(events) + remote_api_provisioning_triggered.send(app._get_current_object()) - # Check if the record ID was recorded correctly - assert record["custom_fields"]["kcr:commons_search_recid"] == mock_response["_id"] + # Check that the remote API was called correctly + assert ( + requests_mock.call_count == 2 + ) # 1 for user update at token login, 1 for remote API + h = requests_mock.request_history + assert h[1].url == resp_url + assert h[1].method == "POST" + assert h[1].headers["Authorization"] == "Bearer 12345" + publish_payload = { + "_internal_id": read_record.data["id"], + "content": "", + "content_type": "work", + "contributors": [ + {"name": "Troy Brown", "role": ""}, + {"name": "Troy Inc.", "role": ""}, + ], + "description": "", + "modified_date": arrow.utcnow().format("YYYY-MM-DD"), + "network_node": "works", + "other_urls": [], + "owner": { + "name": "", + "owner_username": None, + "url": "http://hcommons.org/profiles/None", + }, + "primary_url": f"http://works.kcommons.org/records/{read_record.data['id']}", + "publication_date": "2020-06-01", + "thumbnail_url": "", + "title": "A Romans Story", + } + assert json.loads(h[1].body) == publish_payload - # Check if the timestamp was recorded (within a 10-second window) - recorded_time = arrow.get(record["custom_fields"]["kcr:commons_search_updated"]) - assert (arrow.utcnow() - recorded_time).total_seconds() < 10 + # Check that the record was updated with the remote API info and timestamp + app.logger.debug(f"Reading final record {read_record.data['id']}") + final_read_record = service.read(system_identity, read_record.data["id"]) + assert ( + final_read_record.data["custom_fields"]["kcr:commons_search_recid"] + == "2E9SqY0Bdd2QL-HGeUuA" + ) + assert arrow.get( + final_read_record.data["custom_fields"]["kcr:commons_search_updated"] + ) >= arrow.utcnow().shift(seconds=-10) diff --git a/site/tests/fixtures/search_provisioning.py b/site/tests/fixtures/search_provisioning.py index ba7971aac..c3882970a 100644 --- a/site/tests/fixtures/search_provisioning.py +++ b/site/tests/fixtures/search_provisioning.py @@ -1,5 +1,7 @@ import pytest from celery import shared_task +from invenio_queues.proxies import current_queues +from pprint import pformat from typing import Optional @@ -67,3 +69,20 @@ def mock_request(http_method, draft_id, metadata, api_url): return mock_adapter return mock_request + + +# TODO: This didn't work +# @pytest.fixture(scope="function") +# def mock_signal_subscriber(app, monkeypatch): +# """Mock ext.on_api_provisioning_triggered event subscriber.""" + +# def mocksubscriber(app_obj, *args, **kwargs): +# with app_obj.app_context(): +# app_obj.logger.debug("Mocked remote_api_provisioning_triggered") +# app_obj.logger.debug("Events:") +# app_obj.logger( +# pformat(current_queues.queues["remote-api-provisioning-events"].events) +# ) +# raise RuntimeError("Mocked remote_api_provisioning_triggered") + +# return mocksubscriber diff --git a/site/tests/helpers/fake_datacite_client.py b/site/tests/helpers/fake_datacite_client.py index 9e86f1bfa..a8db85f49 100644 --- a/site/tests/helpers/fake_datacite_client.py +++ b/site/tests/helpers/fake_datacite_client.py @@ -90,6 +90,17 @@ def hide_doi(self, doi): """ return Mock() + def show_doi(self, doi): + """Show a previously hidden DOI ... not. + + This DOI will no + longer be found in DataCite Search + + :param doi: DOI to hide e.g. 10.12345/1. + :return: + """ + return Mock() + def check_doi(self, doi): """Check doi structure. @@ -104,9 +115,7 @@ def check_doi(self, doi): # Provided a DOI with the wrong prefix raise ValueError( "Wrong DOI {0} prefix provided, it should be " - "{1} as defined in the rest client".format( - prefix, self.prefix - ) + "{1} as defined in the rest client".format(prefix, self.prefix) ) else: doi = f"{self.prefix}/{doi}" From 49ee7fa84c6c3dfd62df93f9679879537ff4245e Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Mon, 20 Jan 2025 12:46:04 -0500 Subject: [PATCH 22/23] fix(theme): Adding focus group banner --- assets/less/site/globals/site.overrides | 25 ++++++++++++++++--- .../semantic-ui/invenio_app_rdm/header.html | 6 ++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/assets/less/site/globals/site.overrides b/assets/less/site/globals/site.overrides index bc9feb8e9..8f2f83661 100644 --- a/assets/less/site/globals/site.overrides +++ b/assets/less/site/globals/site.overrides @@ -103,10 +103,9 @@ html.cover-page { .theme.header { box-shadow: none; - .beta-banner { - background-color: @mint50; - background-image: url("../../../../static/images/textured_bgs/KCommons_assets_Seafoam_800.png"); - color: @verdigrisDark; + + .banner { + padding: 0.5rem 1rem; text-align: center; font-size: 16px; @@ -116,6 +115,24 @@ html.cover-page { a { text-decoration: underline; } + &.primary { + background-color: @mint50; + background-image: url("../../../../static/images/textured_bgs/KCommons_assets_Seafoam_800.png"); + color: @verdigrisDark; + border-bottom: 1px solid @verdigris50; + } + &.secondary { + background-color: @verdigrisDark; + background-image: none; + color: @white; + padding: 0.5rem 1rem; + text-align: center; + font-weight: bold; + // text-shadow: 0 0 0.5rem 000; + a { + color: @white; + } + } } } diff --git a/templates/semantic-ui/invenio_app_rdm/header.html b/templates/semantic-ui/invenio_app_rdm/header.html index 370b2b336..42a598e59 100644 --- a/templates/semantic-ui/invenio_app_rdm/header.html +++ b/templates/semantic-ui/invenio_app_rdm/header.html @@ -46,7 +46,11 @@
{%- block site_banner %} -
Public Beta version - Help build the open, academy-owned platform you want! Share your ideas and bug reports!
+ + + + + {%- endblock site_banner %}
{%- block banner %} From ada9b452c4e92a95d3895a1168e570a3f29c5760 Mon Sep 17 00:00:00 2001 From: Ian Scott Date: Mon, 20 Jan 2025 12:46:47 -0500 Subject: [PATCH 23/23] fix(search-provisioning): Fixes to search provisioning --- site/kcworks/dependencies/invenio-remote-api-provisioner | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/kcworks/dependencies/invenio-remote-api-provisioner b/site/kcworks/dependencies/invenio-remote-api-provisioner index 8faa8ae25..12c549138 160000 --- a/site/kcworks/dependencies/invenio-remote-api-provisioner +++ b/site/kcworks/dependencies/invenio-remote-api-provisioner @@ -1 +1 @@ -Subproject commit 8faa8ae25b5cb8253325408fcf46a8673f37d4c1 +Subproject commit 12c549138131b34a94ff894a4d6cd03081b805e3