Adding additional details to error messages, and cleaning up the cruft around these messages to keep them clear and succinct.
Most noteable is the addition of the line on which the error occurs for script tasks. It will report the line number and pass back the content of the line that failed. The validator only returns the first error it encounters, as it's clear that all we ever get right now is two of the same error. Did a lot of work between this and spiffworkflow to remove all the places where we obfuscate or drop details as we converted between workflowExceptions and APIExceptions. Dropped the python levenshtein dependency, in favor of just rolling a simple one ourselves in Spiffworkflow.
This commit is contained in:
parent
a9805ad40c
commit
fb54edac1c
1
Pipfile
1
Pipfile
|
@ -46,7 +46,6 @@ werkzeug = "*"
|
|||
xlrd = "*"
|
||||
xlsxwriter = "*"
|
||||
pygithub = "*"
|
||||
python-levenshtein = "*"
|
||||
apscheduler = "*"
|
||||
|
||||
[requires]
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "7d8b69e62f7b29be90bce732e1e7ae4eebbde533882bb96a52e3672fe6ae91aa"
|
||||
"sha256": "ad259e41c4e42c8818992a6e5ce7436d35755a02e7f12688bed01e0250a3d668"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
|
@ -244,14 +244,6 @@
|
|||
"index": "pypi",
|
||||
"version": "==5.5"
|
||||
},
|
||||
"dataclasses": {
|
||||
"hashes": [
|
||||
"sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf",
|
||||
"sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"
|
||||
],
|
||||
"markers": "python_version < '3.7'",
|
||||
"version": "==0.8"
|
||||
},
|
||||
"deprecated": {
|
||||
"hashes": [
|
||||
"sha256:08452d69b6b5bc66e8330adde0a4f8642e969b9e1702904d137eeb29c8ffc771",
|
||||
|
@ -433,14 +425,6 @@
|
|||
],
|
||||
"version": "==1.2.0"
|
||||
},
|
||||
"importlib-metadata": {
|
||||
"hashes": [
|
||||
"sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac",
|
||||
"sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e"
|
||||
],
|
||||
"markers": "python_version < '3.8'",
|
||||
"version": "==4.6.1"
|
||||
},
|
||||
"inflection": {
|
||||
"hashes": [
|
||||
"sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
|
||||
|
@ -592,11 +576,11 @@
|
|||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:8050475b70470cc58f4441ee92375db611792ba39ca1ad41d39cad193ea9e040",
|
||||
"sha256:b45cde981d1835145257b4a3c5cb7b80786dcf5f50dd2990749a50c16cb48e01"
|
||||
"sha256:77368dfedad93c3a041cbbdbce0b33fac1d8608c9e2e2288408a43ce3493d2ff",
|
||||
"sha256:d4090ca9a36cd129126ad8b10c3982c47d4644a6e3ccb20534b512badce95f35"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.12.1"
|
||||
"version": "==3.12.2"
|
||||
},
|
||||
"marshmallow-enum": {
|
||||
"hashes": [
|
||||
|
@ -616,42 +600,36 @@
|
|||
},
|
||||
"numpy": {
|
||||
"hashes": [
|
||||
"sha256:012426a41bc9ab63bb158635aecccc7610e3eff5d31d1eb43bc099debc979d94",
|
||||
"sha256:06fab248a088e439402141ea04f0fffb203723148f6ee791e9c75b3e9e82f080",
|
||||
"sha256:0eef32ca3132a48e43f6a0f5a82cb508f22ce5a3d6f67a8329c81c8e226d3f6e",
|
||||
"sha256:1ded4fce9cfaaf24e7a0ab51b7a87be9038ea1ace7f34b841fe3b6894c721d1c",
|
||||
"sha256:2e55195bc1c6b705bfd8ad6f288b38b11b1af32f3c8289d6c50d47f950c12e76",
|
||||
"sha256:2ea52bd92ab9f768cc64a4c3ef8f4b2580a17af0a5436f6126b08efbd1838371",
|
||||
"sha256:36674959eed6957e61f11c912f71e78857a8d0604171dfd9ce9ad5cbf41c511c",
|
||||
"sha256:384ec0463d1c2671170901994aeb6dce126de0a95ccc3976c43b0038a37329c2",
|
||||
"sha256:39b70c19ec771805081578cc936bbe95336798b7edf4732ed102e7a43ec5c07a",
|
||||
"sha256:400580cbd3cff6ffa6293df2278c75aef2d58d8d93d3c5614cd67981dae68ceb",
|
||||
"sha256:43d4c81d5ffdff6bae58d66a3cd7f54a7acd9a0e7b18d97abb255defc09e3140",
|
||||
"sha256:50a4a0ad0111cc1b71fa32dedd05fa239f7fb5a43a40663269bb5dc7877cfd28",
|
||||
"sha256:603aa0706be710eea8884af807b1b3bc9fb2e49b9f4da439e76000f3b3c6ff0f",
|
||||
"sha256:6149a185cece5ee78d1d196938b2a8f9d09f5a5ebfbba66969302a778d5ddd1d",
|
||||
"sha256:759e4095edc3c1b3ac031f34d9459fa781777a93ccc633a472a5468587a190ff",
|
||||
"sha256:7fb43004bce0ca31d8f13a6eb5e943fa73371381e53f7074ed21a4cb786c32f8",
|
||||
"sha256:811daee36a58dc79cf3d8bdd4a490e4277d0e4b7d103a001a4e73ddb48e7e6aa",
|
||||
"sha256:8b5e972b43c8fc27d56550b4120fe6257fdc15f9301914380b27f74856299fea",
|
||||
"sha256:99abf4f353c3d1a0c7a5f27699482c987cf663b1eac20db59b8c7b061eabd7fc",
|
||||
"sha256:a0d53e51a6cb6f0d9082decb7a4cb6dfb33055308c4c44f53103c073f649af73",
|
||||
"sha256:a12ff4c8ddfee61f90a1633a4c4afd3f7bcb32b11c52026c92a12e1325922d0d",
|
||||
"sha256:a4646724fba402aa7504cd48b4b50e783296b5e10a524c7a6da62e4a8ac9698d",
|
||||
"sha256:a76f502430dd98d7546e1ea2250a7360c065a5fdea52b2dffe8ae7180909b6f4",
|
||||
"sha256:a9d17f2be3b427fbb2bce61e596cf555d6f8a56c222bd2ca148baeeb5e5c783c",
|
||||
"sha256:ab83f24d5c52d60dbc8cd0528759532736b56db58adaa7b5f1f76ad551416a1e",
|
||||
"sha256:aeb9ed923be74e659984e321f609b9ba54a48354bfd168d21a2b072ed1e833ea",
|
||||
"sha256:c843b3f50d1ab7361ca4f0b3639bf691569493a56808a0b0c54a051d260b7dbd",
|
||||
"sha256:cae865b1cae1ec2663d8ea56ef6ff185bad091a5e33ebbadd98de2cfa3fa668f",
|
||||
"sha256:cc6bd4fd593cb261332568485e20a0712883cf631f6f5e8e86a52caa8b2b50ff",
|
||||
"sha256:cf2402002d3d9f91c8b01e66fbb436a4ed01c6498fffed0e4c7566da1d40ee1e",
|
||||
"sha256:d051ec1c64b85ecc69531e1137bb9751c6830772ee5c1c426dbcfe98ef5788d7",
|
||||
"sha256:d6631f2e867676b13026e2846180e2c13c1e11289d67da08d71cacb2cd93d4aa",
|
||||
"sha256:dbd18bcf4889b720ba13a27ec2f2aac1981bd41203b3a3b27ba7a33f88ae4827",
|
||||
"sha256:df609c82f18c5b9f6cb97271f03315ff0dbe481a2a02e56aeb1b1a985ce38e60"
|
||||
"sha256:1a784e8ff7ea2a32e393cc53eb0003eca1597c7ca628227e34ce34eb11645a0e",
|
||||
"sha256:2ba579dde0563f47021dcd652253103d6fd66165b18011dce1a0609215b2791e",
|
||||
"sha256:3537b967b350ad17633b35c2f4b1a1bbd258c018910b518c30b48c8e41272717",
|
||||
"sha256:3c40e6b860220ed862e8097b8f81c9af6d7405b723f4a7af24a267b46f90e461",
|
||||
"sha256:598fe100b2948465cf3ed64b1a326424b5e4be2670552066e17dfaa67246011d",
|
||||
"sha256:620732f42259eb2c4642761bd324462a01cdd13dd111740ce3d344992dd8492f",
|
||||
"sha256:709884863def34d72b183d074d8ba5cfe042bc3ff8898f1ffad0209161caaa99",
|
||||
"sha256:75579acbadbf74e3afd1153da6177f846212ea2a0cc77de53523ae02c9256513",
|
||||
"sha256:7c55407f739f0bfcec67d0df49103f9333edc870061358ac8a8c9e37ea02fcd2",
|
||||
"sha256:a1f2fb2da242568af0271455b89aee0f71e4e032086ee2b4c5098945d0e11cf6",
|
||||
"sha256:a290989cd671cd0605e9c91a70e6df660f73ae87484218e8285c6522d29f6e38",
|
||||
"sha256:ac4fd578322842dbda8d968e3962e9f22e862b6ec6e3378e7415625915e2da4d",
|
||||
"sha256:ad09f55cc95ed8d80d8ab2052f78cc21cb231764de73e229140d81ff49d8145e",
|
||||
"sha256:b9205711e5440954f861ceeea8f1b415d7dd15214add2e878b4d1cf2bcb1a914",
|
||||
"sha256:bba474a87496d96e61461f7306fba2ebba127bed7836212c360f144d1e72ac54",
|
||||
"sha256:bebab3eaf0641bba26039fb0b2c5bf9b99407924b53b1ea86e03c32c64ef5aef",
|
||||
"sha256:cc367c86eb87e5b7c9592935620f22d13b090c609f1b27e49600cd033b529f54",
|
||||
"sha256:ccc6c650f8700ce1e3a77668bb7c43e45c20ac06ae00d22bdf6760b38958c883",
|
||||
"sha256:cf680682ad0a3bef56dae200dbcbac2d57294a73e5b0f9864955e7dd7c2c2491",
|
||||
"sha256:d2910d0a075caed95de1a605df00ee03b599de5419d0b95d55342e9a33ad1fb3",
|
||||
"sha256:d5caa946a9f55511e76446e170bdad1d12d6b54e17a2afe7b189112ed4412bb8",
|
||||
"sha256:d89b0dc7f005090e32bb4f9bf796e1dcca6b52243caf1803fdd2b748d8561f63",
|
||||
"sha256:d95d16204cd51ff1a1c8d5f9958ce90ae190be81d348b514f9be39f878b8044a",
|
||||
"sha256:e4d5a86a5257843a18fb1220c5f1c199532bc5d24e849ed4b0289fb59fbd4d8f",
|
||||
"sha256:e58ddb53a7b4959932f5582ac455ff90dcb05fac3f8dcc8079498d43afbbde6c",
|
||||
"sha256:e80fe25cba41c124d04c662f33f6364909b985f2eb5998aaa5ae4b9587242cce",
|
||||
"sha256:eda2829af498946c59d8585a9fd74da3f810866e05f8df03a86f70079c7531dd",
|
||||
"sha256:fd0a359c1c17f00cb37de2969984a74320970e0ceef4808c32e00773b06649d9"
|
||||
],
|
||||
"version": "==1.19.5"
|
||||
"version": "==1.21.0"
|
||||
},
|
||||
"openapi-schema-validator": {
|
||||
"hashes": [
|
||||
|
@ -686,33 +664,28 @@
|
|||
},
|
||||
"pandas": {
|
||||
"hashes": [
|
||||
"sha256:0a643bae4283a37732ddfcecab3f62dd082996021b980f580903f4e8e01b3c5b",
|
||||
"sha256:0de3ddb414d30798cbf56e642d82cac30a80223ad6fe484d66c0ce01a84d6f2f",
|
||||
"sha256:19a2148a1d02791352e9fa637899a78e371a3516ac6da5c4edc718f60cbae648",
|
||||
"sha256:21b5a2b033380adbdd36b3116faaf9a4663e375325831dac1b519a44f9e439bb",
|
||||
"sha256:24c7f8d4aee71bfa6401faeba367dd654f696a77151a8a28bc2013f7ced4af98",
|
||||
"sha256:26fa92d3ac743a149a31b21d6f4337b0594b6302ea5575b37af9ca9611e8981a",
|
||||
"sha256:2860a97cbb25444ffc0088b457da0a79dc79f9c601238a3e0644312fcc14bf11",
|
||||
"sha256:2b1c6cd28a0dfda75c7b5957363333f01d370936e4c6276b7b8e696dd500582a",
|
||||
"sha256:2c2f7c670ea4e60318e4b7e474d56447cf0c7d83b3c2a5405a0dbb2600b9c48e",
|
||||
"sha256:3be7a7a0ca71a2640e81d9276f526bca63505850add10206d0da2e8a0a325dae",
|
||||
"sha256:4c62e94d5d49db116bef1bd5c2486723a292d79409fc9abd51adf9e05329101d",
|
||||
"sha256:5008374ebb990dad9ed48b0f5d0038124c73748f5384cc8c46904dace27082d9",
|
||||
"sha256:5447ea7af4005b0daf695a316a423b96374c9c73ffbd4533209c5ddc369e644b",
|
||||
"sha256:573fba5b05bf2c69271a32e52399c8de599e4a15ab7cec47d3b9c904125ab788",
|
||||
"sha256:5a780260afc88268a9d3ac3511d8f494fdcf637eece62fb9eb656a63d53eb7ca",
|
||||
"sha256:70865f96bb38fec46f7ebd66d4b5cfd0aa6b842073f298d621385ae3898d28b5",
|
||||
"sha256:731568be71fba1e13cae212c362f3d2ca8932e83cb1b85e3f1b4dd77d019254a",
|
||||
"sha256:b61080750d19a0122469ab59b087380721d6b72a4e7d962e4d7e63e0c4504814",
|
||||
"sha256:bf23a3b54d128b50f4f9d4675b3c1857a688cc6731a32f931837d72effb2698d",
|
||||
"sha256:c16d59c15d946111d2716856dd5479221c9e4f2f5c7bc2d617f39d870031e086",
|
||||
"sha256:c61c043aafb69329d0f961b19faa30b1dab709dd34c9388143fc55680059e55a",
|
||||
"sha256:c94ff2780a1fd89f190390130d6d36173ca59fcfb3fe0ff596f9a56518191ccb",
|
||||
"sha256:edda9bacc3843dfbeebaf7a701763e68e741b08fccb889c003b0a52f0ee95782",
|
||||
"sha256:f10fc41ee3c75a474d3bdf68d396f10782d013d7f67db99c0efbfd0acb99701b"
|
||||
"sha256:08eeff3da6a188e24db7f292b39a8ca9e073bf841fbbeadb946b3ad5c19d843e",
|
||||
"sha256:1ff13eed501e07e7fb26a4ea18a846b6e5d7de549b497025601fd9ccb7c1d123",
|
||||
"sha256:522bfea92f3ef6207cadc7428bda1e7605dae0383b8065030e7b5d0266717b48",
|
||||
"sha256:7897326cae660eee69d501cbfa950281a193fcf407393965e1bc07448e1cc35a",
|
||||
"sha256:798675317d0e4863a92a9a6bc5bd2490b5f6fef8c17b95f29e2e33f28bef9eca",
|
||||
"sha256:7d3cd2c99faa94d717ca00ea489264a291ad7209453dffbf059bfb7971fd3a61",
|
||||
"sha256:823737830364d0e2af8c3912a28ba971296181a07950873492ed94e12d28c405",
|
||||
"sha256:872aa91e0f9ca913046ab639d4181a899f5e592030d954d28c2529b88756a736",
|
||||
"sha256:88864c1e28353b958b1f30e4193818519624ad9a1776921622a6a2a016d5d807",
|
||||
"sha256:92835113a67cbd34747c198d41f09f4b63f6fe11ca5643baebc7ab1e30e89e95",
|
||||
"sha256:98efc2d4983d5bb47662fe2d97b2c81b91566cb08b266490918b9c7d74a5ef64",
|
||||
"sha256:b10d7910ae9d7920a5ff7816d794d99acbc361f7b16a0f017d4fa83ced8cb55e",
|
||||
"sha256:c554e6c9cf2d5ea1aba5979cc837b3649539ced0e18ece186f055450c86622e2",
|
||||
"sha256:c746876cdd8380be0c3e70966d4566855901ac9aaa5e4b9ccaa5ca5311457d11",
|
||||
"sha256:c81b8d91e9ae861eb4406b4e0f8d4dabbc105b9c479b3d1e921fba1d35b5b62a",
|
||||
"sha256:e6b75091fa54a53db3927b4d1bc997c23c5ba6f87acdfe1ee5a92c38c6b2ed6a",
|
||||
"sha256:ed4fc66f23fe17c93a5d439230ca2d6b5f8eac7154198d327dbe8a16d98f3f10",
|
||||
"sha256:f058c786e7b0a9e7fa5e0b9f4422e0ccdd3bf3aa3053c18d77ed2a459bd9a45a",
|
||||
"sha256:fe7a549d10ca534797095586883a5c17d140d606747591258869c56e14d1b457"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.5"
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"psycopg2-binary": {
|
||||
"hashes": [
|
||||
|
@ -864,13 +837,6 @@
|
|||
],
|
||||
"version": "==1.0.4"
|
||||
},
|
||||
"python-levenshtein": {
|
||||
"hashes": [
|
||||
"sha256:dc2395fbd148a1ab31090dd113c366695934b9e85fe5a4b2a032745efd0346f6"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.12.2"
|
||||
},
|
||||
"pytz": {
|
||||
"hashes": [
|
||||
"sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da",
|
||||
|
@ -1013,7 +979,7 @@
|
|||
},
|
||||
"spiffworkflow": {
|
||||
"git": "https://github.com/sartography/SpiffWorkflow.git",
|
||||
"ref": "0db2ab992b418be224ac24c8a73a44e0bc1648ac"
|
||||
"ref": "66555b92ef1d8d9ce117b6f2ccf6aa248df9835f"
|
||||
},
|
||||
"sqlalchemy": {
|
||||
"hashes": [
|
||||
|
@ -1058,15 +1024,6 @@
|
|||
"index": "pypi",
|
||||
"version": "==0.0.8"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
|
||||
"sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
|
||||
"sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
|
||||
],
|
||||
"markers": "python_version < '3.8'",
|
||||
"version": "==3.10.0.0"
|
||||
},
|
||||
"tzlocal": {
|
||||
"hashes": [
|
||||
"sha256:643c97c5294aedc737780a49d9df30889321cbe1204eac2c2ec6134035a92e44",
|
||||
|
@ -1139,13 +1096,6 @@
|
|||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.4.4"
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
"sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3",
|
||||
"sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"
|
||||
],
|
||||
"version": "==3.5.0"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
|
@ -1214,14 +1164,6 @@
|
|||
"index": "pypi",
|
||||
"version": "==5.5"
|
||||
},
|
||||
"importlib-metadata": {
|
||||
"hashes": [
|
||||
"sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac",
|
||||
"sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e"
|
||||
],
|
||||
"markers": "python_version < '3.8'",
|
||||
"version": "==4.6.1"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
"sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3",
|
||||
|
@ -1279,22 +1221,6 @@
|
|||
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
|
||||
],
|
||||
"version": "==0.10.2"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497",
|
||||
"sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342",
|
||||
"sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"
|
||||
],
|
||||
"markers": "python_version < '3.8'",
|
||||
"version": "==3.10.0.0"
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
"sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3",
|
||||
"sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"
|
||||
],
|
||||
"version": "==3.5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -510,7 +510,7 @@ paths:
|
|||
description: The unique id of an existing workflow specification to validate.
|
||||
schema:
|
||||
type: string
|
||||
- name: validate_study_id
|
||||
- name: study_id
|
||||
in: query
|
||||
required: false
|
||||
description: Optional id of study to test under different scenarios
|
||||
|
|
|
@ -10,7 +10,9 @@ import sentry_sdk
|
|||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, code, message, status_code=400,
|
||||
file_name="", task_id="", task_name="", tag="", task_data = {}):
|
||||
file_name="", task_id="", task_name="", tag="", task_data=None, error_type="", line_number=0, offset=0):
|
||||
if task_data is None:
|
||||
task_data = {}
|
||||
self.status_code = status_code
|
||||
self.code = code # a short consistent string describing the error.
|
||||
self.message = message # A detailed message that provides more information.
|
||||
|
@ -18,8 +20,11 @@ class ApiError(Exception):
|
|||
self.task_name = task_name or "" # OPTIONAL: The name of the task in the BPMN Diagram.
|
||||
self.file_name = file_name or "" # OPTIONAL: The file that caused the error.
|
||||
self.tag = tag or "" # OPTIONAL: The XML Tag that caused the issue.
|
||||
self.task_data = task_data or "" # OPTIONAL: A snapshot of data connected to the task when error ocurred.
|
||||
if hasattr(g,'user'):
|
||||
self.task_data = task_data or "" # OPTIONAL: A snapshot of data connected to the task when error occurred.
|
||||
self.line_number = line_number
|
||||
self.offset = offset
|
||||
self.error_type = error_type
|
||||
if hasattr(g, 'user'):
|
||||
user = g.user.uid
|
||||
else:
|
||||
user = 'Unknown'
|
||||
|
@ -29,12 +34,16 @@ class ApiError(Exception):
|
|||
Exception.__init__(self, self.message)
|
||||
|
||||
@classmethod
|
||||
def from_task(cls, code, message, task, status_code=400):
|
||||
def from_task(cls, code, message, task, status_code=400, line_number=0, offset=0, error_type="", error_line=""):
|
||||
"""Constructs an API Error with details pulled from the current task."""
|
||||
instance = cls(code, message, status_code=status_code)
|
||||
instance.task_id = task.task_spec.name or ""
|
||||
instance.task_name = task.task_spec.description or ""
|
||||
instance.file_name = task.workflow.spec.file or ""
|
||||
instance.line_number = line_number
|
||||
instance.offset = offset
|
||||
instance.error_type = error_type
|
||||
instance.error_line = error_line
|
||||
|
||||
# Fixme: spiffworkflow is doing something weird where task ends up referenced in the data in some cases.
|
||||
if "task" in task.data:
|
||||
|
@ -61,7 +70,11 @@ class ApiError(Exception):
|
|||
so consolidating the code, and doing the best things
|
||||
we can with the data we have."""
|
||||
if isinstance(exp, WorkflowTaskExecException):
|
||||
return ApiError.from_task(code, message, exp.task)
|
||||
return ApiError.from_task(code, message, exp.task, line_number=exp.line_number,
|
||||
offset=exp.offset,
|
||||
error_type=exp.exception.__class__.__name__,
|
||||
error_line=exp.error_line)
|
||||
|
||||
else:
|
||||
return ApiError.from_task_spec(code, message, exp.sender)
|
||||
|
||||
|
@ -69,7 +82,7 @@ class ApiError(Exception):
|
|||
class ApiErrorSchema(ma.Schema):
|
||||
class Meta:
|
||||
fields = ("code", "message", "workflow_name", "file_name", "task_name", "task_id",
|
||||
"task_data", "task_user", "hint")
|
||||
"task_data", "task_user", "hint", "line_number", "offset", "error_type", "error_line")
|
||||
|
||||
|
||||
@app.errorhandler(ApiError)
|
||||
|
|
|
@ -46,22 +46,15 @@ def get_workflow_specification(spec_id):
|
|||
return WorkflowSpecModelSchema().dump(spec)
|
||||
|
||||
|
||||
def validate_workflow_specification(spec_id, validate_study_id=None, test_until=None):
|
||||
errors = {}
|
||||
def validate_workflow_specification(spec_id, study_id=None, test_until=None):
|
||||
try:
|
||||
WorkflowService.test_spec(spec_id, validate_study_id, test_until)
|
||||
WorkflowService.test_spec(spec_id, study_id, test_until)
|
||||
WorkflowService.test_spec(spec_id, study_id, test_until, required_only=True)
|
||||
except ApiError as ae:
|
||||
ae.message = "When populating all fields ... \n" + ae.message
|
||||
errors['all'] = ae
|
||||
try:
|
||||
# Run the validation twice, the second time, just populate the required fields.
|
||||
WorkflowService.test_spec(spec_id, validate_study_id, test_until, required_only=True)
|
||||
except ApiError as ae:
|
||||
ae.message = "When populating only required fields ... \n" + ae.message
|
||||
errors['required'] = ae
|
||||
interpreted_errors = ValidationErrorService.interpret_validation_errors(errors)
|
||||
return ApiErrorSchema(many=True).dump(interpreted_errors)
|
||||
|
||||
error = ae
|
||||
error = ValidationErrorService.interpret_validation_error(error)
|
||||
return ApiErrorSchema(many=True).dump([error])
|
||||
return []
|
||||
|
||||
def update_workflow_specification(spec_id, body):
|
||||
if spec_id is None:
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import re
|
||||
|
||||
generic_message = """Workflow validation failed. For more information about the error, see below."""
|
||||
|
||||
# known_errors is a dictionary of errors from validation that we want to give users a hint for solving their problem.
|
||||
# The key is the known error, or part of the known error. It is a string.
|
||||
|
@ -14,7 +13,7 @@ generic_message = """Workflow validation failed. For more information about the
|
|||
|
||||
# I know this explanation is confusing. If you have ideas for clarification, pull request welcome.
|
||||
|
||||
known_errors = {'Error is Non-default exclusive outgoing sequence flow without condition':
|
||||
known_errors = {'Non-default exclusive outgoing sequence flow without condition':
|
||||
{'hint': 'Add a Condition Type to your gateway path.'},
|
||||
|
||||
'Could not set task title on task .*':
|
||||
|
@ -29,37 +28,16 @@ class ValidationErrorService(object):
|
|||
Validation is run twice,
|
||||
once where we try to fill in all form fields
|
||||
and a second time where we only fill in the required fields.
|
||||
|
||||
We get a list that contains possible errors from the validation."""
|
||||
|
||||
@staticmethod
|
||||
def interpret_validation_errors(errors):
|
||||
if len(errors) == 0:
|
||||
return ()
|
||||
|
||||
interpreted_errors = []
|
||||
|
||||
for error_type in ['all', 'required']:
|
||||
if error_type in errors:
|
||||
hint = generic_message
|
||||
for known_key in known_errors:
|
||||
regex = re.compile(known_key)
|
||||
result = regex.search(errors[error_type].message)
|
||||
if result is not None:
|
||||
if 'hint' in known_errors[known_key]:
|
||||
if 'groups' in known_errors[known_key]:
|
||||
caught = {}
|
||||
|
||||
for group in known_errors[known_key]['groups']:
|
||||
group_id = known_errors[known_key]['groups'][group]
|
||||
group_value = result.groups()[group_id]
|
||||
caught[group] = group_value
|
||||
|
||||
hint = known_errors[known_key]['hint'].format(**caught)
|
||||
else:
|
||||
hint = known_errors[known_key]['hint']
|
||||
|
||||
errors[error_type].hint = hint
|
||||
interpreted_errors.append(errors[error_type])
|
||||
|
||||
return interpreted_errors
|
||||
def interpret_validation_error(error):
|
||||
if error is None:
|
||||
return
|
||||
for known_key in known_errors:
|
||||
regex = re.compile(known_key)
|
||||
result = regex.search(error.message)
|
||||
if result is not None:
|
||||
if 'hint' in known_errors[known_key]:
|
||||
error.hint = known_errors[known_key]['hint']
|
||||
return error
|
||||
|
|
|
@ -159,8 +159,9 @@ class LookupService(object):
|
|||
xls = ExcelFile(data_model.data, engine='openpyxl')
|
||||
df = xls.parse(xls.sheet_names[0]) # Currently we only look at the fist sheet.
|
||||
df = df.convert_dtypes()
|
||||
df = df.loc[:, ~df.columns.str.contains('^Unnamed')] # Drop unnamed columns.
|
||||
df = pd.DataFrame(df).dropna(how='all') # Drop null rows
|
||||
df = pd.DataFrame(df).replace({NA: None})
|
||||
df = pd.DataFrame(df).replace({NA: ''})
|
||||
|
||||
if value_column not in df:
|
||||
raise ApiError("invalid_enum",
|
||||
|
|
|
@ -53,21 +53,15 @@ class CustomBpmnScriptEngine(BpmnScriptEngine):
|
|||
|
||||
try:
|
||||
if task.workflow.data[WorkflowProcessor.VALIDATION_PROCESS_KEY]:
|
||||
augmentMethods = Script.generate_augmented_validate_list(task, study_id, workflow_id)
|
||||
augment_methods = Script.generate_augmented_validate_list(task, study_id, workflow_id)
|
||||
else:
|
||||
augmentMethods = Script.generate_augmented_list(task, study_id, workflow_id)
|
||||
|
||||
super().execute(task, script, data, externalMethods=augmentMethods)
|
||||
except SyntaxError as e:
|
||||
raise ApiError('syntax_error',
|
||||
f'Something is wrong with your python script '
|
||||
f'please correct the following:'
|
||||
f' {script}, {e.msg}')
|
||||
except NameError as e:
|
||||
raise ApiError('name_error',
|
||||
f'something you are referencing does not exist:'
|
||||
f' {script}, {e}')
|
||||
augment_methods = Script.generate_augmented_list(task, study_id, workflow_id)
|
||||
|
||||
super().execute(task, script, data, external_methods=augment_methods)
|
||||
except WorkflowException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
raise WorkflowTaskExecException(task, f' {script}, {e}', e)
|
||||
|
||||
def evaluate_expression(self, task, expression):
|
||||
"""
|
||||
|
@ -86,7 +80,7 @@ class CustomBpmnScriptEngine(BpmnScriptEngine):
|
|||
else:
|
||||
augmentMethods = Script.generate_augmented_list(task, study_id, workflow_id)
|
||||
exp, valid = self.validateExpression(expression)
|
||||
return self._eval(exp, externalMethods=augmentMethods, **task.data)
|
||||
return self._eval(exp, external_methods=augmentMethods, **task.data)
|
||||
|
||||
except Exception as e:
|
||||
raise WorkflowTaskExecException(task,
|
||||
|
@ -331,8 +325,8 @@ class WorkflowProcessor(object):
|
|||
spec = parser.get_spec(process_id)
|
||||
except ValidationException as ve:
|
||||
raise ApiError(code="workflow_validation_error",
|
||||
message="Failed to parse Workflow Specification '%s'. \n" % workflow_spec_id +
|
||||
"Error is %s. \n" % str(ve),
|
||||
message="Failed to parse the Workflow Specification. " +
|
||||
"Error is '%s.'" % str(ve),
|
||||
file_name=ve.filename,
|
||||
task_id=ve.id,
|
||||
tag=ve.tag)
|
||||
|
|
|
@ -61,7 +61,6 @@ python-box==5.2.0
|
|||
python-dateutil==2.8.1
|
||||
python-docx==0.8.10
|
||||
python-editor==1.0.4
|
||||
python-levenshtein==0.12.0
|
||||
pytz==2020.4
|
||||
pyyaml==5.4
|
||||
recommonmark==0.6.0
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_1j7idla" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.7.0">
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_1j7idla" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.7.3">
|
||||
<bpmn:process id="Process_18biih5" isExecutable="true">
|
||||
<bpmn:startEvent id="StartEvent_1">
|
||||
<bpmn:outgoing>SequenceFlow_1pnq3kg</bpmn:outgoing>
|
||||
|
@ -8,32 +8,34 @@
|
|||
<bpmn:endEvent id="EndEvent_063bpg6">
|
||||
<bpmn:incoming>SequenceFlow_12pf6um</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:scriptTask id="Invalid_Script_Task" name="An Invalid Script Reference">
|
||||
<bpmn:scriptTask id="Invalid_Script_Task" name="A Syntax Error">
|
||||
<bpmn:incoming>SequenceFlow_1pnq3kg</bpmn:incoming>
|
||||
<bpmn:outgoing>SequenceFlow_12pf6um</bpmn:outgoing>
|
||||
<bpmn:script>a really bad error that should fail</bpmn:script>
|
||||
<bpmn:script>x = 1
|
||||
y = 2
|
||||
x + y === a</bpmn:script>
|
||||
</bpmn:scriptTask>
|
||||
<bpmn:sequenceFlow id="SequenceFlow_12pf6um" sourceRef="Invalid_Script_Task" targetRef="EndEvent_063bpg6" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_18biih5">
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="99" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_12pf6um_di" bpmnElement="SequenceFlow_12pf6um">
|
||||
<di:waypoint x="390" y="117" />
|
||||
<di:waypoint x="442" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_1pnq3kg_di" bpmnElement="SequenceFlow_1pnq3kg">
|
||||
<di:waypoint x="215" y="117" />
|
||||
<di:waypoint x="290" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="99" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="EndEvent_063bpg6_di" bpmnElement="EndEvent_063bpg6">
|
||||
<dc:Bounds x="442" y="99" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="ScriptTask_1imeym0_di" bpmnElement="Invalid_Script_Task">
|
||||
<dc:Bounds x="290" y="77" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_12pf6um_di" bpmnElement="SequenceFlow_12pf6um">
|
||||
<di:waypoint x="390" y="117" />
|
||||
<di:waypoint x="442" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_1j7idla" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.7.3">
|
||||
<bpmn:process id="Process_18biih5" isExecutable="true">
|
||||
<bpmn:startEvent id="StartEvent_1">
|
||||
<bpmn:outgoing>SequenceFlow_1pnq3kg</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:sequenceFlow id="SequenceFlow_1pnq3kg" sourceRef="StartEvent_1" targetRef="Invalid_Script_Task" />
|
||||
<bpmn:endEvent id="EndEvent_063bpg6">
|
||||
<bpmn:incoming>SequenceFlow_12pf6um</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:scriptTask id="Invalid_Script_Task" name="An Invalid Variable">
|
||||
<bpmn:incoming>SequenceFlow_1pnq3kg</bpmn:incoming>
|
||||
<bpmn:outgoing>SequenceFlow_12pf6um</bpmn:outgoing>
|
||||
<bpmn:script>x = 1
|
||||
y = 2
|
||||
x + a == 3</bpmn:script>
|
||||
</bpmn:scriptTask>
|
||||
<bpmn:sequenceFlow id="SequenceFlow_12pf6um" sourceRef="Invalid_Script_Task" targetRef="EndEvent_063bpg6" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_18biih5">
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_12pf6um_di" bpmnElement="SequenceFlow_12pf6um">
|
||||
<di:waypoint x="390" y="117" />
|
||||
<di:waypoint x="442" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_1pnq3kg_di" bpmnElement="SequenceFlow_1pnq3kg">
|
||||
<di:waypoint x="215" y="117" />
|
||||
<di:waypoint x="290" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="99" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="EndEvent_063bpg6_di" bpmnElement="EndEvent_063bpg6">
|
||||
<dc:Bounds x="442" y="99" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="ScriptTask_1imeym0_di" bpmnElement="Invalid_Script_Task">
|
||||
<dc:Bounds x="290" y="77" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
|
@ -122,7 +122,7 @@ class TestStudyService(BaseTest):
|
|||
self.assertEqual("Cancer Center's PRC Approval Form", documents["UVACompl_PRCAppr"]['description'])
|
||||
self.assertEqual("UVA Compliance", documents["UVACompl_PRCAppr"]['category1'])
|
||||
self.assertEqual("PRC Approval", documents["UVACompl_PRCAppr"]['category2'])
|
||||
self.assertEqual(None, documents["UVACompl_PRCAppr"]['category3'])
|
||||
self.assertEqual("", documents["UVACompl_PRCAppr"]['category3'])
|
||||
self.assertEqual("CRC", documents["UVACompl_PRCAppr"]['Who Uploads?'])
|
||||
self.assertEqual(0, documents["UVACompl_PRCAppr"]['count'])
|
||||
self.assertEqual(True, documents["UVACompl_PRCAppr"]['required'])
|
||||
|
|
|
@ -10,7 +10,7 @@ class TestFormFieldName(BaseTest):
|
|||
|
||||
json_data = json.loads(rv.get_data(as_text=True))
|
||||
self.assertEqual(json_data[0]['message'],
|
||||
'When populating all fields ... \nInvalid Field name: "user-title". A field ID must begin '
|
||||
'Invalid Field name: "user-title". A field ID must begin '
|
||||
'with a letter, and can only contain letters, numbers, and "_"')
|
||||
|
||||
def test_form_field_name_with_period(self):
|
||||
|
|
|
@ -10,5 +10,5 @@ class TestFormFieldType(BaseTest):
|
|||
|
||||
json_data = json.loads(rv.get_data(as_text=True))
|
||||
self.assertEqual(json_data[0]['message'],
|
||||
'When populating all fields ... \nType is missing for field "name". A field type must be provided.')
|
||||
'Type is missing for field "name". A field type must be provided.')
|
||||
# print('TestFormFieldType: Good Form')
|
||||
|
|
|
@ -59,7 +59,6 @@ class TestWorkflowSpecValidation(BaseTest):
|
|||
app.config['PB_ENABLED'] = True
|
||||
self.validate_all_loaded_workflows()
|
||||
|
||||
|
||||
def validate_all_loaded_workflows(self):
|
||||
workflows = session.query(WorkflowSpecModel).all()
|
||||
errors = []
|
||||
|
@ -71,12 +70,12 @@ class TestWorkflowSpecValidation(BaseTest):
|
|||
def test_invalid_expression(self):
|
||||
self.load_example_data()
|
||||
errors = self.validate_workflow("invalid_expression")
|
||||
self.assertEqual(2, len(errors))
|
||||
self.assertEqual(1, len(errors))
|
||||
self.assertEqual("workflow_validation_exception", errors[0]['code'])
|
||||
self.assertEqual("ExclusiveGateway_003amsm", errors[0]['task_id'])
|
||||
self.assertEqual("Has Bananas Gateway", errors[0]['task_name'])
|
||||
self.assertEqual("invalid_expression.bpmn", errors[0]['file_name'])
|
||||
self.assertEqual('When populating all fields ... \nExclusiveGateway_003amsm: Error evaluating expression \'this_value_does_not_exist==true\', '
|
||||
self.assertEqual('ExclusiveGateway_003amsm: Error evaluating expression \'this_value_does_not_exist==true\', '
|
||||
'name \'this_value_does_not_exist\' is not defined', errors[0]["message"])
|
||||
self.assertIsNotNone(errors[0]['task_data'])
|
||||
self.assertIn("has_bananas", errors[0]['task_data'])
|
||||
|
@ -84,7 +83,7 @@ class TestWorkflowSpecValidation(BaseTest):
|
|||
def test_validation_error(self):
|
||||
self.load_example_data()
|
||||
errors = self.validate_workflow("invalid_spec")
|
||||
self.assertEqual(2, len(errors))
|
||||
self.assertEqual(1, len(errors))
|
||||
self.assertEqual("workflow_validation_error", errors[0]['code'])
|
||||
self.assertEqual("StartEvent_1", errors[0]['task_id'])
|
||||
self.assertEqual("invalid_spec.bpmn", errors[0]['file_name'])
|
||||
|
@ -93,7 +92,7 @@ class TestWorkflowSpecValidation(BaseTest):
|
|||
def test_invalid_script(self):
|
||||
self.load_example_data()
|
||||
errors = self.validate_workflow("invalid_script")
|
||||
self.assertEqual(2, len(errors))
|
||||
self.assertEqual(1, len(errors))
|
||||
self.assertEqual("workflow_validation_exception", errors[0]['code'])
|
||||
#self.assertTrue("NoSuchScript" in errors[0]['message'])
|
||||
self.assertEqual("Invalid_Script_Task", errors[0]['task_id'])
|
||||
|
@ -103,12 +102,23 @@ class TestWorkflowSpecValidation(BaseTest):
|
|||
def test_invalid_script2(self):
|
||||
self.load_example_data()
|
||||
errors = self.validate_workflow("invalid_script2")
|
||||
self.assertEqual(2, len(errors))
|
||||
self.assertEqual(1, len(errors))
|
||||
self.assertEqual("workflow_validation_exception", errors[0]['code'])
|
||||
self.assertEqual("Invalid_Script_Task", errors[0]['task_id'])
|
||||
self.assertEqual("An Invalid Script Reference", errors[0]['task_name'])
|
||||
self.assertEqual(3, errors[0]['line_number'])
|
||||
self.assertEqual(9, errors[0]['offset'])
|
||||
self.assertEqual("SyntaxError", errors[0]['error_type'])
|
||||
self.assertEqual("A Syntax Error", errors[0]['task_name'])
|
||||
self.assertEqual("invalid_script2.bpmn", errors[0]['file_name'])
|
||||
|
||||
def test_invalid_script3(self):
|
||||
self.load_example_data()
|
||||
errors = self.validate_workflow("invalid_script3")
|
||||
self.assertEqual(1, len(errors))
|
||||
self.assertEqual("Invalid_Script_Task", errors[0]['task_id'])
|
||||
self.assertEqual(3, errors[0]['line_number'])
|
||||
self.assertEqual("NameError", errors[0]['error_type'])
|
||||
|
||||
def test_repeating_sections_correctly_populated(self):
|
||||
self.load_example_data()
|
||||
spec_model = self.load_test_spec('repeat_form')
|
||||
|
|
Loading…
Reference in New Issue